diff --git a/readme.txt b/readme.txt index c2b7dc6f..486c9306 100644 --- a/readme.txt +++ b/readme.txt @@ -174,6 +174,9 @@ A: You can upgrade to a paid account by adding your *Payment details* on your [a A: When the conversion feature is enabled (to convert images to AVIF or WebP), each image will use double the number of credits: one for compression and one for format conversion. == Changelog == += 3.7.0 = +* chore: migrated meta key from `tiny_compress_images` to `_tiny_compress_images` + = 3.6.14 = * fix: added check for valid path before deleting converted image * fix: use hook uninstall_plugin instead of uninstall.php to prevent dependency deletion diff --git a/src/class-tiny-image.php b/src/class-tiny-image.php index a586f430..d927e2c2 100644 --- a/src/class-tiny-image.php +++ b/src/class-tiny-image.php @@ -139,9 +139,30 @@ private function duplicate_check( $filenames, $file, $size_name ) { return $filenames; } + /** + * Will retrieve compression meta data for the given post_id. + * + * As migrations on large libraries can take longer, we will fall back on + * the legacy key on migrating. We can remove the LEGACY_META_KEY on 3.8.0. + * + * @since 3.7.0 + * + * @param int $post_id Attachment ID. + * @return mixed The stored tiny metadata, or '' when none exists. + */ + public static function get_tiny_metadata( $post_id ) { + $tiny_metadata = get_post_meta( $post_id, Tiny_Config::META_KEY, true ); + + if ( empty( $tiny_metadata ) ) { + $tiny_metadata = get_post_meta( $post_id, Tiny_Config::LEGACY_META_KEY, true ); + } + + return $tiny_metadata; + } + private function parse_tiny_metadata( $tiny_metadata = null ) { if ( is_null( $tiny_metadata ) ) { - $tiny_metadata = get_post_meta( $this->id, Tiny_Config::META_KEY, true ); + $tiny_metadata = self::get_tiny_metadata( $this->id ); } if ( $tiny_metadata ) { foreach ( $tiny_metadata as $size => $meta ) { diff --git a/src/class-tiny-migrate.php b/src/class-tiny-migrate.php new file mode 100644 index 00000000..de38aae1 --- /dev/null +++ b/src/class-tiny-migrate.php @@ -0,0 +1,147 @@ + Ordered map of version to migration callable. + */ + private static function migrations() { + return array( + 1 => array( self::class, 'migrate_meta_key_to_private' ), + ); + } + + /** + * Runs all pending migrations in version order. + * + * Compares the stored database version against each known migration + * and executes any that have not yet been applied. Updates the stored + * version upon completion. + * + * @since 3.7.0 + * + * @return void + */ + public static function run() { + $stored_version = (int) get_option( self::DB_VERSION_OPTION, 0 ); + + if ( $stored_version >= self::DB_VERSION ) { + return; + } + + foreach ( self::migrations() as $version => $migration ) { + if ( $stored_version >= $version ) { + continue; + } + + if ( get_transient( self::MIGRATION_BACKOFF_KEY ) ) { + // transient key to hold migrations exists so exit early + return; + } + + if ( ! call_user_func( $migration ) ) { + set_transient( self::MIGRATION_BACKOFF_KEY, 1, HOUR_IN_SECONDS ); + return; + } + } + + update_option( self::DB_VERSION_OPTION, self::DB_VERSION ); + } + + /** + * Migrates the tiny meta key from public to private. + * + * Renames all `tiny_compress_images` post meta entries to + * `_tiny_compress_images`. + * + * @since 3.7.0 + * + * @return bool True on success or when there is nothing to migrate, false on DB error. + */ + private static function migrate_meta_key_to_private() { + global $wpdb; + + $batch_size = 2500; + do { + $query = + "UPDATE $wpdb->postmeta + SET meta_key = '_tiny_compress_images' + WHERE meta_key = 'tiny_compress_images' + LIMIT " . $batch_size; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Renames a fixed internal meta key in fixed-size batches; only internal constants are interpolated. + $result = $wpdb->query( $query ); + + if ( false === $result ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'Tinify: failed to migrate meta key. DB error: ' . $wpdb->last_error ); + return false; + } + } while ( $batch_size === (int) $result ); + + wp_cache_flush(); + + return true; + } +} diff --git a/src/config/class-tiny-config.php b/src/config/class-tiny-config.php index dfab8f81..5b3aa717 100644 --- a/src/config/class-tiny-config.php +++ b/src/config/class-tiny-config.php @@ -9,5 +9,6 @@ class Tiny_Config { const SHRINK_URL = 'https://api.tinify.com/shrink'; const KEYS_URL = 'https://api.tinify.com/keys'; const MONTHLY_FREE_COMPRESSIONS = 500; - const META_KEY = 'tiny_compress_images'; + const META_KEY = '_tiny_compress_images'; + const LEGACY_META_KEY = 'tiny_compress_images'; } diff --git a/test/fixtures/class-tiny-config.php b/test/fixtures/class-tiny-config.php index 4c748efb..b17c5360 100644 --- a/test/fixtures/class-tiny-config.php +++ b/test/fixtures/class-tiny-config.php @@ -1,18 +1,19 @@ addMethod('get_locale'); $this->addMethod('wp_timezone_string'); $this->addMethod('update_option'); + $this->addMethod('update'); + $this->addMethod('query'); + $this->addMethod('wp_cache_flush'); + $this->addMethod('get_transient'); + $this->addMethod('set_transient'); + $this->addMethod('delete_transient'); $this->addMethod('check_ajax_referer'); $this->addMethod('wp_json_encode'); $this->addMethod('wp_send_json_error'); @@ -122,6 +134,7 @@ public function defaults() $this->admin_initFunctions = array(); $this->options = new WordPressOptions(); $this->metadata = array(); + $this->transients = array(); $this->filters = array(); $GLOBALS['_wp_additional_image_sizes'] = array(); } @@ -185,6 +198,18 @@ public function call($method, $args) } if ('translate' === $method) { return $args[0]; + } elseif ('get_transient' === $method) { + $key = isset($args[0]) ? $args[0] : ''; + return isset($this->transients[$key]) ? $this->transients[$key] : false; + } elseif ('set_transient' === $method) { + $key = isset($args[0]) ? $args[0] : ''; + $value = isset($args[1]) ? $args[1] : ''; + $this->transients[$key] = $value; + return true; + } elseif ('delete_transient' === $method) { + $key = isset($args[0]) ? $args[0] : ''; + unset($this->transients[$key]); + return true; } elseif ('get_option' === $method) { return call_user_func_array(array($this->options, 'get'), $args); } elseif ('get_post_meta' === $method) { @@ -224,6 +249,11 @@ public function addOption($key, $value) $this->options->set($key, $value); } + public function addTransient($key, $value) + { + $this->transients[$key] = $value; + } + public function addImageSize($size, $values) { $GLOBALS['_wp_additional_image_sizes'][$size] = $values; diff --git a/test/unit/TinyImageTest.php b/test/unit/TinyImageTest.php index f70f3b4a..114e17c8 100644 --- a/test/unit/TinyImageTest.php +++ b/test/unit/TinyImageTest.php @@ -17,7 +17,7 @@ public function set_up() { } public function test_tiny_post_meta_key_may_never_change() { - $this->assertEquals( '61b16225f107e6f0a836bf19d47aa0fd912f8925', sha1( Tiny_Config::META_KEY ) ); + $this->assertEquals( '438fc52ce17b9aedf0cf70dea52d5551affba59a', sha1( Tiny_Config::META_KEY ) ); } public function test_update_wp_metadata_should_not_update_with_no_resized_original() { diff --git a/test/unit/TinyMigrateTest.php b/test/unit/TinyMigrateTest.php new file mode 100644 index 00000000..146c40e6 --- /dev/null +++ b/test/unit/TinyMigrateTest.php @@ -0,0 +1,170 @@ +wp->stub('query', function() { + return 1; + }); + } + + /** + * Stubs $wpdb->query to return the given row counts on successive calls, + * simulating the batched UPDATE draining the table. + */ + private function queueQueryResults(array $results) + { + $index = 0; + $this->wp->stub('query', function() use (&$index, $results) { + $value = isset($results[$index]) ? $results[$index] : 0; + $index++; + return $value; + }); + } + + /** + * Helper to check if a specific option update occurred. + */ + private function assertOptionWasUpdated($option, $value) + { + $calls = $this->wp->getCalls('update_option'); + foreach ($calls as $call) { + if (isset($call[0], $call[1]) && $call[0] === $option && $call[1] === $value) { + return $this->assertTrue(true); + } + } + $this->fail("Failed asserting that option '$option' was updated to '$value'."); + } + + public function test_run_skips_migration_when_db_version_is_current() + { + $this->wp->addOption(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION); + + Tiny_Migrate::run(); + + $this->assertCount(0, $this->wp->getCalls('query'), 'Should not touch DB if version matches.'); + } + + public function test_run_performs_migration_and_updates_version() + { + Tiny_Migrate::run(); + + $query_calls = $this->wp->getCalls('query'); + $this->assertCount(1, $query_calls); + + $sql = $query_calls[0][0]; + + $this->assertStringContainsString('UPDATE wp_postmeta', $sql); + $this->assertStringContainsString("SET meta_key = '_tiny_compress_images'", $sql); + $this->assertStringContainsString("WHERE meta_key = 'tiny_compress_images'", $sql); + $this->assertStringContainsString('LIMIT ' . 2500, $sql); + + $this->assertOptionWasUpdated(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION); + } + + public function test_run_renames_meta_key_in_batches() + { + // Two full batches (2500 rows) followed by a partial batch end the loop. + $this->queueQueryResults(array(2500, 2500, 42)); + + Tiny_Migrate::run(); + + $this->assertCount(3, $this->wp->getCalls('query'), 'Should keep batching until a partial batch is returned.'); + $this->assertOptionWasUpdated(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION); + } + + public function test_get_tiny_metadata_reads_the_current_private_key() + { + $this->wp->updateMetadata(1, Tiny_Config::META_KEY, array('size' => 'current')); + + $this->assertEquals(array('size' => 'current'), Tiny_Image::get_tiny_metadata(1)); + } + + public function test_get_tiny_metadata_falls_back_to_legacy_key_before_migration() + { + // Data still lives under the old public key because the migration has + // not run yet (or is still in flight / backed off). + $this->wp->updateMetadata(1, Tiny_Config::LEGACY_META_KEY, array('size' => 'legacy')); + + $this->assertEquals(array('size' => 'legacy'), Tiny_Image::get_tiny_metadata(1)); + } + + public function test_get_tiny_metadata_prefers_current_key_over_legacy() + { + $this->wp->updateMetadata(1, Tiny_Config::LEGACY_META_KEY, array('size' => 'legacy')); + $this->wp->updateMetadata(1, Tiny_Config::META_KEY, array('size' => 'current')); + + $this->assertEquals(array('size' => 'current'), Tiny_Image::get_tiny_metadata(1)); + } + + public function test_get_tiny_metadata_returns_empty_when_no_metadata_exists() + { + $this->assertEmpty(Tiny_Image::get_tiny_metadata(1)); + } + + public function test_run_flushes_object_cache_after_migrating() + { + Tiny_Migrate::run(); + + $this->assertCount(1, $this->wp->getCalls('wp_cache_flush'), 'Object cache must be flushed so reads see the renamed key.'); + } + + public function test_run_does_not_flush_cache_when_migration_fails() + { + $this->wp->stub('query', function() { return false; }); + + Tiny_Migrate::run(); + + $this->assertCount(0, $this->wp->getCalls('wp_cache_flush')); + } + + public function test_run_does_not_update_db_version_when_migration_fails() + { + $this->wp->stub('query', function() { return false; }); + + Tiny_Migrate::run(); + + $option_calls = $this->wp->getCalls('update_option'); + $version_updates = array_filter($option_calls, function($call) { return $call[0] === Tiny_Migrate::DB_VERSION_OPTION; }); + + $this->assertEmpty($version_updates, 'Should not update DB version when migration fails.'); + } + + public function test_run_does_not_update_option_if_unnecessary() + { + $this->wp->addOption(Tiny_Migrate::DB_VERSION_OPTION, Tiny_Migrate::DB_VERSION); + + Tiny_Migrate::run(); + + $this->assertEmpty($this->wp->getCalls('update_option'), 'Should not call update_option at all when version is already current.'); + } + + public function test_run_sets_backoff_transient_when_migration_fails() + { + $this->wp->stub('query', function() { return false; }); + + Tiny_Migrate::run(); + + $set_transient_calls = $this->wp->getCalls('set_transient'); + $this->assertCount(1, $set_transient_calls, 'A backoff transient should be set after a failed migration.'); + $this->assertEquals(Tiny_Migrate::MIGRATION_BACKOFF_KEY, $set_transient_calls[0][0]); + $this->assertEquals(HOUR_IN_SECONDS, $set_transient_calls[0][2]); + } + + public function test_run_skips_migration_when_backoff_transient_is_set() + { + $this->wp->stub('get_transient', function($key) { + return Tiny_Migrate::MIGRATION_BACKOFF_KEY === $key ? 1 : false; + }); + + Tiny_Migrate::run(); + + $this->assertCount(0, $this->wp->getCalls('query'), 'DB update should not be attempted during the backoff period.'); + } +} diff --git a/tiny-compress-images.php b/tiny-compress-images.php index e6aa9cad..567894db 100644 --- a/tiny-compress-images.php +++ b/tiny-compress-images.php @@ -10,6 +10,7 @@ */ require dirname( __FILE__ ) . '/src/config/class-tiny-config.php'; +require dirname( __FILE__ ) . '/src/class-tiny-migrate.php'; require dirname( __FILE__ ) . '/src/class-tiny-helpers.php'; require dirname( __FILE__ ) . '/src/class-tiny-php.php'; require dirname( __FILE__ ) . '/src/class-tiny-wp-base.php'; @@ -37,6 +38,8 @@ require dirname( __FILE__ ) . '/src/class-tiny-compress-fopen.php'; } +add_action( 'admin_init', array( 'Tiny_Migrate', 'run' ) ); + $tiny_plugin = new Tiny_Plugin(); register_uninstall_hook(