From d26ca5963670b3cfb9977a639fb16071a3500d0f Mon Sep 17 00:00:00 2001 From: Carl Alexander Date: Tue, 23 Feb 2021 17:58:50 -0500 Subject: [PATCH] feat: add dynamodb object cache --- bootstrap.php | 24 + composer.json | 1 + grumphp.yml | 3 +- object-cache-api.php | 232 +++ src/CloudProvider/Aws/AbstractClient.php | 30 +- src/CloudProvider/Aws/DynamoDbClient.php | 103 ++ src/CloudProvider/Aws/LambdaClient.php | 5 +- src/CloudProvider/Aws/S3Client.php | 5 +- .../CloudStorageConfiguration.php | 2 +- src/Configuration/ConsoleConfiguration.php | 3 +- src/Configuration/EmailConfiguration.php | 2 +- .../EventManagementConfiguration.php | 1 - .../ObjectCacheConfiguration.php | 48 + src/Configuration/WordPressConfiguration.php | 73 +- src/Configuration/YmirConfiguration.php | 5 + src/Console/EditAttachmentImageCommand.php | 10 +- src/Console/InstallObjectCacheCommand.php | 103 ++ src/DependencyInjection/Container.php | 8 + .../ServiceLocatorTrait.php | 2 +- src/Http/Client.php | 172 +++ .../AbstractPersistentObjectCache.php | 609 ++++++++ src/ObjectCache/DynamoDbObjectCache.php | 207 +++ src/ObjectCache/ObjectCacheInterface.php | 107 ++ .../PersistentObjectCacheInterface.php | 25 + .../PreloadedObjectCacheInterface.php | 25 + src/ObjectCache/WordPressObjectCache.php | 139 ++ src/Plugin.php | 51 +- src/Subscriber/HttpApiSubscriber.php | 49 - src/Support/Collection.php | 241 ++++ stubs/object-cache.php | 22 + tests/Mock/DynamoDbClientMockTrait.php | 30 + ...pMockTrait.php => HttpClientMockTrait.php} | 9 +- .../CloudProvider/Aws/DynamoDbClientTest.php | 236 ++++ .../CloudProvider/Aws/LambdaClientTest.php | 18 +- tests/Unit/CloudProvider/Aws/S3ClientTest.php | 20 +- .../Unit/CloudProvider/Aws/SesClientTest.php | 6 +- .../AbstractPersistentObjectCacheTest.php | 1258 +++++++++++++++++ .../ObjectCache/DynamoDbObjectCacheTest.php | 492 +++++++ .../Unit/Subscriber/HttpApiSubscriberTest.php | 52 - tests/bootstrap.php | 2 - ymir.php | 16 +- 41 files changed, 4224 insertions(+), 222 deletions(-) create mode 100644 bootstrap.php create mode 100644 object-cache-api.php create mode 100644 src/CloudProvider/Aws/DynamoDbClient.php create mode 100644 src/Configuration/ObjectCacheConfiguration.php create mode 100644 src/Console/InstallObjectCacheCommand.php create mode 100644 src/Http/Client.php create mode 100644 src/ObjectCache/AbstractPersistentObjectCache.php create mode 100644 src/ObjectCache/DynamoDbObjectCache.php create mode 100644 src/ObjectCache/ObjectCacheInterface.php create mode 100644 src/ObjectCache/PersistentObjectCacheInterface.php create mode 100644 src/ObjectCache/PreloadedObjectCacheInterface.php create mode 100644 src/ObjectCache/WordPressObjectCache.php delete mode 100644 src/Subscriber/HttpApiSubscriber.php create mode 100644 src/Support/Collection.php create mode 100644 stubs/object-cache.php create mode 100644 tests/Mock/DynamoDbClientMockTrait.php rename tests/Mock/{WPHttpMockTrait.php => HttpClientMockTrait.php} (69%) create mode 100644 tests/Unit/CloudProvider/Aws/DynamoDbClientTest.php create mode 100644 tests/Unit/ObjectCache/AbstractPersistentObjectCacheTest.php create mode 100644 tests/Unit/ObjectCache/DynamoDbObjectCacheTest.php delete mode 100644 tests/Unit/Subscriber/HttpApiSubscriberTest.php diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..af4f5e1 --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (version_compare(PHP_VERSION, '7.2', '<')) { + exit(sprintf('Ymir requires PHP 7.2 or higher. Your WordPress site is using PHP %s.', PHP_VERSION)); +} + +// Setup class autoloader +require_once dirname(__FILE__).'/src/Autoloader.php'; +\Ymir\Plugin\Autoloader::register(); + +global $ymir; + +$ymir = new \Ymir\Plugin\Plugin(__DIR__.'/ymir.php'); diff --git a/composer.json b/composer.json index ca5d85b..4594647 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ ], "require": { "php": "^7.2 || ^8.0", + "ext-curl": "*", "ext-json": "*" }, "require-dev": { diff --git a/grumphp.yml b/grumphp.yml index afbdd12..c4908f4 100644 --- a/grumphp.yml +++ b/grumphp.yml @@ -35,7 +35,8 @@ grumphp: - 'src/Configuration' - 'src/CloudStorage/CloudStorageStreamWrapper.php' - 'src/Email/Email.php' - - 'src/EventManagement/EventManager.php' + - 'src/ObjectCache/AbstractPersistentObjectCache.php' + - 'src/Support/Collection.php' - 'tests' phpunit: always_execute: true diff --git a/object-cache-api.php b/object-cache-api.php new file mode 100644 index 0000000..9905eeb --- /dev/null +++ b/object-cache-api.php @@ -0,0 +1,232 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +require_once __DIR__.'/bootstrap.php'; + +use Ymir\Plugin\ObjectCache\ObjectCacheInterface; +use Ymir\Plugin\ObjectCache\PersistentObjectCacheInterface; +use Ymir\Plugin\ObjectCache\PreloadedObjectCacheInterface; + +/** + * Ymir object cache API. + * + * @link https://developer.wordpress.org/reference/classes/wp_object_cache + */ + +/** + * Adds data to the cache, if the cache key doesn't already exist. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_add + */ +function wp_cache_add($key, $data,$group = '', $expire = 0): bool +{ + global $wp_object_cache; + + return $wp_object_cache->add(trim((string) $group) ?: 'default', (string) $key, $data, (int) $expire); +} + +/** + * Adds a group or set of groups to the list of global groups. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_add_global_groups + */ +function wp_cache_add_global_groups($groups) +{ + global $wp_object_cache; + + $wp_object_cache->addGlobalGroups((array) $groups); +} + +/** + * Adds a group or set of groups to the list of non-persistent groups. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_add_non_persistent_groups + */ +function wp_cache_add_non_persistent_groups($groups) +{ + global $wp_object_cache; + + $wp_object_cache->addNonPersistentGroups((array) $groups); +} + +/** + * Closes the cache. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_close + */ +function wp_cache_close(): bool +{ + global $wp_object_cache; + + return $wp_object_cache->close(); +} + +/** + * Decrements numeric cache item's value. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_decr + */ +function wp_cache_decr($key, $offset = 1, $group = '') +{ + global $wp_object_cache; + + return $wp_object_cache->decrement(trim((string) $group) ?: 'default', (string) $key, (int) $offset); +} + +/** + * Removes the cache contents matching key and group. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_delete + */ +function wp_cache_delete($key, $group = ''): bool +{ + global $wp_object_cache; + + return $wp_object_cache->delete(trim((string) $group) ?: 'default', (string) $key); +} + +/** + * Removes all cache items. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_flush + */ +function wp_cache_flush(): bool +{ + global $wp_object_cache; + + return $wp_object_cache->flush(); +} + +/** + * Retrieves the cache contents from the cache by key and group. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_get + */ +function wp_cache_get($key, $group = '', $force = false, &$found = null) +{ + global $wp_object_cache; + + return $wp_object_cache->get(trim((string) $group) ?: 'default', (string) $key, (bool) $force, $found); +} + +/** + * Retrieves multiple values from the cache in one call. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_get_multiple + */ +function wp_cache_get_multiple($keys, $group = '', $force = false): array +{ + global $wp_object_cache; + + return $wp_object_cache->getMultiple(trim((string) $group) ?: 'default', (array) $keys, (bool) $force); +} + +/** + * Increment numeric cache item's value. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_incr + */ +function wp_cache_incr($key, $offset = 1, $group = '') +{ + global $wp_object_cache; + + return $wp_object_cache->increment(trim((string) $group) ?: 'default', (string) $key, (int) $offset); +} + +/** + * Sets up Object Cache Global and assigns it. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_init + */ +function wp_cache_init() +{ + global $wp_object_cache, $ymir; + + try { + $objectCache = $ymir->getContainer()->get('ymir_object_cache'); + + if (!$objectCache instanceof ObjectCacheInterface) { + throw new RuntimeException('Object cache needs to implement ObjectCacheInterface'); + } elseif ($objectCache instanceof PersistentObjectCacheInterface && !$objectCache->isAvailable()) { + throw new RuntimeException('Persistent object cache is unavailable'); + } + + $wp_object_cache = $objectCache; + + if ($objectCache instanceof PreloadedObjectCacheInterface) { + $objectCache->load(); + } + } catch (Exception $exception) { + $wp_object_cache = $ymir->getContainer()->get('wordpress_object_cache'); + } + +} + +/** + * Replaces the contents of the cache with new data. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_replace + */ +function wp_cache_replace($key, $data, $group = '', $expire = 0): bool +{ + global $wp_object_cache; + + return $wp_object_cache->replace(trim((string) $group) ?: 'default', (string) $key, $data, (int) $expire); +} + +/** + * Reset internal cache keys and structures. + * + * If the cache back end uses global blog or site IDs as part of its cache keys, + * this function instructs the back end to reset those keys and perform any cleanup + * since blog or site IDs have changed since cache init. + * + * This function is deprecated. Use wp_cache_switch_to_blog() instead of this + * function when preparing the cache for a blog switch. For clearing the cache + * during unit tests, consider using wp_cache_init(). wp_cache_init() is not + * recommended outside of unit tests as the performance penalty for using it is + * high. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_reset + */ +function wp_cache_reset() { + _deprecated_function(__FUNCTION__, '3.5.0', 'WP_Object_Cache::reset()'); +} + +/** + * Saves the data to the cache. + * + * Differs from wp_cache_add() and wp_cache_replace() in that it will always write data. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_save + */ +function wp_cache_set($key, $data, $group = '', $expire = 0): bool +{ + global $wp_object_cache; + + return $wp_object_cache->set(trim((string) $group) ?: 'default', (string) $key, $data, (int) $expire); +} + +/** + * Switches the internal blog ID. + * + * This changes the blog id used to create keys in blog specific groups. + * + * @link https://developer.wordpress.org/reference/functions/wp_cache_switch_to_blog + */ +function wp_cache_switch_to_blog($blogId) +{ + global $wp_object_cache; + + $wp_object_cache->switchToBlog((int) $blogId); +} diff --git a/src/CloudProvider/Aws/AbstractClient.php b/src/CloudProvider/Aws/AbstractClient.php index 7e736bd..79a2103 100644 --- a/src/CloudProvider/Aws/AbstractClient.php +++ b/src/CloudProvider/Aws/AbstractClient.php @@ -13,11 +13,20 @@ namespace Ymir\Plugin\CloudProvider\Aws; +use Ymir\Plugin\Http\Client; + /** * Base AWS client. */ abstract class AbstractClient { + /** + * The Ymir HTTP client. + * + * @var Client + */ + private $client; + /** * The AWS API key. * @@ -46,19 +55,12 @@ abstract class AbstractClient */ private $securityToken; - /** - * The WordPress HTTP transport. - * - * @var \WP_Http - */ - private $transport; - /** * Constructor. */ - public function __construct(\WP_Http $transport, string $key, string $region, string $secret) + public function __construct(Client $client, string $key, string $region, string $secret) { - $this->transport = $transport; + $this->client = $client; $this->key = $key; $this->region = $region; $this->secret = $secret; @@ -142,15 +144,7 @@ protected function request(string $method, string $uri, ?string $body = null, ar $arguments['body'] = $body; } - $response = $this->transport->request($this->createRequestUrl($uri), $arguments); - - if ($response instanceof \WP_Error) { - throw new \RuntimeException($response->get_error_message()); - } elseif (!is_array($response)) { - throw new \RuntimeException('Response must be an array'); - } - - return $response; + return $this->client->request($this->createRequestUrl($uri), $arguments); } /** diff --git a/src/CloudProvider/Aws/DynamoDbClient.php b/src/CloudProvider/Aws/DynamoDbClient.php new file mode 100644 index 0000000..50b1097 --- /dev/null +++ b/src/CloudProvider/Aws/DynamoDbClient.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\CloudProvider\Aws; + +/** + * The client for AWS DynamoDB API. + */ +class DynamoDbClient extends AbstractClient +{ + /** + * Get one or more items from one or more tables. + */ + public function batchGetItem(array $arguments): array + { + $response = $this->perform('BatchGetItem', $arguments); + + if (200 !== $this->parseResponseStatusCode($response)) { + throw new \RuntimeException('Unable to get cache items'); + } + + $items = json_decode($response['body'], true); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new \RuntimeException('Unable to decode response from DynamoDB API'); + } + + return $items; + } + + /** + * Deletes a single item in a table by primary key. + */ + public function deleteItem(array $arguments) + { + $response = $this->perform('DeleteItem', $arguments); + + if (200 !== $this->parseResponseStatusCode($response)) { + throw new \RuntimeException('Unable to delete cache item'); + } + } + + /** + * Get one item from a DynamoDB table. + */ + public function getItem(array $arguments) + { + $response = $this->perform('GetItem', $arguments); + + if (200 !== $this->parseResponseStatusCode($response)) { + throw new \RuntimeException('Unable to delete cache item'); + } + + $item = json_decode($response['body'], true); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new \RuntimeException('Unable to decode response from DynamoDB API'); + } + + return $item; + } + + /** + * Creates a new item, or replaces an old item with a new item. + */ + public function putItem(array $arguments) + { + $response = $this->perform('PutItem', $arguments); + + if (200 !== $this->parseResponseStatusCode($response)) { + throw new \RuntimeException('Unable to save cache item'); + } + } + + /** + * {@inheritdoc} + */ + protected function getService(): string + { + return 'dynamodb'; + } + + /** + * Perform the given operation DynamoDB operation. + */ + private function perform(string $operation, array $arguments = []): array + { + return $this->request('post', '/', json_encode($arguments), [ + 'content-type' => 'application/x-amz-json-1.0', + 'x-amz-target' => sprintf('DynamoDB_20120810.%s', $operation), + ]); + } +} diff --git a/src/CloudProvider/Aws/LambdaClient.php b/src/CloudProvider/Aws/LambdaClient.php index bec5881..5ef8398 100644 --- a/src/CloudProvider/Aws/LambdaClient.php +++ b/src/CloudProvider/Aws/LambdaClient.php @@ -15,6 +15,7 @@ use Ymir\Plugin\Console; use Ymir\Plugin\Console\ConsoleClientInterface; +use Ymir\Plugin\Http\Client; /** * The client for AWS Lambda API. @@ -38,9 +39,9 @@ class LambdaClient extends AbstractClient implements ConsoleClientInterface /** * {@inheritdoc} */ - public function __construct(\WP_Http $transport, string $functionName, string $key, string $region, string $secret, string $siteUrl) + public function __construct(Client $client, string $functionName, string $key, string $region, string $secret, string $siteUrl) { - parent::__construct($transport, $key, $region, $secret); + parent::__construct($client, $key, $region, $secret); $this->functionName = $functionName; $this->siteUrl = $siteUrl; diff --git a/src/CloudProvider/Aws/S3Client.php b/src/CloudProvider/Aws/S3Client.php index 64b51d3..82c4ffc 100644 --- a/src/CloudProvider/Aws/S3Client.php +++ b/src/CloudProvider/Aws/S3Client.php @@ -14,6 +14,7 @@ namespace Ymir\Plugin\CloudProvider\Aws; use Ymir\Plugin\CloudStorage\CloudStorageClientInterface; +use Ymir\Plugin\Http\Client; /** * The client for AWS S3 API. @@ -30,9 +31,9 @@ class S3Client extends AbstractClient implements CloudStorageClientInterface /** * Constructor. */ - public function __construct(\WP_Http $transport, string $bucket, string $key, string $region, string $secret) + public function __construct(Client $client, string $bucket, string $key, string $region, string $secret) { - parent::__construct($transport, $key, $region, $secret); + parent::__construct($client, $key, $region, $secret); $this->bucket = $bucket; } diff --git a/src/Configuration/CloudStorageConfiguration.php b/src/Configuration/CloudStorageConfiguration.php index 07cb136..15e942c 100644 --- a/src/Configuration/CloudStorageConfiguration.php +++ b/src/Configuration/CloudStorageConfiguration.php @@ -29,7 +29,7 @@ class CloudStorageConfiguration implements ContainerConfigurationInterface public function modify(Container $container) { $container['cloud_storage_client'] = $container->service(function (Container $container) { - return new S3Client($container['http_transport'], $container['cloud_provider_store'], $container['cloud_provider_key'], $container['cloud_provider_region'], $container['cloud_provider_secret']); + return new S3Client($container['ymir_http_client'], $container['cloud_provider_store'], $container['cloud_provider_key'], $container['cloud_provider_region'], $container['cloud_provider_secret']); }); $container['cloud_storage_protocol'] = CloudStorageStreamWrapper::PROTOCOL.'://'; } diff --git a/src/Configuration/ConsoleConfiguration.php b/src/Configuration/ConsoleConfiguration.php index 843c074..5287093 100644 --- a/src/Configuration/ConsoleConfiguration.php +++ b/src/Configuration/ConsoleConfiguration.php @@ -35,12 +35,13 @@ public function modify(Container $container) new Console\CreateCroppedImageCommand($container['file_manager'], $container['event_manager']), new Console\CreateSiteIconCommand($container['file_manager'], $container['event_manager'], $container['site_icon']), new Console\EditAttachmentImageCommand($container['file_manager']), + new Console\InstallObjectCacheCommand($container['content_directory'], $container['filesystem'], $container['plugin_dir_path']), new Console\ResizeAttachmentImageCommand($container['file_manager']), new Console\RunAllCronCommand($container['console_client'], $container['site_query']), ]; }); $container['console_client'] = $container->service(function (Container $container) { - return new LambdaClient($container['http_transport'], $container['cloud_provider_function_name'], $container['cloud_provider_key'], $container['cloud_provider_region'], $container['cloud_provider_secret'], $container['site_url']); + return new LambdaClient($container['ymir_http_client'], $container['cloud_provider_function_name'], $container['cloud_provider_key'], $container['cloud_provider_region'], $container['cloud_provider_secret'], $container['site_url']); }); } } diff --git a/src/Configuration/EmailConfiguration.php b/src/Configuration/EmailConfiguration.php index 4d16462..639fa99 100644 --- a/src/Configuration/EmailConfiguration.php +++ b/src/Configuration/EmailConfiguration.php @@ -29,7 +29,7 @@ class EmailConfiguration implements ContainerConfigurationInterface public function modify(Container $container) { $container['email_client'] = $container->service(function (Container $container) { - return new SesClient($container['http_transport'], $container['cloud_provider_key'], $container['cloud_provider_region'], $container['cloud_provider_secret']); + return new SesClient($container['ymir_http_client'], $container['cloud_provider_key'], $container['cloud_provider_region'], $container['cloud_provider_secret']); }); $container['email'] = function (Container $container) { return new Email($container['event_manager'], $container['default_email_from'], $container['phpmailer'], $container['blog_charset']); diff --git a/src/Configuration/EventManagementConfiguration.php b/src/Configuration/EventManagementConfiguration.php index 723160b..23c800c 100644 --- a/src/Configuration/EventManagementConfiguration.php +++ b/src/Configuration/EventManagementConfiguration.php @@ -44,7 +44,6 @@ public function modify(Container $container) $container['subscribers'] = $container->service(function (Container $container) { return [ new Subscriber\AssetsSubscriber($container['site_url'], $container['assets_url'], $container['ymir_project_type']), - new Subscriber\HttpApiSubscriber(), new Subscriber\ImageEditorSubscriber($container['console_client'], $container['file_manager']), new Subscriber\PluploadSubscriber($container['plugin_relative_path'], $container['rest_namespace'], $container['assets_url'], $container['plupload_error_messages']), new Subscriber\RedirectSubscriber($container['ymir_primary_domain_name'], $container['is_multisite']), diff --git a/src/Configuration/ObjectCacheConfiguration.php b/src/Configuration/ObjectCacheConfiguration.php new file mode 100644 index 0000000..f954ad9 --- /dev/null +++ b/src/Configuration/ObjectCacheConfiguration.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\Configuration; + +use Ymir\Plugin\CloudProvider\Aws\DynamoDbClient; +use Ymir\Plugin\DependencyInjection\Container; +use Ymir\Plugin\DependencyInjection\ContainerConfigurationInterface; +use Ymir\Plugin\ObjectCache\DynamoDbObjectCache; +use Ymir\Plugin\ObjectCache\WordPressObjectCache; + +/** + * Configures the dependency injection container with object cache and services. + */ +class ObjectCacheConfiguration implements ContainerConfigurationInterface +{ + /** + * {@inheritdoc} + */ + public function modify(Container $container) + { + $container['dynamodb_client'] = $container->service(function (Container $container) { + return new DynamoDbClient($container['ymir_http_client'], $container['cloud_provider_key'], $container['cloud_provider_region'], $container['cloud_provider_secret']); + }); + $container['wordpress_object_cache'] = $container->service(function (Container $container) { + if (!class_exists(\WP_Object_Cache::class)) { + require_once ABSPATH.WPINC.'/class-wp-object-cache.php'; + } + + return new WordPressObjectCache(new \WP_Object_Cache()); + }); + $container['ymir_object_cache'] = $container->service(function (Container $container) { + $table = getenv('YMIR_CACHE_TABLE'); + + return is_string($table) ? new DynamoDbObjectCache($container['dynamodb_client'], $container['is_multisite'], $table) : $container['wordpress_object_cache']; + }); + } +} diff --git a/src/Configuration/WordPressConfiguration.php b/src/Configuration/WordPressConfiguration.php index eaf5d90..13778cf 100644 --- a/src/Configuration/WordPressConfiguration.php +++ b/src/Configuration/WordPressConfiguration.php @@ -26,10 +26,14 @@ class WordPressConfiguration implements ContainerConfigurationInterface */ public function modify(Container $container) { - $container['blog_charset'] = get_bloginfo('charset'); + $container['blog_charset'] = $container->service(function () { + return get_bloginfo('charset'); + }); $container['content_directory'] = WP_CONTENT_DIR; $container['content_url'] = WP_CONTENT_URL; - $container['current_user'] = wp_get_current_user(); + $container['current_user'] = $container->service(function () { + return wp_get_current_user(); + }); $container['default_email_from'] = $container->service(function () { $sitename = strtolower(wp_parse_url(network_home_url(), PHP_URL_HOST)); @@ -39,7 +43,14 @@ public function modify(Container $container) return 'wordpress@'.$sitename; }); - $container['http_transport'] = _wp_http_get_object(); + $container['filesystem'] = $container->service(function () { + if (!class_exists(\WP_Filesystem_Direct::class)) { + require_once ABSPATH.'wp-admin/includes/class-wp-filesystem-base.php'; + require_once ABSPATH.'wp-admin/includes/class-wp-filesystem-direct.php'; + } + + return new \WP_Filesystem_Direct(false); + }); $container['is_multisite'] = is_multisite(); $container['phpmailer'] = function () { if (!class_exists(\PHPMailer::class)) { @@ -48,31 +59,33 @@ public function modify(Container $container) return new \PHPMailer(true); }; - $container['plupload_error_messages'] = [ - 'queue_limit_exceeded' => __('You have attempted to queue too many files.'), - 'file_exceeds_size_limit' => __('%s exceeds the maximum upload size for this site.'), - 'zero_byte_file' => __('This file is empty. Please try another.'), - 'invalid_filetype' => __('Sorry, this file type is not permitted for security reasons.'), - 'not_an_image' => __('This file is not an image. Please try another.'), - 'image_memory_exceeded' => __('Memory exceeded. Please try another smaller file.'), - 'image_dimensions_exceeded' => __('This is larger than the maximum size. Please try another.'), - 'default_error' => __('An error occurred in the upload. Please try again later.'), - 'missing_upload_url' => __('There was a configuration error. Please contact the server administrator.'), - 'upload_limit_exceeded' => __('You may only upload 1 file.'), - 'http_error' => __('Unexpected response from the server. The file may have been uploaded successfully. Check in the Media Library or reload the page.'), - 'http_error_image' => __('Post-processing of the image failed. If this is a photo or a large image, please scale it down to 2500 pixels and upload it again.'), - 'upload_failed' => __('Upload failed.'), - 'big_upload_failed' => __('Please try uploading this file with the %1$sbrowser uploader%2$s.'), - 'big_upload_queued' => __('%s exceeds the maximum upload size for the multi-file uploader when used in your browser.'), - 'io_error' => __('IO error.'), - 'security_error' => __('Security error.'), - 'file_cancelled' => __('File canceled.'), - 'upload_stopped' => __('Upload stopped.'), - 'dismiss' => __('Dismiss'), - 'crunching' => __('Crunching…'), - 'deleted' => __('moved to the trash.'), - 'error_uploading' => __('“%s” has failed to upload.'), - ]; + $container['plupload_error_messages'] = $container->service(function () { + return [ + 'queue_limit_exceeded' => __('You have attempted to queue too many files.'), + 'file_exceeds_size_limit' => __('%s exceeds the maximum upload size for this site.'), + 'zero_byte_file' => __('This file is empty. Please try another.'), + 'invalid_filetype' => __('Sorry, this file type is not permitted for security reasons.'), + 'not_an_image' => __('This file is not an image. Please try another.'), + 'image_memory_exceeded' => __('Memory exceeded. Please try another smaller file.'), + 'image_dimensions_exceeded' => __('This is larger than the maximum size. Please try another.'), + 'default_error' => __('An error occurred in the upload. Please try again later.'), + 'missing_upload_url' => __('There was a configuration error. Please contact the server administrator.'), + 'upload_limit_exceeded' => __('You may only upload 1 file.'), + 'http_error' => __('Unexpected response from the server. The file may have been uploaded successfully. Check in the Media Library or reload the page.'), + 'http_error_image' => __('Post-processing of the image failed. If this is a photo or a large image, please scale it down to 2500 pixels and upload it again.'), + 'upload_failed' => __('Upload failed.'), + 'big_upload_failed' => __('Please try uploading this file with the %1$sbrowser uploader%2$s.'), + 'big_upload_queued' => __('%s exceeds the maximum upload size for the multi-file uploader when used in your browser.'), + 'io_error' => __('IO error.'), + 'security_error' => __('Security error.'), + 'file_cancelled' => __('File canceled.'), + 'upload_stopped' => __('Upload stopped.'), + 'dismiss' => __('Dismiss'), + 'crunching' => __('Crunching…'), + 'deleted' => __('moved to the trash.'), + 'error_uploading' => __('“%s” has failed to upload.'), + ]; + }); $container['site_icon'] = $container->service(function () { if (!class_exists(\WP_Site_Icon::class)) { require_once ABSPATH.'wp-admin/includes/class-wp-site-icon.php'; @@ -83,7 +96,9 @@ public function modify(Container $container) $container['site_query'] = $container->service(function () { return class_exists(\WP_Site_Query::class) ? new \WP_Site_Query() : null; }); - $container['site_url'] = set_url_scheme(get_home_url(), 'https'); + $container['site_url'] = $container->service(function () { + return set_url_scheme(get_home_url(), 'https'); + }); $container['uploads_basedir'] = $container->service(function () { return wp_upload_dir()['basedir'] ?? ''; }); diff --git a/src/Configuration/YmirConfiguration.php b/src/Configuration/YmirConfiguration.php index c396dc4..7d0c9e9 100644 --- a/src/Configuration/YmirConfiguration.php +++ b/src/Configuration/YmirConfiguration.php @@ -15,6 +15,7 @@ use Ymir\Plugin\DependencyInjection\Container; use Ymir\Plugin\DependencyInjection\ContainerConfigurationInterface; +use Ymir\Plugin\Http\Client; /** * Configures the dependency injection container with Ymir parameters and services. @@ -27,7 +28,11 @@ class YmirConfiguration implements ContainerConfigurationInterface public function modify(Container $container) { $container['ymir_environment'] = getenv('YMIR_ENVIRONMENT') ?: ''; + $container['ymir_http_client'] = $container->service(function (Container $container) { + return new Client($container['ymir_plugin_version']); + }); $container['ymir_primary_domain_name'] = getenv('YMIR_PRIMARY_DOMAIN_NAME') ?: ''; $container['ymir_project_type'] = getenv('YMIR_PROJECT_TYPE') ?: 'wordpress'; + $container['ymir_plugin_version'] = '1.0.1'; } } diff --git a/src/Console/EditAttachmentImageCommand.php b/src/Console/EditAttachmentImageCommand.php index 0a3fa19..19eb71f 100644 --- a/src/Console/EditAttachmentImageCommand.php +++ b/src/Console/EditAttachmentImageCommand.php @@ -13,6 +13,8 @@ namespace Ymir\Plugin\Console; +use Ymir\Plugin\Support\Collection; + /** * Command to edit an attachment image. */ @@ -119,13 +121,11 @@ protected static function getCommandName(): string */ private function deletePreviousImageVersions(array $images) { - $imageFiles = array_filter(array_map(function ($image) { + (new Collection($images))->filter()->map(function ($image) { return is_array($image) && !empty($image['file']) && preg_match('/-e[0-9]{13}-/', $image['file']) ? $image['file'] : null; - }, $images)); - - foreach ($imageFiles as $imageFile) { + })->each(function (string $imageFile) { wp_delete_file($this->fileManager->getUploadsFilePath($imageFile)); - } + }); } /** diff --git a/src/Console/InstallObjectCacheCommand.php b/src/Console/InstallObjectCacheCommand.php new file mode 100644 index 0000000..0d616df --- /dev/null +++ b/src/Console/InstallObjectCacheCommand.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\Console; + +/** + * Command that installs the object cache drop-in. + */ +class InstallObjectCacheCommand extends AbstractCommand +{ + /** + * The WordPress content directory. + * + * @var string + */ + private $contentDirectory; + + /** + * The file system. + * + * @var \WP_Filesystem_Direct + */ + private $filesystem; + + /** + * The path to the Ymir plugin directory. + * + * @var string + */ + private $pluginDirectory; + + /** + * Constructor. + */ + public function __construct(string $contentDirectory, \WP_Filesystem_Direct $filesystem, string $pluginDirectory) + { + $this->contentDirectory = $contentDirectory; + $this->filesystem = $filesystem; + $this->pluginDirectory = $pluginDirectory; + } + + /** + * {@inheritdoc} + */ + public function __invoke(array $arguments, array $options) + { + $dropin = rtrim($this->contentDirectory, '/').'/object-cache.php'; + $force = isset($options['force']); + + if (!$force && $this->filesystem->exists($dropin)) { + $this->error('Please use the "--force" option to overwrite an existing object-cache drop-in'); + } elseif (!$this->filesystem->copy(rtrim($this->pluginDirectory, '/').'/stubs/object-cache.php', $dropin, $force, (fileperms(ABSPATH.'index.php') & 0777 | 0644))) { + $this->error('Unable to copy the object cache drop-in'); + } + + if (function_exists('wp_opcache_invalidate')) { + wp_opcache_invalidate($dropin, true); + } + + $this->success('Object cache drop-in installed successfully'); + } + + /** + * {@inheritdoc} + */ + public static function getDescription(): string + { + return 'Install the Ymir object cache drop-in'; + } + + /** + * {@inheritdoc} + */ + public static function getSynopsis(): array + { + return [ + [ + 'type' => 'flag', + 'name' => 'force', + 'description' => 'Force the installation of the drop-in even if one is present already', + 'optional' => true, + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected static function getCommandName(): string + { + return 'install-object-cache'; + } +} diff --git a/src/DependencyInjection/Container.php b/src/DependencyInjection/Container.php index 2b07601..214afcc 100644 --- a/src/DependencyInjection/Container.php +++ b/src/DependencyInjection/Container.php @@ -54,6 +54,14 @@ public function configure($configurations) } } + /** + * Finds an entry of the container by its identifier and returns it. + */ + public function get($id) + { + return $this->offsetGet($id); + } + /** * {@inheritdoc} */ diff --git a/src/DependencyInjection/ServiceLocatorTrait.php b/src/DependencyInjection/ServiceLocatorTrait.php index 2e3ec76..ca1bc7e 100644 --- a/src/DependencyInjection/ServiceLocatorTrait.php +++ b/src/DependencyInjection/ServiceLocatorTrait.php @@ -32,6 +32,6 @@ protected static function getService(string $service) throw new \RuntimeException('Ymir plugin isn\'t active'); } - return $ymir->getContainer()->offsetGet($service); + return $ymir->getContainer()->get($service); } } diff --git a/src/Http/Client.php b/src/Http/Client.php new file mode 100644 index 0000000..04044a3 --- /dev/null +++ b/src/Http/Client.php @@ -0,0 +1,172 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\Http; + +use Ymir\Plugin\Support\Collection; + +/** + * Ymir HTTP client that partially mirrors the WordPress HTTP API. + * + * @see https://developer.wordpress.org/plugins/http-api + */ +class Client +{ + /** + * The cURL handle. + * + * @var resource + */ + private $handle; + + /** + * The Ymir plugin version. + * + * @var string + */ + private $version; + + /** + * Constructor. + */ + public function __construct(string $version) + { + $handle = curl_init(); + + if (!is_resource($handle)) { + throw new \RuntimeException('Unable to initialize a cURL session'); + } + + curl_setopt($handle, CURLINFO_HEADER_OUT, true); + + curl_setopt($handle, CURLOPT_HEADER, false); + curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($handle, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); + curl_setopt($handle, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); + curl_setopt($handle, CURLOPT_CAINFO, ABSPATH.WPINC.'/certificates/ca-bundle.crt'); + curl_setopt($handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS); + curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); + + $this->handle = $handle; + $this->version = $version; + } + + /** + * Close cURL session. + */ + public function __destruct() + { + if (is_resource($this->handle)) { + curl_close($this->handle); + } + } + + /** + * Send an HTTP request. + * + * @see WP_Http::request + */ + public function request(string $url, array $options = []): array + { + $options = array_merge([ + 'method' => 'GET', + 'timeout' => 5, + 'connect_timeout' => 10, + 'redirection' => 5, + 'user-agent' => sprintf('ymir-plugin/%s', $this->version), + 'headers' => [], + 'body' => null, + ], $options); + + // By default, cURL sends the "Expect" header all the time which severely impacts + // performance. Instead, we'll send it if the body is larger than 1 mb like + // Guzzle does. + // + // @see https://stackoverflow.com/questions/22381855/whole-second-delays-when-communicating-with-aws-dynamodb + $options['headers']['expect'] = !empty($options['body']) && strlen($options['body']) > 1048576 ? '100-Continue' : ''; + + if (!in_array($options['method'], ['GET', 'POST'])) { + curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['method']); + } elseif ('POST' === $options['method']) { + curl_setopt($this->handle, CURLOPT_POST, true); + } + + if ('HEAD' === $options['method']) { + curl_setopt($this->handle, CURLOPT_NOBODY, true); + } elseif (in_array($options['method'], ['POST', 'PUT'])) { + curl_setopt($this->handle, CURLOPT_POSTFIELDS, $options['body'] ?? ''); + } elseif (!empty($options['body'])) { + curl_setopt($this->handle, CURLOPT_POSTFIELDS, $options['body']); + } + + if (!empty($options['headers'])) { + curl_setopt($this->handle, CURLOPT_HTTPHEADER, array_map(function ($key, $value) { + return sprintf('%s: %s', $key, $value); + }, array_keys($options['headers']), $options['headers'])); + } + + curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT_MS, round($options['connect_timeout'] * 1000)); + curl_setopt($this->handle, CURLOPT_TIMEOUT_MS, round($options['timeout'] * 1000)); + curl_setopt($this->handle, CURLOPT_REFERER, $url); + curl_setopt($this->handle, CURLOPT_URL, $url); + curl_setopt($this->handle, CURLOPT_USERAGENT, $options['user-agent']); + + $response = $this->execute($this->handle); + + return $response; + } + + /** + * Execute cURL session. + */ + private function execute($handle): array + { + $rawHeaders = ''; + + curl_setopt($handle, CURLOPT_HEADERFUNCTION, function ($handle, $header) use (&$rawHeaders) { + $rawHeaders .= $header; + + return strlen($header); + }); + + $body = curl_exec($handle); + + curl_setopt($this->handle, CURLOPT_HEADERFUNCTION, null); + + if (curl_errno($handle)) { + throw new \RuntimeException(sprintf('cURL error %s: %s', curl_errno($handle), curl_error($handle))); + } + + $headers = explode("\n", preg_replace('/\n[ \t]/', ' ', str_replace("\r\n", "\n", $rawHeaders))); + $matches = []; + + preg_match('#^HTTP/(1\.\d)[ \t]+(\d+)[ \t]+(.+)#i', array_shift($headers), $matches); + + if (!isset($matches[2], $matches[3])) { + throw new \RuntimeException('Unable to parse response code'); + } + + return [ + 'body' => $body, + 'headers' => (new Collection($headers))->filter()->mapWithKeys(function (string $header) { + list($key, $value) = explode(':', $header, 2); + + return [strtolower($key) => preg_replace('#(\s+)#i', ' ', trim($value))]; + })->all(), + 'response' => [ + 'code' => (int) $matches[2], + 'message' => $matches[3], + ], + ]; + } +} diff --git a/src/ObjectCache/AbstractPersistentObjectCache.php b/src/ObjectCache/AbstractPersistentObjectCache.php new file mode 100644 index 0000000..c691282 --- /dev/null +++ b/src/ObjectCache/AbstractPersistentObjectCache.php @@ -0,0 +1,609 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\ObjectCache; + +use Ymir\Plugin\Support\Collection; + +/** + * A persistent object cache stores data outside the PHP runtime. + */ +abstract class AbstractPersistentObjectCache implements PersistentObjectCacheInterface, PreloadedObjectCacheInterface +{ + /** + * Save a cache item only if it doesn't exist. + * + * @var int + */ + protected const MODE_ADD = 1; + + /** + * Save a cache item only if it exists. + * + * @var int + */ + protected const MODE_REPLACE = 2; + + /** + * The current blog ID when multisite is active. + * + * @var int + */ + private $blogId; + + /** + * In-memory cache. + * + * @var array + */ + private $cache; + + /** + * List of global groups. + * + * @var array + */ + private $globalGroups = []; + + /** + * Flag whether this is a multisite installation or not. + * + * @var bool + */ + private $isMultisite; + + /** + * List of non-persistent groups. + * + * @var array + */ + private $nonPersistentGroups = []; + + /** + * Prefix used for all cache keys. + * + * @var string + */ + private $prefix; + + /** + * All the keys requested during the current script execution. + * + * @var array + */ + private $requestedKeys; + + /** + * Constructor. + */ + public function __construct(bool $isMultisite, string $prefix = '') + { + $this->cache = []; + $this->isMultisite = $isMultisite; + $this->prefix = trim($prefix); + $this->requestedKeys = []; + + if (!empty($this->prefix)) { + $this->prefix = $this->sanitizeCacheKeyPart($this->prefix); + } + } + + /** + * {@inheritdoc} + */ + public function add(string $group, string $key, $value, int $expire = 0): bool + { + if (function_exists('wp_suspend_cache_addition') && wp_suspend_cache_addition()) { + return false; + } + + return !$this->hasInMemory($this->generateCacheKey($group, $key)) ? $this->store($group, $key, $value, $expire, self::MODE_ADD) : false; + } + + /** + * {@inheritdoc} + */ + public function addGlobalGroups(array $groups) + { + $this->globalGroups = array_unique(array_merge($this->globalGroups, $groups)); + } + + /** + * {@inheritdoc} + */ + public function addNonPersistentGroups(array $groups) + { + $this->nonPersistentGroups = array_unique(array_merge($this->nonPersistentGroups, $groups)); + } + + /** + * {@inheritdoc} + */ + public function close(): bool + { + return true; + } + + /** + * {@inheritdoc} + */ + public function decrement(string $group, string $key, int $offset = 1) + { + $cacheKey = $this->generateCacheKey($group, $key); + $value = $this->getFromMemory($cacheKey); + + if (!is_int($value) && !$this->isNonPersistentGroup($group)) { + $value = $this->getFromPersistentCache($cacheKey); + } + + if (!is_int($value)) { + return false; + } + + $value -= $offset; + $value = max(0, $value); + + return $this->store($group, $key, $value) ? $value : false; + } + + /** + * {@inheritdoc} + */ + public function delete(string $group, string $key): bool + { + $cacheKey = $this->generateCacheKey($group, $key); + + if ($this->isNonPersistentGroup($group) && !$this->hasInMemory($cacheKey)) { + return false; + } + + unset($this->cache[$cacheKey], $this->requestedKeys[$cacheKey]); + + $result = true; + + if (!$this->isNonPersistentGroup($group)) { + try { + $result = $this->deleteFromPersistentCache($cacheKey); + } catch (\Exception $exception) { + $result = false; + } + } + + return $result; + } + + /** + * {@inheritdoc} + */ + public function flush(): bool + { + $this->cache = []; + $this->requestedKeys = []; + + try { + return $this->flushPersistentCache(); + } catch (\Exception $exception) { + return false; + } + } + + /** + * {@inheritdoc} + */ + public function get(string $group, string $key, bool $force = false, &$found = null) + { + $cacheKey = $this->generateCacheKey($group, $key); + + if ($this->isNonPersistentGroup($group) && !$this->hasInMemory($cacheKey)) { + $found = false; + + return false; + } elseif ((!$force || $this->isNonPersistentGroup($group)) && $this->hasInMemory($cacheKey)) { + $found = true; + + return $this->getFromMemory($cacheKey); + } + + try { + $value = $this->getFromPersistentCache($cacheKey); + } catch (\Exception $exception) { + $value = false; + } + + $found = false !== $value; + + if (false !== $value) { + $this->requestedKeys[$cacheKey] = true; + $this->storeInMemory($cacheKey, $value); + } + + return $value; + } + + /** + * {@inheritdoc} + */ + public function getMultiple(string $group, array $keys, bool $force = false): array + { + $keys = (new Collection($keys))->map(function ($key) { + return (string) $key; + }); + + $cacheKeys = $keys->mapWithKeys(function (string $key) use ($group) { + return [$key => $this->generateCacheKey($group, $key)]; + }); + $cacheKeys->each(function (string $cacheKey) { + $this->requestedKeys[$cacheKey] = true; + }); + + $values = $keys->mapWithKeys(function (string $key) use ($cacheKeys, $force, $group) { + $cacheKey = $cacheKeys[$key] ?? ''; + $value = false; + + if ((!$force || $this->isNonPersistentGroup($group)) && $this->hasInMemory($cacheKey)) { + $value = $this->getFromMemory($cacheKey); + } + + return [$key => $value]; + }); + + $keysWithMissingValues = $keys->diff($values->filter(function ($value) { + return false !== $value; + })->keys()); + + if ($keysWithMissingValues->isEmpty() || $this->isNonPersistentGroup($group)) { + return $values->all(); + } + + $valuesFromPersistentCache = new Collection($this->getFromPersistentCache($keysWithMissingValues->map(function (string $key) use ($cacheKeys) { + return $cacheKeys[$key] ?? ''; + })->filter()->all())); + + $valuesFromPersistentCache->each(function ($value, string $key) { + $this->storeInMemory($key, $value); + }); + + $keysWithMissingValues->each(function (string $key) use ($cacheKeys, $values, $valuesFromPersistentCache) { + $cacheKey = $cacheKeys[$key] ?? ''; + + if (empty($cacheKey)) { + return; + } + + $values[$key] = $valuesFromPersistentCache[$cacheKey] ?? false; + }); + + $order = $keys->flip()->all(); + $values = $values->all(); + + uksort($values, function ($a, $b) use ($order) { + return $order[$a] - $order[$b]; + }); + + return $values; + } + + /** + * {@inheritdoc} + */ + public function increment(string $group, string $key, int $offset = 1) + { + $cacheKey = $this->generateCacheKey($group, $key); + $value = $this->getFromMemory($cacheKey); + + if (!is_int($value) && !$this->isNonPersistentGroup($group)) { + $value = $this->getFromPersistentCache($cacheKey); + } + + if (!is_int($value)) { + return false; + } + + $value += $offset; + $value = max(0, $value); + + return $this->store($group, $key, $value) ? $value : false; + } + + /** + * {@inheritdoc} + */ + public function load() + { + if (!isset($_SERVER['REQUEST_METHOD'], $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI']) || !in_array(strtoupper($_SERVER['REQUEST_METHOD']), ['GET', 'HEAD'])) { + return; + } + + $requestKey = $this->generateCacheKey('ymir-preload', md5(serialize([ + 'method' => $_SERVER['REQUEST_METHOD'], + 'host' => $_SERVER['HTTP_HOST'], + 'path' => urldecode($_SERVER['REQUEST_URI']), + 'query' => urldecode($_SERVER['QUERY_STRING'] ?? ''), + ]))); + + $preloadedValues = new Collection($this->getValuesFromPersistentCache((new Collection($this->getValuesFromPersistentCache($requestKey)))->keys()->all())); + + $this->cache = $preloadedValues->all(); + $this->requestedKeys = $preloadedValues->keys()->mapWithKeys(function (string $key) { + return [$key => true]; + })->all(); + + register_shutdown_function(function () use ($requestKey) { + $this->storeValueInPersistentCache($requestKey, $this->requestedKeys); + }); + } + + /** + * {@inheritdoc} + */ + public function replace(string $group, string $key, $value, int $expire = 0): bool + { + return !$this->isNonPersistentGroup($group) || $this->hasInMemory($this->generateCacheKey($group, $key)) ? $this->store($group, $key, $value, $expire, self::MODE_REPLACE) : false; + } + + /** + * {@inheritdoc} + */ + public function set(string $group, string $key, $value, int $expire = 0): bool + { + return $this->store($group, $key, $value, $expire); + } + + /** + * {@inheritdoc} + */ + public function switchToBlog(int $blogId) + { + $this->blogId = $this->isMultisite ? $blogId : null; + } + + /** + * Delete the value stored in the persistent object cache for the given key. + */ + abstract protected function deleteValueFromPersistentCache(string $key): bool; + + /** + * Remove all values stored in the persistent object cache. + */ + abstract protected function flushPersistentCache(): bool; + + /** + * Get the values stored in the persistent object cache for the given keys. + */ + abstract protected function getValuesFromPersistentCache($keys); + + /** + * Store the given key-value pair in the persistent object cache. + */ + abstract protected function storeValueInPersistentCache(string $key, $value, int $expire = 0, int $mode = 0): bool; + + /** + * Delete the "alloptions" values stored in the persistent object cache. + * + * This option requires special handling because it can cause race conditions on high traffic sites. + * + * @see https://core.trac.wordpress.org/ticket/31245 + */ + private function deleteAllOptionsValueFromPersistentCache(): bool + { + $keys = $this->getAllOptionsKeys(); + + foreach ($keys as $key) { + $this->delete('alloptions_values', (string) $key); + } + + return $this->delete('options', 'alloptions_keys'); + } + + /** + * Delete the value stored in the persistent object cache for the given key. + */ + private function deleteFromPersistentCache(string $key): bool + { + return $this->isAllOptionsCacheKey($key) ? $this->deleteAllOptionsValueFromPersistentCache() : $this->deleteValueFromPersistentCache($key); + } + + /** + * Generate a cache key from the given group and key. + */ + private function generateCacheKey(string $group, string $key): string + { + $cacheKey = sprintf('%s:%s', $this->sanitizeCacheKeyPart($group), $this->sanitizeCacheKeyPart($key)); + $prefix = !empty($this->prefix) ? $this->prefix.':' : ''; + + if ($this->isMultisite && null !== $this->blogId && !$this->isGlobalGroup($group)) { + $prefix .= $this->blogId.':'; + } + + return $prefix.$cacheKey; + } + + /** + * Get the cache key for the `alloptions` cache value. + */ + private function getAllOptionsCacheKey(): string + { + return $this->generateCacheKey('options', 'alloptions'); + } + + /** + * Get all the array keys in the "alloptions" array stored in the object cache. + */ + private function getAllOptionsKeys(): array + { + return (new Collection($this->get('options', 'alloptions_keys')))->keys()->all(); + } + + /** + * Get the "alloptions" value stored in the persistent object cache. + * + * This option requires special handling because it can cause race conditions on high traffic sites. + * + * @see https://core.trac.wordpress.org/ticket/31245 + */ + private function getAllOptionsValueFromPersistentCache() + { + return $this->getMultiple('alloptions_values', $this->getAllOptionsKeys()); + } + + /** + * Get the data stored in the in-memory object cache for the given key. + */ + private function getFromMemory(string $key) + { + if (!isset($this->cache[$key])) { + return null; + } + + return is_object($this->cache[$key]) ? clone $this->cache[$key] : $this->cache[$key]; + } + + /** + * Get the values stored in the persistent object cache for the given keys. + */ + private function getFromPersistentCache($keys) + { + return is_string($keys) && $this->isAllOptionsCacheKey($keys) ? $this->getAllOptionsValueFromPersistentCache() : $this->getValuesFromPersistentCache($keys); + } + + /** + * Checks if the given key has data stored in the in-memory object cache. + */ + private function hasInMemory(string $key): bool + { + return isset($this->cache[$key]); + } + + /** + * Checks if the given cache key is for the `alloptions` cache value. + */ + private function isAllOptionsCacheKey(string $key): bool + { + return $this->getAllOptionsCacheKey() === $key; + } + + /** + * Checks if the given group is a global group. + */ + private function isGlobalGroup(string $group): bool + { + return in_array($group, $this->globalGroups); + } + + /** + * Checks if the given group is a non-persistent group. + */ + private function isNonPersistentGroup(string $group): bool + { + return in_array($group, $this->nonPersistentGroups); + } + + /** + * Sanitize part of the cache key. + */ + private function sanitizeCacheKeyPart(string $part): string + { + return preg_replace('/[: ]/', '-', strtolower($part)); + } + + /** + * Store the given key-value pair. + */ + private function store(string $group, string $key, $value, int $expire = 0, int $mode = 0): bool + { + $cacheKey = $this->generateCacheKey($group, $key); + $result = true; + + if (!$this->isNonPersistentGroup($group)) { + try { + $result = $this->storeInPersistentCache($cacheKey, $value, $expire, $mode); + } catch (\Exception $exception) { + $result = false; + } + } + + if ($result) { + $this->storeInMemory($cacheKey, $value); + } + + return $result; + } + + /** + * Store the "alloptions" value in the persistent object cache. + * + * This option requires special handling because it can cause race conditions on high traffic sites. + * + * @see https://core.trac.wordpress.org/ticket/31245 + */ + private function storeAllOptionsInPersistentCache($options, int $expire = 0): bool + { + $keys = (new Collection($this->getAllOptionsKeys()))->mapWithKeys(function (string $key) { + return [$key => true]; + })->all(); + $options = new Collection($options); + $newOptions = $storedOptions = new Collection($this->getAllOptionsValueFromPersistentCache()); + + $options->filter(function ($value, $key) use ($storedOptions) { + return !isset($storedOptions[$key]) || $storedOptions[$key] !== $value; + })->each(function ($value, $key) use (&$keys) { + if (!isset($keys[$key])) { + $keys[$key] = true; + } + })->each(function ($value, $key) use ($expire, $newOptions) { + if (!$this->set('alloptions_values', (string) $key, $value, $expire)) { + throw new \RuntimeException('Unable to set alloptions value'); + } + + $newOptions[$key] = $value; + }); + + $storedOptions->keys()->filter(function ($key) use ($options) { + return !isset($options[$key]); + })->each(function ($key) use (&$keys) { + if (isset($keys[$key])) { + unset($keys[$key]); + } + })->each(function ($key) use ($newOptions) { + if (!$this->delete('alloptions_values', (string) $key)) { + throw new \RuntimeException('Unable to delete alloptions value'); + } + + unset($newOptions[$key]); + }); + + if (!$this->set('options', 'alloptions_keys', $keys)) { + throw new \RuntimeException('Unable to save alloptions keys'); + } + + $this->storeInMemory($this->getAllOptionsCacheKey(), $newOptions->all()); + + return true; + } + + /** + * Store the given data in the in-memory object cache. + */ + private function storeInMemory(string $key, $value) + { + $this->cache[$key] = is_object($value) ? clone $value : $value; + } + + /** + * Store the given key-value pair in the persistent object cache. + */ + private function storeInPersistentCache(string $key, $value, int $expire = 0, int $mode = 0): bool + { + return $this->isAllOptionsCacheKey($key) ? $this->storeAllOptionsInPersistentCache($value, $expire) : $this->storeValueInPersistentCache($key, $value, $expire, $mode); + } +} diff --git a/src/ObjectCache/DynamoDbObjectCache.php b/src/ObjectCache/DynamoDbObjectCache.php new file mode 100644 index 0000000..1ec94db --- /dev/null +++ b/src/ObjectCache/DynamoDbObjectCache.php @@ -0,0 +1,207 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\ObjectCache; + +use Ymir\Plugin\CloudProvider\Aws\DynamoDbClient; +use Ymir\Plugin\Support\Collection; + +/** + * Object cache that persists data on DynamoDB. + */ +class DynamoDbObjectCache extends AbstractPersistentObjectCache +{ + /** + * The client used to interact with DynamoDB. + * + * @var DynamoDbClient + */ + private $dynamoDbClient; + + /** + * The table used by the object cache. + * + * @var string + */ + private $table; + + /** + * Constructor. + */ + public function __construct(DynamoDbClient $dynamoDbClient, bool $isMultisite, string $table, string $prefix = '') + { + parent::__construct($isMultisite, $prefix); + + $this->dynamoDbClient = $dynamoDbClient; + $this->table = $table; + } + + /** + * {@inheritdoc} + */ + public function isAvailable(): bool + { + try { + $this->getValue('test'); + + return true; + } catch (\Exception $exception) { + return false; + } + } + + /** + * {@inheritdoc} + */ + protected function deleteValueFromPersistentCache(string $key): bool + { + $this->dynamoDbClient->deleteItem([ + 'TableName' => $this->table, + 'Key' => [ + 'key' => ['S' => $key], + ], + ]); + + return true; + } + + /** + * {@inheritdoc} + */ + protected function flushPersistentCache(): bool + { + // We can't flush a DynamoDB table. Instead, we need to delete and recreate the table. + return true; + } + + /** + * {@inheritdoc} + */ + protected function getValuesFromPersistentCache($keys) + { + return is_string($keys) ? $this->getValue($keys) : $this->getValues((array) $keys); + } + + /** + * {@inheritdoc} + */ + protected function storeValueInPersistentCache(string $key, $value, int $expire = 0, int $mode = 0): bool + { + $arguments = [ + 'TableName' => $this->table, + 'Item' => [ + 'key' => ['S' => $key], + 'value' => ['S' => is_numeric($value) ? (string) $value : serialize($value)], + ], + ]; + + if ($expire > 0) { + $arguments['Item']['expires_at'] = ['N' => $expire]; + } + + if (self::MODE_ADD === $mode) { + $arguments['ConditionExpression'] = 'attribute_not_exists(#key) OR #expires_at < :now'; + } elseif (self::MODE_REPLACE === $mode) { + $arguments['ConditionExpression'] = 'attribute_exists(#key) AND #expires_at > :now'; + } + + if (0 !== $mode) { + $arguments['ExpressionAttributeNames'] = [ + '#key' => 'key', + '#expires_at' => 'expires_at', + ]; + $arguments['ExpressionAttributeValues'] = [ + ':now' => ['N' => (string) time()], + ]; + } + + $this->dynamoDbClient->putItem($arguments); + + return true; + } + + /** + * Get the value stored in DynamoDB for the given key. + */ + private function getValue(string $key) + { + $response = $this->dynamoDbClient->getItem([ + 'TableName' => $this->table, + 'ConsistentRead' => false, + 'Key' => [ + 'key' => ['S' => $key], + ], + ]); + + if (!isset($response['Item']['value']['S']) || $this->isExpired($response['Item'])) { + return false; + } + + return $this->unserialize($response['Item']['value']['S']); + } + + /** + * Get the values stored in DynamoDB for the given keys. + */ + private function getValues(array $keys): array + { + return (new Collection($keys))->chunk(100)->map(function (Collection $chunkedKeys) { + $response = $this->dynamoDbClient->batchGetItem([ + 'RequestItems' => [ + $this->table => [ + 'ConsistentRead' => false, + 'Keys' => $chunkedKeys->map(function (string $key) { + return ['key' => ['S' => $key]]; + })->values()->all(), + ], + ], + ]); + + $current = time(); + + return isset($response['Responses'][$this->table]) ? (new Collection($response['Responses'][$this->table]))->filter(function (array $item) use ($current) { + return !$this->isExpired($item, $current); + })->mapWithKeys(function (array $item) { + return [$item['key']['S'] => $this->unserialize($item['value']['S'])]; + })->all() : []; + })->collapse()->all(); + } + + /** + * Determine if the given item is expired. + */ + private function isExpired(array $item, ?int $time = null): bool + { + if (null === $time) { + $time = time(); + } + + return isset($item['expires_at']['N']) && $item['expires_at']['N'] <= $time; + } + + /** + * Unserialize the value. + */ + private function unserialize(string $value) + { + if (false !== filter_var($value, FILTER_VALIDATE_INT)) { + return (int) $value; + } + + if (is_numeric($value)) { + return (float) $value; + } + + return unserialize($value); + } +} diff --git a/src/ObjectCache/ObjectCacheInterface.php b/src/ObjectCache/ObjectCacheInterface.php new file mode 100644 index 0000000..2743334 --- /dev/null +++ b/src/ObjectCache/ObjectCacheInterface.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\ObjectCache; + +/** + * A WordPress Object Cache. + */ +interface ObjectCacheInterface +{ + /** + * Adds a value to the cache, if there's no value stored for the given key. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/add + */ + public function add(string $group, string $key, $value, int $expire = 0): bool; + + /** + * Adds a group or set of groups to the list of global groups. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/add_global_groups + */ + public function addGlobalGroups(array $groups); + + /** + * Adds a group or set of groups to the list of non-persistent groups. + */ + public function addNonPersistentGroups(array $groups); + + /** + * Closes the cache. + */ + public function close(): bool; + + /** + * Decrements numeric cache item's value. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/decr + */ + public function decrement(string $group, string $key, int $offset = 1); + + /** + * Removes the cache contents matching key and group. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/delete + */ + public function delete(string $group, string $key): bool; + + /** + * Removes all cache items. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/flush + */ + public function flush(): bool; + + /** + * Retrieves the cache contents from the cache by key and group. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/get + */ + public function get(string $group, string $key, bool $force = false, &$found = null); + + /** + * Retrieves multiple values from the cache in one call. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/get_multiple + */ + public function getMultiple(string $group, array $keys, bool $force = false): array; // DO WE REALLY NEED THIS? Merge with get + + /** + * Increment numeric cache item's value. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/incr + */ + public function increment(string $group, string $key, int $offset = 1); + + /** + * Replaces the contents of the cache with new data. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/replace + */ + public function replace(string $group, string $key, $value, int $expire = 0): bool; + + /** + * Saves the value to the cache. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/set + */ + public function set(string $group, string $key, $value, int $expire = 0): bool; + + /** + * Switches the internal blog ID. + * + * @see https://developer.wordpress.org/reference/classes/wp_object_cache/switch_to_blog + */ + public function switchToBlog(int $blogId); +} diff --git a/src/ObjectCache/PersistentObjectCacheInterface.php b/src/ObjectCache/PersistentObjectCacheInterface.php new file mode 100644 index 0000000..846192d --- /dev/null +++ b/src/ObjectCache/PersistentObjectCacheInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\ObjectCache; + +/** + * A WordPress Object Cache that persists data between executions. + */ +interface PersistentObjectCacheInterface extends ObjectCacheInterface +{ + /** + * Checks if the persistent cache is available. + */ + public function isAvailable(): bool; +} diff --git a/src/ObjectCache/PreloadedObjectCacheInterface.php b/src/ObjectCache/PreloadedObjectCacheInterface.php new file mode 100644 index 0000000..fc69289 --- /dev/null +++ b/src/ObjectCache/PreloadedObjectCacheInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\ObjectCache; + +/** + * A WordPress Object Cache that preloads cache keys based on. + */ +interface PreloadedObjectCacheInterface extends ObjectCacheInterface +{ + /** + * Load the object cache with all the keys used by the previous request. + */ + public function load(); +} diff --git a/src/ObjectCache/WordPressObjectCache.php b/src/ObjectCache/WordPressObjectCache.php new file mode 100644 index 0000000..006fdd4 --- /dev/null +++ b/src/ObjectCache/WordPressObjectCache.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\ObjectCache; + +/** + * A wrapper for the built-in WordPress object cache. + */ +class WordPressObjectCache implements ObjectCacheInterface +{ + /** + * The built-in WordPress object cache. + * + * @var \WP_Object_Cache + */ + private $cache; + + /** + * Constructor. + */ + public function __construct(\WP_Object_Cache $cache) + { + $this->cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function add(string $group, string $key, $value, int $expire = 0): bool + { + return $this->cache->add($key, $value, $group, $expire); + } + + /** + * {@inheritdoc} + */ + public function addGlobalGroups(array $groups) + { + $this->cache->add_global_groups($groups); + } + + /** + * {@inheritdoc} + */ + public function addNonPersistentGroups(array $groups) + { + // Built-in WordPress object cache isn't persistent. + } + + /** + * {@inheritdoc} + */ + public function close(): bool + { + return true; + } + + /** + * {@inheritdoc} + */ + public function decrement(string $group, string $key, int $offset = 1) + { + return $this->cache->decr($key, $offset, $group); + } + + /** + * {@inheritdoc} + */ + public function delete(string $group, string $key): bool + { + return $this->cache->delete($key, $group); + } + + /** + * {@inheritdoc} + */ + public function flush(): bool + { + return $this->cache->flush(); + } + + /** + * {@inheritdoc} + */ + public function get(string $group, string $key, bool $force = false, &$found = null) + { + return $this->cache->get($key, $group, $force, $found); + } + + /** + * {@inheritdoc} + */ + public function getMultiple(string $group, array $keys, bool $force = false): array + { + return $this->cache->get_multiple($keys, $group, $force); + } + + /** + * {@inheritdoc} + */ + public function increment(string $group, string $key, int $offset = 1) + { + return $this->cache->incr($key, $offset, $group); + } + + /** + * {@inheritdoc} + */ + public function replace(string $group, string $key, $value, int $expire = 0): bool + { + return $this->cache->replace($key, $value, $group, $expire); + } + + /** + * {@inheritdoc} + */ + public function set(string $group, string $key, $value, int $expire = 0): bool + { + return $this->cache->set($key, $value, $group, $expire); + } + + /** + * {@inheritdoc} + */ + public function switchToBlog(int $blogId) + { + $this->cache->switch_to_blog($blogId); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 71f76db..6890e56 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -29,6 +29,13 @@ class Plugin */ private $container; + /** + * The file path of the plugin. + * + * @var string + */ + private $filePath; + /** * Flag to track if the plugin is loaded. * @@ -39,7 +46,7 @@ class Plugin /** * Constructor. */ - public function __construct(string $file) + public function __construct(string $filePath) { if (!defined('ABSPATH')) { throw new \RuntimeException('"ABSPATH" constant isn\'t defined'); @@ -53,12 +60,26 @@ public function __construct(string $file) $this->container = new Container([ 'root_directory' => $rootDirectory, - 'plugin_name' => basename($file, '.php'), - 'plugin_basename' => plugin_basename($file), - 'plugin_path' => plugin_dir_path($file), - 'plugin_relative_path' => '/'.trim(str_replace($rootDirectory, '', plugin_dir_path($file)), '/'), - 'plugin_url' => plugin_dir_url($file), + 'plugin_name' => basename($filePath, '.php'), + ]); + $this->filePath = $filePath; + + $this->container->configure([ + Configuration\AssetsConfiguration::class, + Configuration\AttachmentConfiguration::class, + Configuration\CloudProviderConfiguration::class, + Configuration\CloudStorageConfiguration::class, + Configuration\ConsoleConfiguration::class, + Configuration\EmailConfiguration::class, + Configuration\EventManagementConfiguration::class, + Configuration\ObjectCacheConfiguration::class, + Configuration\PhpConfiguration::class, + Configuration\RestApiConfiguration::class, + Configuration\UploadsConfiguration::class, + Configuration\WordPressConfiguration::class, + Configuration\YmirConfiguration::class, ]); + $this->loaded = false; } @@ -87,20 +108,10 @@ public function load() return; } - $this->container->configure([ - Configuration\AssetsConfiguration::class, - Configuration\AttachmentConfiguration::class, - Configuration\CloudProviderConfiguration::class, - Configuration\CloudStorageConfiguration::class, - Configuration\ConsoleConfiguration::class, - Configuration\EmailConfiguration::class, - Configuration\EventManagementConfiguration::class, - Configuration\PhpConfiguration::class, - Configuration\RestApiConfiguration::class, - Configuration\UploadsConfiguration::class, - Configuration\WordPressConfiguration::class, - Configuration\YmirConfiguration::class, - ]); + $this->container['plugin_basename'] = plugin_basename($this->filePath); + $this->container['plugin_dir_path'] = plugin_dir_path($this->filePath); + $this->container['plugin_dir_url'] = plugin_dir_url($this->filePath); + $this->container['plugin_relative_path'] = '/'.trim(str_replace($this->container['root_directory'], '', plugin_dir_path($this->filePath)), '/'); CloudStorageStreamWrapper::register($this->container['cloud_storage_client']); diff --git a/src/Subscriber/HttpApiSubscriber.php b/src/Subscriber/HttpApiSubscriber.php deleted file mode 100644 index 2da0c08..0000000 --- a/src/Subscriber/HttpApiSubscriber.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Ymir\Plugin\Subscriber; - -use Ymir\Plugin\EventManagement\SubscriberInterface; - -/** - * Subscriber for the WordPress HTTP API. - */ -class HttpApiSubscriber implements SubscriberInterface -{ - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents(): array - { - return [ - 'http_api_curl' => ['addPostfieldsForEmptyPutRequest', 10, 3], - ]; - } - - /** - * By default, the HTTP API doesn't let us add an empty postfield for PUT requests. - */ - public function addPostfieldsForEmptyPutRequest($handle, array $request) - { - if (!is_resource($handle) - || !isset($request['method']) - || 'put' !== strtolower($request['method']) - || !isset($request['body']) - || '' !== trim($request['body']) - ) { - return; - } - - curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']); - } -} diff --git a/src/Support/Collection.php b/src/Support/Collection.php new file mode 100644 index 0000000..91f70af --- /dev/null +++ b/src/Support/Collection.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\Support; + +/** + * A collection offers a fluent interface for easier array manipulation. + */ +class Collection implements \ArrayAccess +{ + /** + * The items contained in the collection. + * + * @var array + */ + protected $items = []; + + /** + * Constructor. + */ + public function __construct($items = []) + { + $this->items = $this->convertToArray($items); + } + + /** + * Get all of the items in the collection. + */ + public function all(): array + { + return $this->items; + } + + /** + * Chunk the collection into chunks of the given size. + */ + public function chunk(int $size) + { + $chunks = new self(); + + if ($size <= 0) { + return $chunks; + } + + foreach (array_chunk($this->items, $size, true) as $chunk) { + $chunks[] = new self($chunk); + } + + return $chunks; + } + + /** + * Collapse the collection of items into a single array. + */ + public function collapse(): self + { + $results = []; + + foreach ($this->items as $item) { + if ($item instanceof self) { + $item = $item->all(); + } elseif (!is_array($item)) { + continue; + } + + $results[] = $item; + } + + return new self(array_merge([], ...$results)); + } + + /** + * Get the items in the collection that are not present in the given items. + */ + public function diff($items): self + { + return new self(array_diff($this->items, $this->convertToArray($items))); + } + + /** + * Get the items in the collection whose keys are not present in the given items. + */ + public function diffKeys($items) + { + return new self(array_diff_key($this->items, $this->convertToArray($items))); + } + + /** + * Execute a callback over each collection item. + */ + public function each(callable $callback) + { + foreach ($this->items as $key => $item) { + if (false === $callback($item, $key)) { + break; + } + } + + return $this; + } + + /** + * Run a filter over each collection item. + */ + public function filter(callable $callback = null): self + { + $filtered = $callback ? array_filter($this->items, $callback, ARRAY_FILTER_USE_BOTH) : array_filter($this->items); + + return new self($filtered); + } + + /** + * Flip the items in the collection. + */ + public function flip(): self + { + return new self(array_flip($this->items)); + } + + /** + * Determine if the collection is empty or not. + */ + public function isEmpty(): bool + { + return empty($this->items); + } + + /** + * Get the keys of the collection items. + */ + public function keys(): self + { + return new self(array_keys($this->items)); + } + + /** + * Run a map over each collection item. + */ + public function map(callable $callback): self + { + $keys = array_keys($this->items); + + $items = array_map($callback, $this->items, $keys); + + return new self(array_combine($keys, $items)); + } + + /** + * Run an associative map over each collection item. + */ + public function mapWithKeys(callable $callback): self + { + $result = []; + + foreach ($this->items as $key => $value) { + $assoc = $callback($value, $key); + + foreach ($assoc as $mapKey => $mapValue) { + $result[$mapKey] = $mapValue; + } + } + + return new self($result); + } + + /** + * Merge the collection with the given items. + */ + public function merge($items): self + { + return new self(array_merge($this->items, $this->convertToArray($items))); + } + + /** + * {@inheritdoc} + */ + public function offsetExists($key) + { + return isset($this->items[$key]); + } + + /** + * {@inheritdoc} + */ + public function offsetGet($key) + { + return $this->items[$key]; + } + + /** + * {@inheritdoc} + */ + public function offsetSet($key, $value) + { + if (null === $key) { + $this->items[] = $value; + } else { + $this->items[$key] = $value; + } + } + + /** + * {@inheritdoc} + */ + public function offsetUnset($key) + { + unset($this->items[$key]); + } + + /** + * Reset the keys on the underlying array. + */ + public function values() + { + return new self(array_values($this->items)); + } + + /** + * Convert the given value to an array. + */ + private function convertToArray($value): array + { + if (is_array($value)) { + return $value; + } elseif ($value instanceof self) { + return $value->all(); + } + + return (array) $value; + } +} diff --git a/stubs/object-cache.php b/stubs/object-cache.php new file mode 100644 index 0000000..f9770e2 --- /dev/null +++ b/stubs/object-cache.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +$objectCacheApiPaths = array_filter(array_map(function (string $filePath) { + return dirname($filePath).'/object-cache-api.php'; +}, (array) glob(WP_CONTENT_DIR.'/plugins/*/ymir.php')), function (string $filePath) { + return is_readable($filePath); +}); + +if (!empty($objectCacheApiPaths)) { + require_once reset($objectCacheApiPaths); +} diff --git a/tests/Mock/DynamoDbClientMockTrait.php b/tests/Mock/DynamoDbClientMockTrait.php new file mode 100644 index 0000000..64326c4 --- /dev/null +++ b/tests/Mock/DynamoDbClientMockTrait.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\Tests\Mock; + +use PHPUnit\Framework\MockObject\MockObject; +use Ymir\Plugin\CloudProvider\Aws\DynamoDbClient; + +trait DynamoDbClientMockTrait +{ + /** + * Creates a mock of a DynamoDbClient object. + */ + private function getDynamoDbClientMock(): MockObject + { + return $this->getMockBuilder(DynamoDbClient::class) + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/tests/Mock/WPHttpMockTrait.php b/tests/Mock/HttpClientMockTrait.php similarity index 69% rename from tests/Mock/WPHttpMockTrait.php rename to tests/Mock/HttpClientMockTrait.php index 2b980c0..cc841d1 100644 --- a/tests/Mock/WPHttpMockTrait.php +++ b/tests/Mock/HttpClientMockTrait.php @@ -14,15 +14,16 @@ namespace Ymir\Plugin\Tests\Mock; use PHPUnit\Framework\MockObject\MockObject; +use Ymir\Plugin\Http\Client; -trait WPHttpMockTrait +trait HttpClientMockTrait { /** - * Creates a mock of a WP_Error object. + * Creates a mock of a Client object. */ - private function getWPHttpMock(): MockObject + private function getHttpClientMock(): MockObject { - return $this->getMockBuilder(\WP_Http::class) + return $this->getMockBuilder(Client::class) ->disableOriginalConstructor() ->getMock(); } diff --git a/tests/Unit/CloudProvider/Aws/DynamoDbClientTest.php b/tests/Unit/CloudProvider/Aws/DynamoDbClientTest.php new file mode 100644 index 0000000..f974472 --- /dev/null +++ b/tests/Unit/CloudProvider/Aws/DynamoDbClientTest.php @@ -0,0 +1,236 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\Tests\Unit\CloudProvider\Aws; + +use Ymir\Plugin\CloudProvider\Aws\DynamoDbClient; +use Ymir\Plugin\Tests\Mock\FunctionMockTrait; +use Ymir\Plugin\Tests\Mock\HttpClientMockTrait; +use Ymir\Plugin\Tests\Unit\TestCase; + +/** + * @covers \Ymir\Plugin\CloudProvider\Aws\DynamoDbClient + */ +class DynamoDbClientTest extends TestCase +{ + use FunctionMockTrait; + use HttpClientMockTrait; + + public function testBatchGetItem() + { + $http = $this->getHttpClientMock(); + $http->expects($this->once()) + ->method('request') + ->with( + $this->identicalTo('https://dynamodb.us-east-1.amazonaws.com/'), + $this->identicalTo([ + 'headers' => [ + 'content-type' => 'application/x-amz-json-1.0', + 'host' => 'dynamodb.us-east-1.amazonaws.com', + 'x-amz-content-sha256' => 'd47700a4e517dfa91f5b94491bc7e68932197d004680405e9257bf7eab8a77c8', + 'x-amz-date' => '20200515T181004Z', + 'x-amz-target' => 'DynamoDB_20120810.BatchGetItem', + 'authorization' => 'AWS4-HMAC-SHA256 Credential=aws-key/20200515/us-east-1/dynamodb/aws4_request,SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-target,Signature=cb5918b6f2922c01ce782191d23391328f82690b0bbd3dc7dfb49273bedfebf8', + ], + 'method' => 'POST', + 'timeout' => 300, + 'body' => '{"RequestItems":{"table":{"ConsistentRead":false,"Keys":[{"key":{"S":"forum"}}]}}}', + ]) + ) + ->willReturn([ + 'body' => '{"Responses": {"Forum": [{"Name":{"S":"Amazon DynamoDB"}, "Threads":{"N":"5"}, "Messages":{"N":"19"}, "Views":{"N":"35"}}]}}', + 'response' => ['code' => 200], + ]); + + $gmdate = $this->getFunctionMock($this->getNamespace(DynamoDbClient::class), 'gmdate'); + $gmdate->expects($this->exactly(5)) + ->withConsecutive( + [$this->identicalTo('Ymd\THis\Z')], + [$this->identicalTo('Ymd')], + [$this->identicalTo('Ymd\THis\Z')], + [$this->identicalTo('Ymd')], + [$this->identicalTo('Ymd')] + ) + ->willReturnOnConsecutiveCalls('20200515T181004Z', '20200515', '20200515T181004Z', '20200515', '20200515'); + + $response = (new DynamoDbClient($http, 'aws-key', 'us-east-1', 'aws-secret'))->batchGetItem([ + 'RequestItems' => [ + 'table' => [ + 'ConsistentRead' => false, + 'Keys' => [ + ['key' => ['S' => 'forum']], + ], + ], + ], + ]); + + $this->assertSame([ + 'Responses' => [ + 'Forum' => [ + [ + 'Name' => ['S' => 'Amazon DynamoDB'], + 'Threads' => ['N' => '5'], + 'Messages' => ['N' => '19'], + 'Views' => ['N' => '35'], + ], + ], + ], + ], $response); + } + + public function testDeleteItem() + { + $http = $this->getHttpClientMock(); + $http->expects($this->once()) + ->method('request') + ->with( + $this->identicalTo('https://dynamodb.us-east-1.amazonaws.com/'), + $this->identicalTo([ + 'headers' => [ + 'content-type' => 'application/x-amz-json-1.0', + 'host' => 'dynamodb.us-east-1.amazonaws.com', + 'x-amz-content-sha256' => 'f75f88323bf44389f3a6dd83fb622ccf77df4831a8c4ede662d7f8b632235966', + 'x-amz-date' => '20200515T181004Z', + 'x-amz-target' => 'DynamoDB_20120810.DeleteItem', + 'authorization' => 'AWS4-HMAC-SHA256 Credential=aws-key/20200515/us-east-1/dynamodb/aws4_request,SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-target,Signature=e56889be45e56745b0c45c2193cce1b05c16303777db9a62f29e1da111324e30', + ], + 'method' => 'POST', + 'timeout' => 300, + 'body' => '{"TableName":"table","Key":{"key":{"S":"key"}}}', + ]) + ) + ->willReturn([ + 'body' => '{"Responses": {"Forum": [{"Name":{"S":"Amazon DynamoDB"}, "Threads":{"N":"5"}, "Messages":{"N":"19"}, "Views":{"N":"35"}}]}}', + 'response' => ['code' => 200], + ]); + + $gmdate = $this->getFunctionMock($this->getNamespace(DynamoDbClient::class), 'gmdate'); + $gmdate->expects($this->exactly(5)) + ->withConsecutive( + [$this->identicalTo('Ymd\THis\Z')], + [$this->identicalTo('Ymd')], + [$this->identicalTo('Ymd\THis\Z')], + [$this->identicalTo('Ymd')], + [$this->identicalTo('Ymd')] + ) + ->willReturnOnConsecutiveCalls('20200515T181004Z', '20200515', '20200515T181004Z', '20200515', '20200515'); + + (new DynamoDbClient($http, 'aws-key', 'us-east-1', 'aws-secret'))->deleteItem([ + 'TableName' => 'table', + 'Key' => [ + 'key' => ['S' => 'key'], + ], + ]); + } + + public function testGetItem() + { + $http = $this->getHttpClientMock(); + $http->expects($this->once()) + ->method('request') + ->with( + $this->identicalTo('https://dynamodb.us-east-1.amazonaws.com/'), + $this->identicalTo([ + 'headers' => [ + 'content-type' => 'application/x-amz-json-1.0', + 'host' => 'dynamodb.us-east-1.amazonaws.com', + 'x-amz-content-sha256' => '8202bb4a7f3ba37635ecba019388cf544cd0058474725d40f1d51198ab7f2e2a', + 'x-amz-date' => '20200515T181004Z', + 'x-amz-target' => 'DynamoDB_20120810.GetItem', + 'authorization' => 'AWS4-HMAC-SHA256 Credential=aws-key/20200515/us-east-1/dynamodb/aws4_request,SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-target,Signature=0f331677fc0ede47d85e8a1f2bf69582f0d67860adb769bcdc5ae228ac500833', + ], + 'method' => 'POST', + 'timeout' => 300, + 'body' => '{"table":{"ConsistentRead":false,"Keys":[{"key":{"S":"forum"}}]}}', + ]) + ) + ->willReturn([ + 'body' => '{"Item": { "Tags": { "SS": ["Update","Multiple Items","HelpMe"]}, "LastPostDateTime": {"S": "201303190436"}, "Message": {"S": "I want to update multiple items in a single call. What\'s the best way to do that?"}}}', + 'response' => ['code' => 200], + ]); + + $gmdate = $this->getFunctionMock($this->getNamespace(DynamoDbClient::class), 'gmdate'); + $gmdate->expects($this->exactly(5)) + ->withConsecutive( + [$this->identicalTo('Ymd\THis\Z')], + [$this->identicalTo('Ymd')], + [$this->identicalTo('Ymd\THis\Z')], + [$this->identicalTo('Ymd')], + [$this->identicalTo('Ymd')] + ) + ->willReturnOnConsecutiveCalls('20200515T181004Z', '20200515', '20200515T181004Z', '20200515', '20200515'); + + $response = (new DynamoDbClient($http, 'aws-key', 'us-east-1', 'aws-secret'))->getItem([ + 'table' => [ + 'ConsistentRead' => false, + 'Keys' => [ + ['key' => ['S' => 'forum']], + ], + ], + ]); + + $this->assertSame([ + 'Item' => [ + 'Tags' => ['SS' => ['Update', 'Multiple Items', 'HelpMe']], + 'LastPostDateTime' => ['S' => '201303190436'], + 'Message' => ['S' => 'I want to update multiple items in a single call. What\'s the best way to do that?'], + ], + ], $response); + } + + public function testPutItem() + { + $http = $this->getHttpClientMock(); + $http->expects($this->once()) + ->method('request') + ->with( + $this->identicalTo('https://dynamodb.us-east-1.amazonaws.com/'), + $this->identicalTo([ + 'headers' => [ + 'content-type' => 'application/x-amz-json-1.0', + 'host' => 'dynamodb.us-east-1.amazonaws.com', + 'x-amz-content-sha256' => '1cc5dbaad2b0b43e5bc819fddaa6761f95f62ab142866d33a87b58fe2733267b', + 'x-amz-date' => '20200515T181004Z', + 'x-amz-target' => 'DynamoDB_20120810.PutItem', + 'authorization' => 'AWS4-HMAC-SHA256 Credential=aws-key/20200515/us-east-1/dynamodb/aws4_request,SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-target,Signature=4dd86e117c2735b9ff9c6e3287123180bedf03a489204f86d6bda84586a5a98c', + ], + 'method' => 'POST', + 'timeout' => 300, + 'body' => '{"TableName":"table","Key":{"key":{"S":"key"},"value":{"S":"value"}}}', + ]) + ) + ->willReturn([ + 'body' => '{"Responses": {"Forum": [{"Name":{"S":"Amazon DynamoDB"}, "Threads":{"N":"5"}, "Messages":{"N":"19"}, "Views":{"N":"35"}}]}}', + 'response' => ['code' => 200], + ]); + + $gmdate = $this->getFunctionMock($this->getNamespace(DynamoDbClient::class), 'gmdate'); + $gmdate->expects($this->exactly(5)) + ->withConsecutive( + [$this->identicalTo('Ymd\THis\Z')], + [$this->identicalTo('Ymd')], + [$this->identicalTo('Ymd\THis\Z')], + [$this->identicalTo('Ymd')], + [$this->identicalTo('Ymd')] + ) + ->willReturnOnConsecutiveCalls('20200515T181004Z', '20200515', '20200515T181004Z', '20200515', '20200515'); + + (new DynamoDbClient($http, 'aws-key', 'us-east-1', 'aws-secret'))->putItem([ + 'TableName' => 'table', + 'Key' => [ + 'key' => ['S' => 'key'], + 'value' => ['S' => 'value'], + ], + ]); + } +} diff --git a/tests/Unit/CloudProvider/Aws/LambdaClientTest.php b/tests/Unit/CloudProvider/Aws/LambdaClientTest.php index bdb51c8..c84352b 100644 --- a/tests/Unit/CloudProvider/Aws/LambdaClientTest.php +++ b/tests/Unit/CloudProvider/Aws/LambdaClientTest.php @@ -15,7 +15,7 @@ use Ymir\Plugin\CloudProvider\Aws\LambdaClient; use Ymir\Plugin\Tests\Mock\FunctionMockTrait; -use Ymir\Plugin\Tests\Mock\WPHttpMockTrait; +use Ymir\Plugin\Tests\Mock\HttpClientMockTrait; use Ymir\Plugin\Tests\Mock\WPPostMockTrait; use Ymir\Plugin\Tests\Unit\TestCase; @@ -25,12 +25,12 @@ class LambdaClientTest extends TestCase { use FunctionMockTrait; - use WPHttpMockTrait; + use HttpClientMockTrait; use WPPostMockTrait; public function testCreateAttachmentMetadata() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -72,7 +72,7 @@ public function testCreateAttachmentMetadata() public function testCreateAttachmentMetadataWithSpecialCharacters() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -114,7 +114,7 @@ public function testCreateAttachmentMetadataWithSpecialCharacters() public function testCreateCroppedAttachmentImage() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -156,7 +156,7 @@ public function testCreateCroppedAttachmentImage() public function testCreateCroppedAttachmentImageWithSiteIconContext() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -198,7 +198,7 @@ public function testCreateCroppedAttachmentImageWithSiteIconContext() public function testEditAttachmentImage() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -240,7 +240,7 @@ public function testEditAttachmentImage() public function testResizeAttachmentImage() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -282,7 +282,7 @@ public function testResizeAttachmentImage() public function testRunCron() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( diff --git a/tests/Unit/CloudProvider/Aws/S3ClientTest.php b/tests/Unit/CloudProvider/Aws/S3ClientTest.php index 97e689d..8f8756b 100644 --- a/tests/Unit/CloudProvider/Aws/S3ClientTest.php +++ b/tests/Unit/CloudProvider/Aws/S3ClientTest.php @@ -15,7 +15,7 @@ use Ymir\Plugin\CloudProvider\Aws\S3Client; use Ymir\Plugin\Tests\Mock\FunctionMockTrait; -use Ymir\Plugin\Tests\Mock\WPHttpMockTrait; +use Ymir\Plugin\Tests\Mock\HttpClientMockTrait; use Ymir\Plugin\Tests\Unit\TestCase; /** @@ -24,11 +24,11 @@ class S3ClientTest extends TestCase { use FunctionMockTrait; - use WPHttpMockTrait; + use HttpClientMockTrait; public function testCopyObject() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -77,12 +77,12 @@ public function testCreatePutObjectRequest() ) ->willReturnOnConsecutiveCalls('20200515', '20200515T181004Z', '20200515T181004Z', '20200515', '20200515'); - $this->assertSame('https://test-bucket.s3.us-east-1.amazonaws.com/object-key?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=aws-key%2F20200515%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20200515T181004Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host%3Bx-amz-acl&X-Amz-Signature=196d3b99d39a506d8edbd65eb976b3916ec08bc2b8be1859c676c7cf98df1578', (new S3Client($this->getWPHttpMock(), 'test-bucket', 'aws-key', 'us-east-1', 'aws-secret'))->createPutObjectRequest('object-key')); + $this->assertSame('https://test-bucket.s3.us-east-1.amazonaws.com/object-key?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=aws-key%2F20200515%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20200515T181004Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host%3Bx-amz-acl&X-Amz-Signature=196d3b99d39a506d8edbd65eb976b3916ec08bc2b8be1859c676c7cf98df1578', (new S3Client($this->getHttpClientMock(), 'test-bucket', 'aws-key', 'us-east-1', 'aws-secret'))->createPutObjectRequest('object-key')); } public function testDeleteObject() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -119,7 +119,7 @@ public function testDeleteObject() public function testGetObject() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -157,7 +157,7 @@ public function testGetObject() public function testGetObjectDetails() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -203,7 +203,7 @@ public function testGetObjectDetails() public function testGetObjects() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -252,7 +252,7 @@ public function testGetObjects() public function testObjectExists() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( @@ -294,7 +294,7 @@ public function testObjectExists() public function testPutObject() { - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( diff --git a/tests/Unit/CloudProvider/Aws/SesClientTest.php b/tests/Unit/CloudProvider/Aws/SesClientTest.php index 7b6bab8..902d130 100644 --- a/tests/Unit/CloudProvider/Aws/SesClientTest.php +++ b/tests/Unit/CloudProvider/Aws/SesClientTest.php @@ -16,7 +16,7 @@ use Ymir\Plugin\CloudProvider\Aws\SesClient; use Ymir\Plugin\Tests\Mock\EmailMockTrait; use Ymir\Plugin\Tests\Mock\FunctionMockTrait; -use Ymir\Plugin\Tests\Mock\WPHttpMockTrait; +use Ymir\Plugin\Tests\Mock\HttpClientMockTrait; use Ymir\Plugin\Tests\Unit\TestCase; /** @@ -26,7 +26,7 @@ class SesClientTest extends TestCase { use EmailMockTrait; use FunctionMockTrait; - use WPHttpMockTrait; + use HttpClientMockTrait; public function testSendEmail() { @@ -35,7 +35,7 @@ public function testSendEmail() ->method('toString') ->willReturn('email'); - $http = $this->getWPHttpMock(); + $http = $this->getHttpClientMock(); $http->expects($this->once()) ->method('request') ->with( diff --git a/tests/Unit/ObjectCache/AbstractPersistentObjectCacheTest.php b/tests/Unit/ObjectCache/AbstractPersistentObjectCacheTest.php new file mode 100644 index 0000000..7cd106c --- /dev/null +++ b/tests/Unit/ObjectCache/AbstractPersistentObjectCacheTest.php @@ -0,0 +1,1258 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\Tests\Unit\ObjectCache; + +use Ymir\Plugin\ObjectCache\AbstractPersistentObjectCache; +use Ymir\Plugin\Tests\Mock\FunctionMockTrait; +use Ymir\Plugin\Tests\Unit\TestCase; + +/** + * @covers \Ymir\Plugin\ObjectCache\AbstractPersistentObjectCache + */ +class AbstractPersistentObjectCacheTest extends TestCase +{ + use FunctionMockTrait; + + public function testAddGlobalGroups() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $globalGroupsProperty = $objectCacheReflection->getProperty('globalGroups'); + $globalGroupsProperty->setAccessible(true); + + $this->assertEmpty($globalGroupsProperty->getValue($objectCache)); + + $objectCache->addGlobalGroups(['group']); + + $this->assertSame(['group'], $globalGroupsProperty->getValue($objectCache)); + } + + public function testAddNonPersistentGroupsProperty() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + + $this->assertEmpty($nonPersistentGroupsProperty->getValue($objectCache)); + + $objectCache->addNonPersistentGroups(['group']); + + $this->assertSame(['group'], $nonPersistentGroupsProperty->getValue($objectCache)); + } + + public function testAddReturnsFalseIfCacheAdditionSuspended() + { + $function_exists = $this->getFunctionMock($this->getNamespace(AbstractPersistentObjectCache::class), 'function_exists'); + $function_exists->expects($this->once()) + ->with($this->identicalTo('wp_suspend_cache_addition')) + ->willReturn(true); + + $wp_suspend_cache_addition = $this->getFunctionMock($this->getNamespace(AbstractPersistentObjectCache::class), 'wp_suspend_cache_addition'); + $wp_suspend_cache_addition->expects($this->once()) + ->willReturn(false); + + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + + $this->assertFalse($objectCache->add('group', 'key', 'value')); + } + + public function testAddReturnsFalseIfCachedInMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $this->assertFalse($objectCache->add('group', 'key', 'value')); + } + + public function testAddReturnsFalseIfStoreValueInPersistentCacheFailed() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('value'), $this->identicalTo(0), $this->identicalTo(1)) + ->willReturn(false); + + $this->assertFalse($objectCache->add('group', 'key', 'value')); + } + + public function testAddReturnsFalseIfStoreValueInPersistentCacheThrowsException() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('value'), $this->identicalTo(0), $this->identicalTo(1)) + ->willThrowException(new \Exception()); + + $this->assertFalse($objectCache->add('group', 'key', 'value')); + } + + public function testAddReturnsTrueIfInNonPersistentGroup() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $objectCache->expects($this->never()) + ->method('storeValueInPersistentCache'); + + $this->assertTrue($objectCache->add('group', 'key', 'value')); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $this->assertSame(['group:key' => 'value'], $cacheProperty->getValue($objectCache)); + } + + public function testAddReturnsTrueIfStoreValueInPersistentCacheSucceeds() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('value'), $this->identicalTo(0), $this->identicalTo(1)) + ->willReturn(true); + + $this->assertTrue($objectCache->add('group', 'key', 'value')); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $this->assertSame(['group:key' => 'value'], $cacheProperty->getValue($objectCache)); + } + + public function testAddStoresAlloptionsInPersistentCacheWhenNotInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->exactly(3)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key1'), $this->identicalTo('value1'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key1' => true, 'key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->add('options', 'alloptions', ['key1' => 'value1', 'key2' => 'value2'])); + + $this->assertSame([ + 'options:alloptions_keys' => ['key1' => true, 'key2' => true], + 'alloptions_values:key1' => 'value1', + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key1' => 'value1', 'key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testAddStoresDeletesUnusedAlloptionsOptionsInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['alloptions_values:key1' => 'value1']); + + $objectCache->expects($this->once()) + ->method('deleteValueFromPersistentCache') + ->with($this->identicalTo('alloptions_values:key1')) + ->willReturn(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('options:alloptions_keys')) + ->willReturn(['key1' => true]); + + $objectCache->expects($this->exactly(2)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->add('options', 'alloptions', ['key2' => 'value2'])); + + $this->assertSame([ + 'options:alloptions_keys' => ['key2' => true], + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testAddStoresOnlyMissingAlloptionsOptionsInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['alloptions_values:key1' => 'value1']); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('options:alloptions_keys')) + ->willReturn(['key1' => true]); + + $objectCache->expects($this->exactly(2)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key1' => true, 'key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->add('options', 'alloptions', ['key1' => 'value1', 'key2' => 'value2'])); + + $this->assertSame([ + 'alloptions_values:key1' => 'value1', + 'options:alloptions_keys' => ['key1' => true, 'key2' => true], + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key1' => 'value1', 'key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testClose() + { + $this->assertTrue(($this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]))->close()); + } + + public function testDecrementDecrementsValueFromMemoryAndDoestSaveItToPersistentCacheWhenInNonPersistentGroup() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 5]); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $objectCache->expects($this->never()) + ->method('storeValueInPersistentCache'); + + $this->assertSame(4, $objectCache->decrement('group', 'key')); + $this->assertSame(['group:key' => 4], $cacheProperty->getValue($objectCache)); + } + + public function testDecrementDecrementsValueFromMemoryAndSuccessfullySavesItToPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 5]); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo(4), $this->identicalTo(0), $this->identicalTo(0)) + ->willReturn(true); + + $this->assertSame(4, $objectCache->decrement('group', 'key')); + $this->assertSame(['group:key' => 4], $cacheProperty->getValue($objectCache)); + } + + public function testDecrementDecrementsValueFromPersistentCacheAndSuccessfullySavesItToPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(5); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo(4), $this->identicalTo(0), $this->identicalTo(0)) + ->willReturn(true); + + $this->assertSame(4, $objectCache->decrement('group', 'key')); + $this->assertSame(['group:key' => 4], $cacheProperty->getValue($objectCache)); + } + + public function testDecrementReturnsFalseWhenNoValueInMemoryAndValueFromPersistentCacheIsntAnInt() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn('5'); + + $this->assertFalse($objectCache->decrement('group', 'key')); + } + + public function testDecrementReturnsFalseWhenNoValueInMemoryOrPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(false); + + $this->assertFalse($objectCache->decrement('group', 'key')); + } + + public function testDecrementReturnsFalseWhenStoreToPersistentCacheFails() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 5]); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo(4), $this->identicalTo(0), $this->identicalTo(0)) + ->willReturn(false); + + $this->assertFalse($objectCache->decrement('group', 'key')); + } + + public function testDecrementReturnsFalseWhenValueInMemoryIsntAnIntAndNoValueInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => '5']); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(false); + + $this->assertFalse($objectCache->decrement('group', 'key')); + } + + public function testDeleteReturnsFalseWhenDeleteFromPersistentCacheFails() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $objectCache->expects($this->once()) + ->method('deleteValueFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(false); + + $this->assertFalse($objectCache->delete('group', 'key')); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testDeleteReturnsFalseWhenInNonPersistentGroupAndNotInMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $this->assertFalse($objectCache->delete('group', 'key')); + } + + public function testDeleteReturnsTrueWhenDeletedSuccessfullyFromPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $objectCache->expects($this->once()) + ->method('deleteValueFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(true); + + $this->assertTrue($objectCache->delete('group', 'key')); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testDeleteReturnsTrueWhenInNonPersistentGroupAndInMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $this->assertTrue($objectCache->delete('group', 'key')); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testFlushReturnsFalseOnException() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $objectCache->expects($this->once()) + ->method('flushPersistentCache') + ->willThrowException(new \Exception()); + + $this->assertFalse($objectCache->flush()); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testFlushReturnsFalseOnFailure() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $objectCache->expects($this->once()) + ->method('flushPersistentCache') + ->willReturn(false); + + $this->assertFalse($objectCache->flush()); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testFlushReturnsTrueOnSuccess() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $objectCache->expects($this->once()) + ->method('flushPersistentCache') + ->willReturn(true); + + $this->assertTrue($objectCache->flush()); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testGetMultipleDoesntGetFromPersistentCacheIfAllKeysAreInMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group1:key1' => 'value1', 'group1:key2' => 'value2', 'group2:key1' => 'value3', 'group2:key2' => 'value4']); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $objectCache->getMultiple('group1', ['key1', 'key2'])); + $this->assertSame(['group1:key1' => true, 'group1:key2' => true], $requestedKeysProperty->getValue($objectCache)); + } + + public function testGetMultipleGetsFromPersistentCacheIfAllKeysAreInMemoryButForcedIsTrue() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group1:key1' => 'value1', 'group1:key2' => 'value2', 'group2:key1' => 'value3', 'group2:key2' => 'value4']); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo(['group1:key1', 'group1:key2'])) + ->willReturn(['group1:key1' => 'new_value1', 'group1:key2' => 'new_value2']); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $this->assertSame(['key1' => 'new_value1', 'key2' => 'new_value2'], $objectCache->getMultiple('group1', ['key1', 'key2'], true)); + $this->assertSame(['group1:key1' => 'new_value1', 'group1:key2' => 'new_value2', 'group2:key1' => 'value3', 'group2:key2' => 'value4'], $cacheProperty->getValue($objectCache)); + $this->assertSame(['group1:key1' => true, 'group1:key2' => true], $requestedKeysProperty->getValue($objectCache)); + } + + public function testGetMultipleGetsMissingValueFromPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group1:key2' => 'value2', 'group2:key1' => 'value3', 'group2:key2' => 'value4']); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo(['group1:key1'])) + ->willReturn(['group1:key1' => 'value1']); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $this->assertSame(['key1' => 'value1', 'key2' => 'value2'], $objectCache->getMultiple('group1', ['key1', 'key2'])); + $this->assertSame(['group1:key2' => 'value2', 'group2:key1' => 'value3', 'group2:key2' => 'value4', 'group1:key1' => 'value1'], $cacheProperty->getValue($objectCache)); + $this->assertSame(['group1:key1' => true, 'group1:key2' => true], $requestedKeysProperty->getValue($objectCache)); + } + + public function testGetMultipleSortsReturnValues() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group1:key1' => 'value1', 'group1:key2' => 'value2', 'group2:key1' => 'value3', 'group2:key2' => 'value4']); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $this->assertSame(['key2' => 'value2', 'key1' => 'value1'], $objectCache->getMultiple('group1', ['key2', 'key1'])); + } + + public function testGetReturnsFalseWhenGettingFromPersistentCacheFails() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(false); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $found = null; + + $this->assertFalse($objectCache->get('group', 'key', true, $found)); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + $this->assertFalse($found); + } + + public function testGetReturnsFalseWhenGettingFromPersistentCacheThrowsException() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willThrowException(new \Exception()); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $found = null; + + $this->assertFalse($objectCache->get('group', 'key', true, $found)); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + $this->assertFalse($found); + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + } + + public function testGetReturnsFalseWhenInNotPersistentGroupAndNotInMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $found = null; + + $this->assertFalse($objectCache->get('group', 'key', false, $found)); + $this->assertFalse($found); + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + } + + public function testGetReturnsFalseWhenInNotPersistentGroupAndNotInMemoryAndForced() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $found = null; + + $this->assertFalse($objectCache->get('group', 'key', true, $found)); + $this->assertFalse($found); + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + } + + public function testGetReturnsValueFromMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $found = null; + + $this->assertSame('value', $objectCache->get('group', 'key', false, $found)); + $this->assertTrue($found); + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + } + + public function testGetReturnsValueFromPersistentCacheWhenForced() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn('new_value'); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $found = null; + + $this->assertSame('new_value', $objectCache->get('group', 'key', true, $found)); + $this->assertSame(['group:key' => 'new_value'], $cacheProperty->getValue($objectCache)); + $this->assertTrue($found); + $this->assertSame(['group:key' => true], $requestedKeysProperty->getValue($objectCache)); + } + + public function testGetReturnsValueFromPersistentCacheWhenNotInMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn('value'); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $found = null; + + $this->assertSame('value', $objectCache->get('group', 'key', false, $found)); + $this->assertSame(['group:key' => 'value'], $cacheProperty->getValue($objectCache)); + $this->assertTrue($found); + $this->assertSame(['group:key' => true], $requestedKeysProperty->getValue($objectCache)); + } + + public function testGetReturnsValueWhenInNotPersistentGroupAndInMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $requestedKeysProperty = $objectCacheReflection->getProperty('requestedKeys'); + $requestedKeysProperty->setAccessible(true); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $this->assertEmpty($requestedKeysProperty->getValue($objectCache)); + + $found = null; + + $this->assertSame('value', $objectCache->get('group', 'key', false, $found)); + $this->assertTrue($found); + } + + public function testIncrementIncrementsValueFromMemoryAndDoestSaveItToPersistentCacheWhenInNonPersistentGroup() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 5]); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $objectCache->expects($this->never()) + ->method('storeValueInPersistentCache'); + + $this->assertSame(6, $objectCache->increment('group', 'key')); + $this->assertSame(['group:key' => 6], $cacheProperty->getValue($objectCache)); + } + + public function testIncrementIncrementsValueFromMemoryAndSuccessfullySavesItToPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 5]); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo(6), $this->identicalTo(0), $this->identicalTo(0)) + ->willReturn(true); + + $this->assertSame(6, $objectCache->increment('group', 'key')); + $this->assertSame(['group:key' => 6], $cacheProperty->getValue($objectCache)); + } + + public function testIncrementIncrementsValueFromPersistentCacheAndSuccessfullySavesItToPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(5); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo(6), $this->identicalTo(0), $this->identicalTo(0)) + ->willReturn(true); + + $this->assertSame(6, $objectCache->increment('group', 'key')); + $this->assertSame(['group:key' => 6], $cacheProperty->getValue($objectCache)); + } + + public function testIncrementReturnsFalseWhenNoValueInMemoryAndValueFromPersistentCacheIsntAnInt() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn('5'); + + $this->assertFalse($objectCache->increment('group', 'key')); + } + + public function testIncrementReturnsFalseWhenNoValueInMemoryOrPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(false); + + $this->assertFalse($objectCache->increment('group', 'key')); + } + + public function testIncrementReturnsFalseWhenStoreToPersistentCacheFails() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 5]); + + $objectCache->expects($this->never()) + ->method('getValuesFromPersistentCache'); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo(6), $this->identicalTo(0), $this->identicalTo(0)) + ->willReturn(false); + + $this->assertFalse($objectCache->increment('group', 'key')); + } + + public function testIncrementReturnsFalseWhenValueInMemoryIsntAnIntAndNoValueInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => '5']); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('group:key')) + ->willReturn(false); + + $this->assertFalse($objectCache->increment('group', 'key')); + } + + public function testReplaceReturnsFalseIfInNonPersistentGroupAndNotCachedInMemory() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $this->assertFalse($objectCache->replace('group', 'key', 'value')); + } + + public function testReplaceReturnsFalseIfStoreValueInPersistentCacheFailed() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('value'), $this->identicalTo(0), $this->identicalTo(2)) + ->willReturn(false); + + $this->assertFalse($objectCache->replace('group', 'key', 'value')); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testReplaceReturnsFalseIfStoreValueInPersistentCacheThrowsException() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('value'), $this->identicalTo(0), $this->identicalTo(2)) + ->willThrowException(new \Exception()); + + $this->assertFalse($objectCache->replace('group', 'key', 'value')); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testReplaceReturnsTrueIfInNonPersistentGroup() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $nonPersistentGroupsProperty = $objectCacheReflection->getProperty('nonPersistentGroups'); + $nonPersistentGroupsProperty->setAccessible(true); + $nonPersistentGroupsProperty->setValue($objectCache, ['group']); + + $objectCache->expects($this->never()) + ->method('storeValueInPersistentCache'); + + $this->assertTrue($objectCache->replace('group', 'key', 'new_value')); + $this->assertSame(['group:key' => 'new_value'], $cacheProperty->getValue($objectCache)); + } + + public function testReplaceReturnsTrueIfStoreValueInPersistentCacheSucceeds() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['group:key' => 'value']); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('new_value'), $this->identicalTo(0), $this->identicalTo(2)) + ->willReturn(true); + + $this->assertTrue($objectCache->replace('group', 'key', 'new_value')); + $this->assertSame(['group:key' => 'new_value'], $cacheProperty->getValue($objectCache)); + } + + public function testReplaceStoresAlloptionsInPersistentCacheWhenNotInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->exactly(3)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key1'), $this->identicalTo('value1'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key1' => true, 'key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->replace('options', 'alloptions', ['key1' => 'value1', 'key2' => 'value2'])); + + $this->assertSame([ + 'options:alloptions_keys' => ['key1' => true, 'key2' => true], + 'alloptions_values:key1' => 'value1', + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key1' => 'value1', 'key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testReplaceStoresDeletesUnusedAlloptionsOptionsInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['alloptions_values:key1' => 'value1']); + + $objectCache->expects($this->once()) + ->method('deleteValueFromPersistentCache') + ->with($this->identicalTo('alloptions_values:key1')) + ->willReturn(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('options:alloptions_keys')) + ->willReturn(['key1' => true]); + + $objectCache->expects($this->exactly(2)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->replace('options', 'alloptions', ['key2' => 'value2'])); + + $this->assertSame([ + 'options:alloptions_keys' => ['key2' => true], + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testReplaceStoresOnlyMissingAlloptionsOptionsInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['alloptions_values:key1' => 'value1']); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('options:alloptions_keys')) + ->willReturn(['key1' => true]); + + $objectCache->expects($this->exactly(2)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key1' => true, 'key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->replace('options', 'alloptions', ['key1' => 'value1', 'key2' => 'value2'])); + + $this->assertSame([ + 'alloptions_values:key1' => 'value1', + 'options:alloptions_keys' => ['key1' => true, 'key2' => true], + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key1' => 'value1', 'key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testSetReturnsFalseIfStoreValueInPersistentCacheFailed() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('value'), $this->identicalTo(0), $this->identicalTo(0)) + ->willReturn(false); + + $this->assertFalse($objectCache->set('group', 'key', 'value')); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testSetReturnsFalseIfStoreValueInPersistentCacheThrowsException() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('value'), $this->identicalTo(0), $this->identicalTo(0)) + ->willThrowException(new \Exception()); + + $this->assertFalse($objectCache->set('group', 'key', 'value')); + $this->assertEmpty($cacheProperty->getValue($objectCache)); + } + + public function testSetReturnsTrueIfStoreValueInPersistentCacheSucceeds() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->once()) + ->method('storeValueInPersistentCache') + ->with($this->identicalTo('group:key'), $this->identicalTo('value'), $this->identicalTo(0), $this->identicalTo(0)) + ->willReturn(true); + + $this->assertTrue($objectCache->set('group', 'key', 'value')); + $this->assertSame(['group:key' => 'value'], $cacheProperty->getValue($objectCache)); + } + + public function testSetStoresAlloptionsInPersistentCacheWhenNotInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + + $objectCache->expects($this->exactly(3)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key1'), $this->identicalTo('value1'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key1' => true, 'key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->replace('options', 'alloptions', ['key1' => 'value1', 'key2' => 'value2'])); + + $this->assertSame([ + 'options:alloptions_keys' => ['key1' => true, 'key2' => true], + 'alloptions_values:key1' => 'value1', + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key1' => 'value1', 'key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testSetStoresDeletesUnusedAlloptionsOptionsInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['alloptions_values:key1' => 'value1']); + + $objectCache->expects($this->once()) + ->method('deleteValueFromPersistentCache') + ->with($this->identicalTo('alloptions_values:key1')) + ->willReturn(true); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('options:alloptions_keys')) + ->willReturn(['key1' => true]); + + $objectCache->expects($this->exactly(2)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->set('options', 'alloptions', ['key2' => 'value2'])); + + $this->assertSame([ + 'options:alloptions_keys' => ['key2' => true], + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testSetStoresOnlyMissingAlloptionsOptionsInPersistentCache() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $cacheProperty = $objectCacheReflection->getProperty('cache'); + $cacheProperty->setAccessible(true); + $cacheProperty->setValue($objectCache, ['alloptions_values:key1' => 'value1']); + + $objectCache->expects($this->once()) + ->method('getValuesFromPersistentCache') + ->with($this->identicalTo('options:alloptions_keys')) + ->willReturn(['key1' => true]); + + $objectCache->expects($this->exactly(2)) + ->method('storeValueInPersistentCache') + ->withConsecutive( + [$this->identicalTo('alloptions_values:key2'), $this->identicalTo('value2'), $this->identicalTo(0), $this->identicalTo(0)], + [$this->identicalTo('options:alloptions_keys'), $this->identicalTo(['key1' => true, 'key2' => true]), $this->identicalTo(0), $this->identicalTo(0)] + ) + ->willReturn(true); + + $this->assertTrue($objectCache->set('options', 'alloptions', ['key1' => 'value1', 'key2' => 'value2'])); + + $this->assertSame([ + 'alloptions_values:key1' => 'value1', + 'options:alloptions_keys' => ['key1' => true, 'key2' => true], + 'alloptions_values:key2' => 'value2', + 'options:alloptions' => ['key1' => 'value1', 'key2' => 'value2'], + ], $cacheProperty->getValue($objectCache)); + } + + public function testSwitchToBlogWithMultisiteDisabled() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [false]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $blogIdProperty = $objectCacheReflection->getProperty('blogId'); + $blogIdProperty->setAccessible(true); + + $this->assertNull($blogIdProperty->getValue($objectCache)); + + $objectCache->switchToBlog(3); + + $this->assertNull($blogIdProperty->getValue($objectCache)); + } + + public function testSwitchToBlogWithMultisiteEnabled() + { + $objectCache = $this->getMockForAbstractClass(AbstractPersistentObjectCache::class, [true]); + $objectCacheReflection = new \ReflectionClass(AbstractPersistentObjectCache::class); + + $blogIdProperty = $objectCacheReflection->getProperty('blogId'); + $blogIdProperty->setAccessible(true); + + $this->assertNull($blogIdProperty->getValue($objectCache)); + + $objectCache->switchToBlog(3); + + $this->assertSame(3, $blogIdProperty->getValue($objectCache)); + } +} diff --git a/tests/Unit/ObjectCache/DynamoDbObjectCacheTest.php b/tests/Unit/ObjectCache/DynamoDbObjectCacheTest.php new file mode 100644 index 0000000..d22b95a --- /dev/null +++ b/tests/Unit/ObjectCache/DynamoDbObjectCacheTest.php @@ -0,0 +1,492 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Ymir\Plugin\Tests\Unit\ObjectCache; + +use Ymir\Plugin\ObjectCache\DynamoDbObjectCache; +use Ymir\Plugin\Tests\Mock\DynamoDbClientMockTrait; +use Ymir\Plugin\Tests\Mock\FunctionMockTrait; +use Ymir\Plugin\Tests\Unit\TestCase; + +/** + * @covers \Ymir\Plugin\ObjectCache\DynamoDbObjectCache + */ +class DynamoDbObjectCacheTest extends TestCase +{ + use DynamoDbClientMockTrait; + use FunctionMockTrait; + + public function testAddWithExpiry() + { + $client = $this->getDynamoDbClientMock(); + $time = $this->getFunctionMock($this->getNamespace(DynamoDbObjectCache::class), 'time'); + + $client->expects($this->once()) + ->method('putItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'Item' => [ + 'key' => ['S' => 'group:key'], + 'value' => ['S' => 's:5:"value";'], + 'expires_at' => ['N' => 60], + ], + 'ConditionExpression' => 'attribute_not_exists(#key) OR #expires_at < :now', + 'ExpressionAttributeNames' => [ + '#key' => 'key', + '#expires_at' => 'expires_at', + ], + 'ExpressionAttributeValues' => [ + ':now' => ['N' => '42'], + ], + ])); + + $time->expects($this->once()) + ->willReturn(42); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertTrue($objectCache->add('group', 'key', 'value', 60)); + } + + public function testAddWithoutExpiry() + { + $client = $this->getDynamoDbClientMock(); + $time = $this->getFunctionMock($this->getNamespace(DynamoDbObjectCache::class), 'time'); + + $client->expects($this->once()) + ->method('putItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'Item' => [ + 'key' => ['S' => 'group:key'], + 'value' => ['S' => 's:5:"value";'], + ], + 'ConditionExpression' => 'attribute_not_exists(#key) OR #expires_at < :now', + 'ExpressionAttributeNames' => [ + '#key' => 'key', + '#expires_at' => 'expires_at', + ], + 'ExpressionAttributeValues' => [ + ':now' => ['N' => '42'], + ], + ])); + + $time->expects($this->once()) + ->willReturn(42); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertTrue($objectCache->add('group', 'key', 'value')); + } + + public function testDelete() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('deleteItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'Key' => [ + 'key' => ['S' => 'group:key'], + ], + ])); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertTrue($objectCache->delete('group', 'key')); + } + + public function testFlush() + { + $client = $this->getDynamoDbClientMock(); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertTrue($objectCache->flush()); + } + + public function testGetMultipleReturnsAllValues() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('batchGetItem') + ->with($this->identicalTo([ + 'RequestItems' => [ + 'table' => [ + 'ConsistentRead' => false, + 'Keys' => [ + ['key' => ['S' => 'group:key1']], + ['key' => ['S' => 'group:key2']], + ], + ], + ], + ])) + ->willReturn([ + 'Responses' => ['table' => [ + ['key' => ['S' => 'group:key1'], 'value' => ['S' => 's:3:"foo";']], + ['key' => ['S' => 'group:key2'], 'value' => ['S' => 's:3:"bar";']], + ]], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame([ + 'key1' => 'foo', + 'key2' => 'bar', + ], $objectCache->getMultiple('group', ['key1', 'key2'])); + } + + public function testGetMultipleWithExpiredValues() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('batchGetItem') + ->with($this->identicalTo([ + 'RequestItems' => [ + 'table' => [ + 'ConsistentRead' => false, + 'Keys' => [ + ['key' => ['S' => 'group:key1']], + ['key' => ['S' => 'group:key2']], + ], + ], + ], + ])) + ->willReturn([ + 'Responses' => ['table' => [ + ['key' => ['S' => 'group:key1'], 'value' => ['S' => 's:3:"foo";'], 'expires_at' => ['N' => 0]], + ['key' => ['S' => 'group:key2'], 'value' => ['S' => 's:3:"bar";']], + ]], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame([ + 'key1' => false, + 'key2' => 'bar', + ], $objectCache->getMultiple('group', ['key1', 'key2'])); + } + + public function testGetMultipleWithInvalidResponse() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('batchGetItem') + ->with($this->identicalTo([ + 'RequestItems' => [ + 'table' => [ + 'ConsistentRead' => false, + 'Keys' => [ + ['key' => ['S' => 'group:key1']], + ['key' => ['S' => 'group:key2']], + ], + ], + ], + ])); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame([ + 'key1' => false, + 'key2' => false, + ], $objectCache->getMultiple('group', ['key1', 'key2'])); + } + + public function testGetMultipleWithMissingValues() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('batchGetItem') + ->with($this->identicalTo([ + 'RequestItems' => [ + 'table' => [ + 'ConsistentRead' => false, + 'Keys' => [ + ['key' => ['S' => 'group:key1']], + ['key' => ['S' => 'group:key2']], + ], + ], + ], + ])) + ->willReturn([ + 'Responses' => ['table' => [ + ['key' => ['S' => 'group:key2'], 'value' => ['S' => 's:3:"bar";']], + ]], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame([ + 'key1' => false, + 'key2' => 'bar', + ], $objectCache->getMultiple('group', ['key1', 'key2'])); + } + + public function testGetReturnsFalseWhenExpired() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('getItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'ConsistentRead' => false, + 'Key' => [ + 'key' => ['S' => 'group:key'], + ], + ])) + ->willReturn([ + 'Item' => ['value' => ['S' => '5.1'], 'expires_at' => ['N' => 0]], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertFalse($objectCache->get('group', 'key')); + } + + public function testGetReturnsFalseWithInvalidResponse() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('getItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'ConsistentRead' => false, + 'Key' => [ + 'key' => ['S' => 'group:key'], + ], + ])); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertFalse($objectCache->get('group', 'key')); + } + + public function testGetReturnsFloat() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('getItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'ConsistentRead' => false, + 'Key' => [ + 'key' => ['S' => 'group:key'], + ], + ])) + ->willReturn([ + 'Item' => ['value' => ['S' => '5.1']], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame(5.1, $objectCache->get('group', 'key')); + } + + public function testGetReturnsInt() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('getItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'ConsistentRead' => false, + 'Key' => [ + 'key' => ['S' => 'group:key'], + ], + ])) + ->willReturn([ + 'Item' => ['value' => ['S' => '5']], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame(5, $objectCache->get('group', 'key')); + } + + public function testGetUnserializesFloat() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('getItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'ConsistentRead' => false, + 'Key' => [ + 'key' => ['S' => 'group:key'], + ], + ])) + ->willReturn([ + 'Item' => ['value' => ['S' => 'd:5.1;']], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame(5.1, $objectCache->get('group', 'key')); + } + + public function testGetUnserializesInt() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('getItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'ConsistentRead' => false, + 'Key' => [ + 'key' => ['S' => 'group:key'], + ], + ])) + ->willReturn([ + 'Item' => ['value' => ['S' => 'i:5;']], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame(5, $objectCache->get('group', 'key')); + } + + public function testGetUnserializesString() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('getItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'ConsistentRead' => false, + 'Key' => [ + 'key' => ['S' => 'group:key'], + ], + ])) + ->willReturn([ + 'Item' => ['value' => ['S' => 's:5:"value";']], + ]); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertSame('value', $objectCache->get('group', 'key')); + } + + public function testReplaceWithExpiry() + { + $client = $this->getDynamoDbClientMock(); + $time = $this->getFunctionMock($this->getNamespace(DynamoDbObjectCache::class), 'time'); + + $client->expects($this->once()) + ->method('putItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'Item' => [ + 'key' => ['S' => 'group:key'], + 'value' => ['S' => 's:5:"value";'], + 'expires_at' => ['N' => 60], + ], + 'ConditionExpression' => 'attribute_exists(#key) AND #expires_at > :now', + 'ExpressionAttributeNames' => [ + '#key' => 'key', + '#expires_at' => 'expires_at', + ], + 'ExpressionAttributeValues' => [ + ':now' => ['N' => '42'], + ], + ])); + + $time->expects($this->once()) + ->willReturn(42); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertTrue($objectCache->replace('group', 'key', 'value', 60)); + } + + public function testReplaceWithoutExpiry() + { + $client = $this->getDynamoDbClientMock(); + $time = $this->getFunctionMock($this->getNamespace(DynamoDbObjectCache::class), 'time'); + + $client->expects($this->once()) + ->method('putItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'Item' => [ + 'key' => ['S' => 'group:key'], + 'value' => ['S' => 's:5:"value";'], + ], + 'ConditionExpression' => 'attribute_exists(#key) AND #expires_at > :now', + 'ExpressionAttributeNames' => [ + '#key' => 'key', + '#expires_at' => 'expires_at', + ], + 'ExpressionAttributeValues' => [ + ':now' => ['N' => '42'], + ], + ])); + + $time->expects($this->once()) + ->willReturn(42); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertTrue($objectCache->replace('group', 'key', 'value')); + } + + public function testSetWithExpiry() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('putItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'Item' => [ + 'key' => ['S' => 'group:key'], + 'value' => ['S' => 's:5:"value";'], + 'expires_at' => ['N' => 60], + ], + ])); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertTrue($objectCache->set('group', 'key', 'value', 60)); + } + + public function testSetWithoutExpiry() + { + $client = $this->getDynamoDbClientMock(); + + $client->expects($this->once()) + ->method('putItem') + ->with($this->identicalTo([ + 'TableName' => 'table', + 'Item' => [ + 'key' => ['S' => 'group:key'], + 'value' => ['S' => 's:5:"value";'], + ], + ])); + + $objectCache = new DynamoDbObjectCache($client, false, 'table'); + + $this->assertTrue($objectCache->set('group', 'key', 'value')); + } +} diff --git a/tests/Unit/Subscriber/HttpApiSubscriberTest.php b/tests/Unit/Subscriber/HttpApiSubscriberTest.php deleted file mode 100644 index 0146f31..0000000 --- a/tests/Unit/Subscriber/HttpApiSubscriberTest.php +++ /dev/null @@ -1,52 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Ymir\Plugin\Tests\Unit\Subscriber; - -use Ymir\Plugin\Subscriber\HttpApiSubscriber; -use Ymir\Plugin\Tests\Mock\FunctionMockTrait; -use Ymir\Plugin\Tests\Unit\TestCase; - -/** - * @covers \Ymir\Plugin\Subscriber\HttpApiSubscriber - */ -class HttpApiSubscriberTest extends TestCase -{ - use FunctionMockTrait; - - public function testAddPostfieldsForEmptyPutRequest() - { - $handle = fopen('php://temp', 'r+'); - - $curl_setopt = $this->getFunctionMock($this->getNamespace(HttpApiSubscriber::class), 'curl_setopt'); - $curl_setopt->expects($this->once()) - ->with($this->identicalTo($handle), $this->identicalTo(CURLOPT_POSTFIELDS), $this->identicalTo('')); - - (new HttpApiSubscriber())->addPostfieldsForEmptyPutRequest($handle, ['method' => 'PUT', 'body' => '']); - } - - public function testGetSubscribedEvents() - { - $callbacks = HttpApiSubscriber::getSubscribedEvents(); - - foreach ($callbacks as $callback) { - $this->assertTrue(method_exists(HttpApiSubscriber::class, is_array($callback) ? $callback[0] : $callback)); - } - - $subscribedEvents = [ - 'http_api_curl' => ['addPostfieldsForEmptyPutRequest', 10, 3], - ]; - - $this->assertSame($subscribedEvents, $callbacks); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index af8397d..0c85a60 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -30,9 +30,7 @@ DG\BypassFinals::enable(); require_once $_core_dir.'/wp-admin/includes/class-wp-site-icon.php'; -require_once $_core_dir.'/wp-includes/class-requests.php'; require_once $_core_dir.'/wp-includes/class-phpmailer.php'; -require_once $_core_dir.'/wp-includes/class-http.php'; require_once $_core_dir.'/wp-includes/class-wp-error.php'; require_once $_core_dir.'/wp-includes/class-wp-image-editor.php'; require_once $_core_dir.'/wp-includes/class-wp-post.php'; diff --git a/ymir.php b/ymir.php index 967da7e..6f52071 100644 --- a/ymir.php +++ b/ymir.php @@ -20,23 +20,17 @@ License: GPL3 */ -if (version_compare(PHP_VERSION, '7.2', '<')) { - exit(sprintf('Ymir requires PHP 7.2 or higher. Your WordPress site is using PHP %s.', PHP_VERSION)); -} - -// Setup class autoloader -require_once dirname(__FILE__).'/src/Autoloader.php'; -\Ymir\Plugin\Autoloader::register(); +require_once __DIR__.'/bootstrap.php'; -// Load plugin global $ymir; -$ymir = new \Ymir\Plugin\Plugin(__FILE__); + +// Add load plugin action add_action('after_setup_theme', array($ymir, 'load')); // Load Ymir pluggable functions if the plugin is active if (!function_exists('is_plugin_active')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } -if (is_plugin_active($ymir->getContainer()['plugin_basename'])) { - require_once dirname(__FILE__).'/pluggable.php'; +if (is_plugin_active(plugin_basename(__FILE__))) { + require_once __DIR__.'/pluggable.php'; }