diff --git a/src/core/class-updater.php b/src/core/class-updater.php index 901c1330..d5c4c070 100644 --- a/src/core/class-updater.php +++ b/src/core/class-updater.php @@ -2,9 +2,9 @@ /** * Plugin update routines. * - * Runs on every page load; cheap when nothing's changed (the version compare - * short-circuits). Each migration is gated on a version bump so it executes - * exactly once per upgrade path. + * Runs on every page load; cheap when nothing's changed because `run()` bails + * as soon as the recorded version matches this build. Each migration is gated + * on a version bump so it executes at most once per upgrade path. * * @package BeyondWords\Core * @since 3.0.0 @@ -27,11 +27,24 @@ class Updater { /** * Run any pending migrations and update the recorded plugin version. * - * Always runs — version checks inside skip the migrations on no-op upgrades. + * Bails immediately once the recorded version matches this build, so the + * migrations run at most once per version change instead of on every + * request. That guard — not the per-migration `version_compare` gates — is + * what makes the common path cheap: a pre-release build such as + * `7.0.0-beta.1` compares `< 7.0.0`, so those gates never close on it and + * would otherwise re-run the v7 migrations (~40 uncached queries plus a slow + * postmeta JOIN) on every page load, front end included. */ public static function run(): void { $version = get_option( 'beyondwords_version', '1.0.0' ); + // Already up to date: nothing to migrate, and the version write below + // would be a no-op. Bail before touching the database. This is the hot + // path — it runs on every request once the plugin has booted once. + if ( BEYONDWORDS__PLUGIN_VERSION === $version ) { + return; + } + if ( version_compare( $version, '3.0.0', '<' ) ) { self::migrate_settings(); } @@ -61,8 +74,9 @@ public static function run(): void { * `[ 'mode' => 'all' ]` or `[ 'mode' => 'terms', 'terms' => [...] ]`. * * Reuses the tolerant readers on `Preselect`, so it is idempotent: already - * mode-based values pass through unchanged. The `-dev` plugin version keeps - * the `< 7.0.0` gate firing on every load, so the no-op path matters. + * mode-based values pass through unchanged. Pre-release builds compare + * `< 7.0.0`, so this can re-run once on each dev-version bump — idempotency + * keeps those re-runs safe. * * @since 7.0.0 */ diff --git a/tests/phpunit/core/test-updater.php b/tests/phpunit/core/test-updater.php index 14ea561a..309192e4 100644 --- a/tests/phpunit/core/test-updater.php +++ b/tests/phpunit/core/test-updater.php @@ -223,4 +223,59 @@ public function migrate_disabled_to_embed_none() wp_delete_post($embedPost, true); wp_delete_post($untouchedPost, true); } + + /** + * The migration gate must close once the recorded version matches this + * build — otherwise the pre-release `< 7.0.0` comparison keeps the v7 + * migrations firing on every request. + * + * @test + */ + public function run_is_a_noop_once_the_recorded_version_matches_this_build() + { + // Simulate an install that has already booted this exact build. + update_option('beyondwords_version', BEYONDWORDS__PLUGIN_VERSION); + + // A post still carrying the legacy opt-out flag. If run() re-executed + // the v7 block it would strip this flag and set Embed = None; the + // version guard must leave it untouched. + $post = self::factory()->post->create(); + update_post_meta($post, 'beyondwords_disabled', '1'); + + Updater::run(); + + // Migration did not run: legacy flag preserved, no Embed written. + $this->assertSame('1', get_post_meta($post, 'beyondwords_disabled', true)); + $this->assertSame('', get_post_meta($post, 'beyondwords_embed', true)); + + wp_delete_post($post, true); + delete_option('beyondwords_version'); + } + + /** + * When the recorded version predates a migration, run() must execute it and + * normalise the stored version to this build. + * + * @test + */ + public function run_migrates_and_records_the_version_when_outdated() + { + update_option('beyondwords_version', '6.0.0'); + + $post = self::factory()->post->create(); + update_post_meta($post, 'beyondwords_disabled', '1'); + + Updater::run(); + + // v7 migration ran: legacy flag converted to Embed = None and removed. + $this->assertSame('none', get_post_meta($post, 'beyondwords_embed', true)); + $this->assertSame('', get_post_meta($post, 'beyondwords_disabled', true)); + + // Stored version normalised to this build, so the next run() is a no-op. + $this->assertSame(BEYONDWORDS__PLUGIN_VERSION, get_option('beyondwords_version')); + + wp_delete_post($post, true); + delete_option('beyondwords_version'); + delete_option('beyondwords_date_activated'); + } }