From df87a6eed6f44278270925fa83ac0e6abd6f481f Mon Sep 17 00:00:00 2001 From: David Herrera Date: Sun, 17 Nov 2024 20:28:10 -0500 Subject: [PATCH 1/3] Add client for speculatively loading values based on URL --- CHANGELOG.md | 18 ++ README.md | 46 +++++- composer.json | 5 +- src/{ => big-pit}/class-big-pit.php | 72 +++----- src/big-pit/class-big-speculative-pit.php | 192 ++++++++++++++++++++++ src/big-pit/class-items.php | 81 +++++++++ src/big-pit/interface-client.php | 48 ++++++ src/simplecache/class-big-pit-adapter.php | 17 +- tests/Feature/BigPitTest.php | 25 +++ tests/Feature/BigSpeculativePitTest.php | 36 ++++ tests/Feature/ExampleFeatureTest.php | 25 --- tests/TestCase.php | 47 ++++-- tests/Unit/CrudUnitTest.php | 49 ------ tests/Unit/InMemoryCacheTest.php | 8 +- 14 files changed, 515 insertions(+), 154 deletions(-) rename src/{ => big-pit}/class-big-pit.php (75%) create mode 100644 src/big-pit/class-big-speculative-pit.php create mode 100644 src/big-pit/class-items.php create mode 100644 src/big-pit/interface-client.php create mode 100644 tests/Feature/BigPitTest.php create mode 100644 tests/Feature/BigSpeculativePitTest.php delete mode 100644 tests/Feature/ExampleFeatureTest.php delete mode 100644 tests/Unit/CrudUnitTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa3d87..54df7c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ This library adheres to [Semantic Versioning](https://semver.org/) and [Keep a CHANGELOG](https://keepachangelog.com/en/1.0.0/). +## 0.5.0 + +### Added + +- `Client` interface, extending `Alley\WP\Types\Feature`, for implementing a Big Pit client. +- `Items` class for clients that want to keep their own in-memory cache of fetched items. +- `Big_Speculative_Pit` client for preloading items used the last time a URL was requested. + +### Changed + +- `Big_Pit` is now in the `Alley\WP\Big_Pit` subnamespace, implements the `Client` interface, and must be instantiated with the `new` keyword. +- The `boot()` method on clients must be called manually. +- The `$wpdb->big_pit` property will be unset if the table is not available. + +### Removed + +- `Big_Pit::instance()` method. + ## 0.4.0 ### Added diff --git a/README.md b/README.md index 1143a70..85fa89c 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,33 @@ You can install the package via Composer: composer require alleyinteractive/wp-big-pit ``` -## Usage +## API + +The `Alley\WP\Big_Pit\Client` interface describes the create-read-update-delete operations that can be used with the Big Pit. You can type hint against this interface when using Big Pit as a dependency. + +```php +namespace Alley\WP\Big_Pit; + +use Alley\WP\Types\Feature; + +interface Client extends Feature { + public function get( string $key, string $group ): mixed; + + public function set( string $key, mixed $value, string $group ): void; + + public function delete( string $key, string $group ): void; + + public function flush_group( string $group ): void; +} +``` Each item in the Big Pit has a key and a group, much like the WordPress object cache. Each key is unique within a group. -### Direct Access +`Client` extends the `Alley\WP\Types\Feature` interface from the [Type Extensions]() library, which includes a `boot()` method for performing side effects. + +You must call `boot()` before using the client. If you are compiling features using the `Features` instance from Type Extensions, you can include the Big Pit client, and it will be booted with the rest of your feature classes. -You can perform CRUD operations directly on The Pit: +## Usage ```php boot(); $big_pit->set( $external_id, $api_response, 'movie_reviews' ); $big_pit->get( $external_id, 'movie_reviews' ); // '{"id":"abcdef12345","title":"The Best Movie Ever","rating":5}' @@ -34,6 +55,23 @@ $big_pit->delete( $external_id, 'movie_reviews' ); $big_pit->flush_group( 'movie_reviews' ); ``` +### Speculative Client + +The `Big_Speculative_Pit` decorator class tracks the items that are fetched during a given request and preloads those items in a single query the next time the same page is requested. + +```php +> + * Fetched items. * - * @var array[] + * @var Items */ - private array $cache = []; + private readonly Items $items; /** - * Instance. - * - * @return self + * Constructor. */ - public static function instance(): self { - if ( null === self::$instance ) { - self::$instance = new self(); - self::$instance->boot(); - } - - return self::$instance; + public function __construct() { + $this->items = new Items(); } /** @@ -67,10 +42,8 @@ public function boot(): void { try { $this->upsert(); - $this->ready = true; } catch ( \Exception $e ) { - // Do nothing. - unset( $e ); + unset( $wpdb->big_pit ); } } @@ -84,19 +57,12 @@ public function boot(): void { public function get( string $key, string $group ): mixed { global $wpdb; - if ( ! $this->ready ) { + if ( ! isset( $wpdb->big_pit ) ) { return null; } - if ( isset( $this->cache[ $group ] ) && array_key_exists( $key, $this->cache[ $group ] ) ) { - $value = $this->cache[ $group ][ $key ]; - - if ( is_object( $value ) ) { - // Don't reuse the same instance across multiple calls. - $value = clone $value; - } - - return $value; + if ( $this->items->has( $key, $group ) ) { + return $this->items->get( $key, $group ); } $value = $wpdb->get_var( @@ -111,7 +77,7 @@ public function get( string $key, string $group ): mixed { $value = maybe_unserialize( $value ); } - $this->cache[ $group ][ $key ] = $value; + $this->items->add( $key, $value, $group ); return $value; } @@ -126,7 +92,7 @@ public function get( string $key, string $group ): mixed { public function set( string $key, mixed $value, string $group ): void { global $wpdb; - if ( ! $this->ready ) { + if ( ! isset( $wpdb->big_pit ) ) { return; } @@ -164,7 +130,7 @@ public function set( string $key, mixed $value, string $group ): void { ); } - unset( $this->cache[ $group ][ $key ] ); + $this->items->remove( $key, $group ); } /** @@ -176,7 +142,7 @@ public function set( string $key, mixed $value, string $group ): void { public function delete( string $key, string $group ): void { global $wpdb; - if ( ! $this->ready ) { + if ( ! isset( $wpdb->big_pit ) ) { return; } @@ -189,7 +155,7 @@ public function delete( string $key, string $group ): void { [ '%s', '%s' ], ); - unset( $this->cache[ $group ][ $key ] ); + $this->items->remove( $key, $group ); } /** @@ -200,7 +166,7 @@ public function delete( string $key, string $group ): void { public function flush_group( string $group ): void { global $wpdb; - if ( ! $this->ready ) { + if ( ! isset( $wpdb->big_pit ) ) { return; } @@ -212,7 +178,7 @@ public function flush_group( string $group ): void { [ '%s' ], ); - unset( $this->cache[ $group ] ); + $this->items->remove_group( $group ); } /** diff --git a/src/big-pit/class-big-speculative-pit.php b/src/big-pit/class-big-speculative-pit.php new file mode 100644 index 0000000..793b1bd --- /dev/null +++ b/src/big-pit/class-big-speculative-pit.php @@ -0,0 +1,192 @@ +> + * + * @var array[] + */ + private array $saved_keys = []; + + /** + * Fetched keys by group. + * + * @phpstan-var array> + * + * @var array[] + */ + private array $fetched_keys = []; + + /** + * Preloaded items. + * + * @var Items + */ + private readonly Items $items; + + /** + * Constructor. + * + * @param Request $request Current request. + * @param Client $origin Client instance. + */ + public function __construct( + private readonly Request $request, + private readonly Client $origin, + ) { + $this->items = new Items(); + } + + /** + * Boot the feature. + */ + public function boot(): void { + // Since this class isn't responsible for creating the table, make sure upstream classes can do so. + $this->origin->boot(); + + /* + * Preload once here and be done with it, rather than having to make sure in every method that values were + * preloaded and that preloading happens only once. Trades a DB query for a simpler implementation. + */ + $this->preload(); + + // Priority 0 so that it's visible in Query Monitor. + add_action( 'shutdown', [ $this, 'on_shutdown' ], 0 ); + } + + /** + * Get a value. + * + * @param string $key Item key. + * @param string $group Item group. + * @return mixed|null + */ + public function get( string $key, string $group ): mixed { + $this->fetched_keys[ $group ][] = $key; + + if ( $this->items->has( $key, $group ) ) { + return $this->items->get( $key, $group ); + } + + return $this->origin->get( $key, $group ); + } + + /** + * Set a value. + * + * @param string $key Item key. + * @param mixed $value Item value. + * @param string $group Item group. + */ + public function set( string $key, mixed $value, string $group ): void { + $this->items->remove( $key, $group ); + $this->origin->set( $key, $value, $group ); + } + + /** + * Delete a value. + * + * @param string $key Item key. + * @param string $group Item group. + */ + public function delete( string $key, string $group ): void { + $this->items->remove( $key, $group ); + $this->origin->delete( $key, $group ); + } + + /** + * Delete all values in a group. + * + * @param string $group Item group. + */ + public function flush_group( string $group ): void { + $this->items->remove_group( $group ); + $this->origin->flush_group( $group ); + } + + /** + * Save the keys that were fetched if they changed. + */ + public function on_shutdown(): void { + // Sort the fetched keys to ensure consistent ordering. + ksort( $this->fetched_keys ); + foreach ( array_keys( $this->fetched_keys ) as $group ) { + $this->fetched_keys[ $group ] = array_unique( $this->fetched_keys[ $group ] ); + sort( $this->fetched_keys[ $group ] ); + } + + if ( + $this->fetched_keys !== $this->saved_keys + && ( count( $this->fetched_keys ) > 0 || count( $this->saved_keys ) > 0 ) + ) { + $this->origin->set( $this->key(), $this->fetched_keys, 'big_speculative_pit' ); + } + } + + /** + * Preload the values. + */ + private function preload(): void { + global $wpdb; + + if ( ! isset( $wpdb->big_pit ) ) { + return; + } + + $saved = $this->origin->get( $this->key(), 'big_speculative_pit' ); + + $this->saved_keys = is_array( $saved ) ? $saved : []; + + foreach ( $this->saved_keys as $group => $keys ) { + $this->items->remove_group( $group ); + + $items = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber + 'SELECT item_key, item_value' + . " FROM {$wpdb->big_pit}" + . ' WHERE item_group = %s' + . ' AND item_key IN (' . implode( ',', array_fill( 0, count( $keys ), '%s' ) ) . ')', + $group, + ...$keys, + ), + ); + + if ( is_array( $items ) ) { + $items = array_column( $items, 'item_value', 'item_key' ); + + foreach ( $keys as $key ) { + $value = $items[ $key ] ?? null; + + if ( is_string( $value ) ) { + $value = maybe_unserialize( $value ); + } + + $this->items->add( $key, $value, $group ); + } + } + } + } + + /** + * Key for storing keys for this request. + * + * @return string + */ + private function key(): string { + return $this->request->getUri(); + } +} diff --git a/src/big-pit/class-items.php b/src/big-pit/class-items.php new file mode 100644 index 0000000..7be5955 --- /dev/null +++ b/src/big-pit/class-items.php @@ -0,0 +1,81 @@ +> + * + * @var array[] + */ + private array $cache = []; + + /** + * Check if a value exists. + * + * @param string $key Item key. + * @param string $group Item group. + * @return bool + */ + public function has( string $key, string $group ): bool { + return isset( $this->cache[ $group ] ) && array_key_exists( $key, $this->cache[ $group ] ); + } + + /** + * Get a value. + * + * @param string $key Item key. + * @param string $group Item group. + * @return mixed + */ + public function get( string $key, string $group ): mixed { + $value = $this->cache[ $group ][ $key ]; + + if ( is_object( $value ) ) { + // Don't reuse the same instance across multiple calls. + $value = clone $value; + } + + return $value; + } + + /** + * Add a value. + * + * @param string $key Item key. + * @param mixed $value Item value. + * @param string $group Item group. + */ + public function add( string $key, mixed $value, string $group ): void { + $this->cache[ $group ][ $key ] = $value; + } + + /** + * Remove a value. + * + * @param string $key Item key. + * @param string $group Item group. + */ + public function remove( string $key, string $group ): void { + unset( $this->cache[ $group ][ $key ] ); + } + + /** + * Remove a group. + * + * @param string $group Item group. + */ + public function remove_group( string $group ): void { + unset( $this->cache[ $group ] ); + } +} diff --git a/src/big-pit/interface-client.php b/src/big-pit/interface-client.php new file mode 100644 index 0000000..150a58f --- /dev/null +++ b/src/big-pit/interface-client.php @@ -0,0 +1,48 @@ +boot(); + $this->crud_assertions( $client ); + } +} diff --git a/tests/Feature/BigSpeculativePitTest.php b/tests/Feature/BigSpeculativePitTest.php new file mode 100644 index 0000000..3bc3a4b --- /dev/null +++ b/tests/Feature/BigSpeculativePitTest.php @@ -0,0 +1,36 @@ +boot(); + $this->crud_assertions( $client ); + + // Save keys to the database to test that CRUD operations also complete successfully after values are preloaded. + $client->on_shutdown(); + + $client = new Big_Speculative_Pit( $request, new Big_Pit() ); + $client->boot(); + $this->crud_assertions( $client ); + } +} diff --git a/tests/Feature/ExampleFeatureTest.php b/tests/Feature/ExampleFeatureTest.php deleted file mode 100644 index d2fcc12..0000000 --- a/tests/Feature/ExampleFeatureTest.php +++ /dev/null @@ -1,25 +0,0 @@ -assertTrue( true ); - $this->assertNotEmpty( home_url() ); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php index 198fca8..371964c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,31 +8,56 @@ namespace Alley\WP\Big_Pit\Tests; use Alley\WP\Big_Pit; +use Alley\WP\Big_Pit\Client; use Mantle\Testkit\Test_Case as TestkitTest_Case; /** * Big Pit Base Test Case */ abstract class TestCase extends TestkitTest_Case { - /** - * Create the database table. - */ - public static function setUpBeforeClass(): void { - parent::setUpBeforeClass(); - - Big_Pit::instance()->boot(); - } - /** * Drop the database table. */ - public static function tearDownAfterClass(): void { + public function tearDown(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange $wpdb->query( 'DROP TABLE ' . $wpdb->big_pit ); delete_option( 'wp_big_pit_database_version' ); - parent::tearDownAfterClass(); + parent::tearDown(); + } + + /** + * Test CRUD operations. + * + * @param Client $client Client to test. + */ + public function crud_assertions( Client $client ): void { + $key1 = rand_str(); + $key2 = rand_str(); + $key3 = rand_str(); + $val1 = rand_str(); + $val2 = rand_str(); + $val3 = rand_str(); + $grp1 = rand_str(); + $grp2 = rand_str(); + + $client->set( $key1, $val1, $grp1 ); + $client->set( $key2, $val2, $grp1 ); + $client->set( $key3, $val3, $grp2 ); + + // Get key1, delete it, and assert it's gone. + $this->assertSame( $val1, $client->get( $key1, $grp1 ) ); + $client->delete( $key1, $grp1 ); + $this->assertNull( $client->get( $key1, $grp1 ) ); + + // Get key2, flush group1, and assert it's gone. + $this->assertSame( $val2, $client->get( $key2, $grp1 ) ); + $client->flush_group( $grp1 ); + $this->assertNull( $client->get( $key2, $grp1 ) ); + + // key3 should still be there. + $this->assertSame( $val3, $client->get( $key3, $grp2 ) ); } } diff --git a/tests/Unit/CrudUnitTest.php b/tests/Unit/CrudUnitTest.php deleted file mode 100644 index 721aac4..0000000 --- a/tests/Unit/CrudUnitTest.php +++ /dev/null @@ -1,49 +0,0 @@ -set( $key1, $val1, $grp1 ); - $big_pit->set( $key2, $val2, $grp1 ); - $big_pit->set( $key3, $val3, $grp2 ); - - // Get key1, delete it, and assert it's gone. - $this->assertSame( $val1, $big_pit->get( $key1, $grp1 ) ); - $big_pit->delete( $key1, $grp1 ); - $this->assertNull( $big_pit->get( $key1, $grp1 ) ); - - // Get key2, flush group1, and assert it's gone. - $this->assertSame( $val2, $big_pit->get( $key2, $grp1 ) ); - $big_pit->flush_group( $grp1 ); - $this->assertNull( $big_pit->get( $key2, $grp1 ) ); - - // key3 should still be there. - $this->assertSame( $val3, $big_pit->get( $key3, $grp2 ) ); - } -} diff --git a/tests/Unit/InMemoryCacheTest.php b/tests/Unit/InMemoryCacheTest.php index e3c48f2..50504ea 100644 --- a/tests/Unit/InMemoryCacheTest.php +++ b/tests/Unit/InMemoryCacheTest.php @@ -7,7 +7,7 @@ namespace Alley\WP\Big_Pit\Tests\Unit; -use Alley\WP\Big_Pit; +use Alley\WP\Big_Pit\Big_Pit; use Alley\WP\Big_Pit\Tests\TestCase; /** @@ -20,7 +20,8 @@ class InMemoryCacheTest extends TestCase { public function test_in_memory_cache() { global $wpdb; - $big_pit = Big_Pit::instance(); + $big_pit = new Big_Pit(); + $big_pit->boot(); // Two fetches of the same key should only result in one query. $num_queries_before = $wpdb->num_queries; @@ -63,7 +64,8 @@ public function test_in_memory_cache() { * Check for improper reuse of a cached object. */ public function test_cached_object_reference() { - $big_pit = Big_Pit::instance(); + $big_pit = new Big_Pit(); + $big_pit->boot(); $big_pit->set( 'key1', (object) [ 'foo' => 'bar' ], 'group1' ); $this->assertNotSame( $big_pit->get( 'key1', 'group1' ), $big_pit->get( 'key1', 'group1' ) ); } From bbd9747088a800811ec20910bdb89f3f2bb749ce Mon Sep 17 00:00:00 2001 From: David Herrera Date: Fri, 22 Nov 2024 01:58:04 -0500 Subject: [PATCH 2/3] Clean up DB access --- src/big-pit/class-big-pit.php | 20 ++++++++++++++++++-- src/big-pit/class-big-speculative-pit.php | 5 ++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/big-pit/class-big-pit.php b/src/big-pit/class-big-pit.php index 3887a95..a13b73e 100644 --- a/src/big-pit/class-big-pit.php +++ b/src/big-pit/class-big-pit.php @@ -57,6 +57,8 @@ public function boot(): void { public function get( string $key, string $group ): mixed { global $wpdb; + assert( $wpdb instanceof \wpdb ); + if ( ! isset( $wpdb->big_pit ) ) { return null; } @@ -67,7 +69,8 @@ public function get( string $key, string $group ): mixed { $value = $wpdb->get_var( $wpdb->prepare( - "SELECT item_value FROM {$wpdb->big_pit} WHERE item_group = %s AND item_key = %s LIMIT 1", + 'SELECT item_value FROM %i WHERE item_group = %s AND item_key = %s LIMIT 1', + $wpdb->big_pit, $group, $key ), @@ -92,6 +95,8 @@ public function get( string $key, string $group ): mixed { public function set( string $key, mixed $value, string $group ): void { global $wpdb; + assert( $wpdb instanceof \wpdb ); + if ( ! isset( $wpdb->big_pit ) ) { return; } @@ -100,7 +105,8 @@ public function set( string $key, mixed $value, string $group ): void { $exists = $wpdb->get_var( $wpdb->prepare( - "SELECT item_id FROM {$wpdb->big_pit} WHERE item_group = %s AND item_key = %s LIMIT 1", + 'SELECT item_id FROM %i WHERE item_group = %s AND item_key = %s LIMIT 1', + $wpdb->big_pit, $group, $key ), @@ -142,6 +148,8 @@ public function set( string $key, mixed $value, string $group ): void { public function delete( string $key, string $group ): void { global $wpdb; + assert( $wpdb instanceof \wpdb ); + if ( ! isset( $wpdb->big_pit ) ) { return; } @@ -166,6 +174,8 @@ public function delete( string $key, string $group ): void { public function flush_group( string $group ): void { global $wpdb; + assert( $wpdb instanceof \wpdb ); + if ( ! isset( $wpdb->big_pit ) ) { return; } @@ -189,6 +199,8 @@ public function flush_group( string $group ): void { private function upsert(): void { global $wpdb; + assert( $wpdb instanceof \wpdb ); + $available_version = '2'; $installed_version = get_option( 'wp_big_pit_database_version', '0' ); @@ -196,6 +208,10 @@ private function upsert(): void { return; } + if ( ! isset( $wpdb->big_pit ) ) { + return; + } + if ( ! function_exists( 'dbDelta' ) ) { require_once ABSPATH . '/wp-admin/includes/upgrade.php'; } diff --git a/src/big-pit/class-big-speculative-pit.php b/src/big-pit/class-big-speculative-pit.php index 793b1bd..8ff79c6 100644 --- a/src/big-pit/class-big-speculative-pit.php +++ b/src/big-pit/class-big-speculative-pit.php @@ -143,6 +143,8 @@ public function on_shutdown(): void { private function preload(): void { global $wpdb; + assert( $wpdb instanceof \wpdb ); + if ( ! isset( $wpdb->big_pit ) ) { return; } @@ -157,9 +159,10 @@ private function preload(): void { $items = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber 'SELECT item_key, item_value' - . " FROM {$wpdb->big_pit}" + . ' FROM %i' . ' WHERE item_group = %s' . ' AND item_key IN (' . implode( ',', array_fill( 0, count( $keys ), '%s' ) ) . ')', + $wpdb->big_pit, $group, ...$keys, ), From 600415216bb521c4f417eab9813142a96c70b97e Mon Sep 17 00:00:00 2001 From: David Herrera Date: Mon, 25 Nov 2024 22:19:18 -0500 Subject: [PATCH 3/3] Fix link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85fa89c..c9edb3d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ interface Client extends Feature { Each item in the Big Pit has a key and a group, much like the WordPress object cache. Each key is unique within a group. -`Client` extends the `Alley\WP\Types\Feature` interface from the [Type Extensions]() library, which includes a `boot()` method for performing side effects. +`Client` extends the `Alley\WP\Types\Feature` interface from the [Type Extensions](https://github.com/alleyinteractive/wp-type-extensions) library, which includes a `boot()` method for performing side effects. You must call `boot()` before using the client. If you are compiling features using the `Features` instance from Type Extensions, you can include the Big Pit client, and it will be booted with the rest of your feature classes.