diff --git a/includes/class-tmdo-installer.php b/includes/class-tmdo-installer.php index da1244a..4485e4c 100644 --- a/includes/class-tmdo-installer.php +++ b/includes/class-tmdo-installer.php @@ -217,6 +217,15 @@ class TMDO_Installer { // Idempotent — only creates tables that don't already exist. self::install_v2_tables(); + // 1.4.2: archive sweeps before this release copied trashed meta daily. + if ( class_exists( 'TMDO_Zone_Archive' ) ) { + global $wpdb; + $archive = TMDO_Zone_Archive::table(); + if ( $archive === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $archive ) ) ) ) { + TMDO_Zone_Archive::remove_duplicates(); + } + } + update_option( 'wpdo_db_version', self::SCHEMA_VERSION ); // Register wpdo_features with autoload=no so it doesn't inflate every page load. @@ -1440,7 +1449,8 @@ class TMDO_Installer { original_meta_id bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_post_id (post_id), - KEY idx_post_type_date (post_type, archived_at) + KEY idx_post_type_date (post_type, archived_at), + KEY idx_original_meta (original_meta_id) ) {$charset};"; foreach ( $sqls as $sql ) { diff --git a/includes/zones/class-tmdo-zone-archive.php b/includes/zones/class-tmdo-zone-archive.php index ccce528..bed8eb3 100644 --- a/includes/zones/class-tmdo-zone-archive.php +++ b/includes/zones/class-tmdo-zone-archive.php @@ -210,7 +210,11 @@ class TMDO_Zone_Archive { global $wpdb; $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); + // Rows already archived are skipped: the sweep used to copy the same + // trashed meta again every day (2.4.2 PR-136). + $table = self::table(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table() $rows = $wpdb->get_results( $wpdb->prepare( "SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type @@ -218,12 +222,14 @@ class TMDO_Zone_Archive { INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_status = 'trash' AND p.post_modified_gmt < %s + AND NOT EXISTS ( SELECT 1 FROM `{$table}` a WHERE a.original_meta_id = pm.meta_id ) LIMIT %d", $cutoff, $limit ), ARRAY_A ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( ! $rows ) { return 0; @@ -245,6 +251,21 @@ class TMDO_Zone_Archive { return count( $entries ); } + /** + * Delete archive rows that copy the same original meta row again, keeping + * the first copy (sweeps before 1.4.2 archived trashed meta every day). + * Rows without an original meta id are left alone. + * + * @return int Rows deleted. + */ + public static function remove_duplicates(): int { + global $wpdb; + $table = self::table(); + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table() + $deleted = $wpdb->query( "DELETE FROM `{$table}` WHERE original_meta_id > 0 AND id NOT IN ( SELECT keep_id FROM ( SELECT MIN(id) AS keep_id FROM `{$table}` WHERE original_meta_id > 0 GROUP BY original_meta_id ) AS k )" ); + return is_int( $deleted ) ? $deleted : 0; + } + /** * Get archive statistics. * diff --git a/tests/integration/ZoneArchiveIntegrationTest.php b/tests/integration/ZoneArchiveIntegrationTest.php index 1c12e21..462da4b 100644 --- a/tests/integration/ZoneArchiveIntegrationTest.php +++ b/tests/integration/ZoneArchiveIntegrationTest.php @@ -45,6 +45,55 @@ class ZoneArchiveIntegrationTest extends TestCase { $GLOBALS['_wp_postmeta'] = []; } + // ── sweep / duplicates (2.4.2 PR-136, manual F-6) ───────────────────── + + /** Posts and postmeta for sweep(): post 10 trashed 200 days ago, post 11 published. */ + private function seed_trashed_post(): void { + global $wpdb; + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" ); + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" ); + $wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', post_modified_gmt DATETIME NOT NULL DEFAULT '2000-01-01 00:00:00', PRIMARY KEY (ID) ) ENGINE=InnoDB" ); + $wpdb->query( "CREATE TABLE `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" ); + $old = gmdate( 'Y-m-d H:i:s', time() - 200 * DAY_IN_SECONDS ); + $wpdb->query( "INSERT INTO `{$wpdb->posts}` VALUES (10, 'hp_listing', 'trash', '{$old}'), (11, 'hp_listing', 'publish', '{$old}')" ); + $wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (10, 'hp_price', '5'), (10, 'hp_rating', '4'), (11, 'hp_price', '9')" ); + } + + private function drop_posts(): void { + global $wpdb; + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" ); + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" ); + } + + private function archive_rows(): int { + global $wpdb; + return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE . '`' ); + } + + public function test_sweep_copies_each_trashed_meta_row_once(): void { + $this->seed_trashed_post(); + try { + $this->assertSame( 2, WPDO_Zone_Archive::sweep( 90, false ) ); + $this->assertSame( 0, WPDO_Zone_Archive::sweep( 90, false ), 'already archived' ); + $this->assertSame( 2, $this->archive_rows() ); + } finally { + $this->drop_posts(); + } + } + + public function test_remove_duplicates_keeps_the_first_copy_of_each_meta_row(): void { + WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 ); + WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_price', '5', 101 ); + WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 ); + WPDO_Zone_Archive::archive( 10, 'hp_listing', 'hp_rating', '4', 102 ); + WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' ); // original_meta_id 0: no identity, kept. + WPDO_Zone_Archive::archive( 12, 'hp_listing', 'hp_price', '7' ); + + $this->assertSame( 2, WPDO_Zone_Archive::remove_duplicates() ); + $this->assertSame( 4, $this->archive_rows() ); + $this->assertSame( 0, WPDO_Zone_Archive::remove_duplicates() ); + } + // ── archive / get ───────────────────────────────────────────────────── public function test_archive_stores_plain_entry(): void {