insert( $table, array( 'post_id' => $post_id, 'post_type' => $post_type, 'meta_key' => $meta_key, 'meta_value' => $meta_value, 'compressed' => $compressed, 'archived_at' => TMDO_DB::now(), 'original_meta_id' => $meta_id, ), array( '%d', '%s', '%s', '%s', '%d', '%s', '%d' ) ); } /** * Archive multiple postmeta entries in batch. * * @param array $entries Array of [post_id, post_type, meta_key, meta_value, meta_id]. * @param bool $compress Whether to compress values. * @return void * @throws \Throwable When a batch insert fails and the transaction is rolled back. */ public static function archive_batch( array $entries, bool $compress = false ): void { TMDO_DB::begin(); try { foreach ( $entries as $entry ) { self::archive( (int) $entry['post_id'], $entry['post_type'], $entry['meta_key'], $entry['meta_value'], (int) ( $entry['meta_id'] ?? 0 ), $compress ); } TMDO_DB::commit(); } catch ( \Throwable $e ) { TMDO_DB::rollback(); throw $e; } } /** * Retrieve archived values for a post. * * @param int $post_id Post ID. * @param string|null $meta_key Optional specific meta_key filter. * @return array Array of [meta_key, meta_value, archived_at, compressed]. */ public static function get( int $post_id, ?string $meta_key = null ): array { global $wpdb; $table = self::table(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Archive::table() via TMDO_DB::table(). if ( $meta_key ) { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d AND meta_key = %s ORDER BY archived_at DESC", $post_id, $meta_key ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d ORDER BY archived_at DESC", $post_id ), ARRAY_A ); } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared // Decompress where needed. // Note: must iterate $rows directly (not $rows ?: []) to allow &$row to modify the original array. foreach ( $rows as &$row ) { if ( (int) $row['compressed'] && function_exists( 'gzdecode' ) ) { $decoded = base64_decode( $row['meta_value'] ); if ( false !== $decoded ) { $decompressed = gzdecode( $decoded ); if ( false !== $decompressed ) { $row['meta_value'] = $decompressed; } } } unset( $row['compressed'] ); } return $rows ?: array(); } /** * Restore archived entries back to wp_postmeta. * * @param int $post_id Post ID. * @param string|null $meta_key Optional meta_key filter (null = restore all). * @return int Number of entries restored. */ public static function restore( int $post_id, ?string $meta_key = null ): int { $entries = self::get( $post_id, $meta_key ); $count = 0; foreach ( $entries as $entry ) { update_post_meta( $post_id, $entry['meta_key'], $entry['meta_value'] ); ++$count; } // Delete restored entries from archive. if ( $count > 0 ) { global $wpdb; $table = self::table(); if ( $meta_key ) { $wpdb->delete( $table, array( 'post_id' => $post_id, 'meta_key' => $meta_key, ), array( '%d', '%s' ) ); } else { $wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) ); } } return $count; } /** * Delete all archived entries for a post. * * @param int $post_id Post ID. * @return void */ public static function delete( int $post_id ): void { global $wpdb; $wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) ); } /** * Sweep: archive stale postmeta entries by age. * * Finds postmeta for trashed/deleted posts older than $days and archives them. * * @param int $days Minimum age in days. * @param bool $compress Whether to compress. * @param int $limit Maximum rows per sweep. * @return int Number of entries archived. */ public static function sweep( int $days = 90, bool $compress = true, int $limit = 500 ): int { global $wpdb; $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_status = 'trash' AND p.post_modified_gmt < %s LIMIT %d", $cutoff, $limit ), ARRAY_A ); if ( ! $rows ) { return 0; } $entries = array(); foreach ( $rows as $row ) { $entries[] = array( 'post_id' => $row['post_id'], 'post_type' => $row['post_type'], 'meta_key' => $row['meta_key'], 'meta_value' => $row['meta_value'], 'meta_id' => $row['meta_id'], ); } self::archive_batch( $entries, $compress ); return count( $entries ); } /** * Get archive statistics. * * @return array{total_rows: int, compressed_rows: int, post_types: array} */ public static function stats(): array { global $wpdb; $table = self::table(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table() $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); $compressed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE compressed = 1" ); $types = $wpdb->get_results( "SELECT post_type, COUNT(*) as cnt FROM `{$table}` GROUP BY post_type ORDER BY cnt DESC", ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared return array( 'total_rows' => $total, 'compressed_rows' => $compressed, 'post_types' => $types ?: array(), ); } }