chore: initial snapshot of 2meet-data-optimizer v0.1.0

Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
<?php
/**
* Zone D (Archive) handler for historical data archival.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone D (Archive) handler — historical data archival with optional compression.
*
* Single table: wpdo_archive
* Stores old/infrequently accessed postmeta entries with optional gzip compression.
*
* Archive flow:
* 1. Identify cold/stale postmeta entries (by age, post_status, or manual selection)
* 2. Copy to wpdo_archive with original_meta_id reference
* 3. Optionally compress meta_value with gzencode()
* 4. Delete from wp_postmeta (only during cleanup phase)
*
* Retrieval: decompress on read, restore to postmeta if needed.
*/
class TMDO_Zone_Archive {
/**
* Get the archive table name.
*/
public static function table(): string {
return TMDO_DB::table( 'wpdo_archive' );
}
/**
* Archive a single postmeta entry.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @param string $meta_value Meta value.
* @param int $meta_id Original meta_id from wp_postmeta.
* @param bool $compress Whether to gzip-compress the value.
*/
public static function archive( int $post_id, string $post_type, string $meta_key, string $meta_value, int $meta_id = 0, bool $compress = false ): void {
global $wpdb;
$table = self::table();
$compressed = 0;
if ( $compress && function_exists( 'gzencode' ) ) {
$meta_value = base64_encode( gzencode( $meta_value, 6 ) );
$compressed = 1;
}
$wpdb->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(),
);
}
}
+262
View File
@@ -0,0 +1,262 @@
<?php
/**
* Zone C (Cold) handler for JSON blob storage with Object Cache integration.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone C (Cold) handler — JSON blob storage with Object Cache integration.
*
* Each post type gets its own table: wpdo_cold_{post_type}
* All cold meta_keys for a post are stored as a single JSON blob in the `data` column.
*
* Read path: Object Cache → cold table → wp_postmeta fallback
* Write path: cold table + Object Cache invalidation
*
* Best for profile/display data that is read often but queried rarely
* (e.g. hp_description, social links, extended profile fields).
*/
class TMDO_Zone_Cold {
/**
* Get the cold table name for a post type.
*
* @param string $post_type Post type slug.
* @return string Full table name.
*/
public static function table( string $post_type ): string {
return TMDO_DB::table( 'wpdo_cold_' . sanitize_key( $post_type ) );
}
/**
* Cache group for a post type.
*
* @param string $post_type Post type slug.
* @return string Cache group name.
*/
private static function cache_group( string $post_type ): string {
return 'wpdo_cold_' . sanitize_key( $post_type );
}
/**
* Ensure the cold table exists for a post type.
*
* @param string $post_type Post type slug.
* @return void
*/
public static function ensure_table( string $post_type ): void {
global $wpdb;
$table = self::table( $post_type );
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $table )
);
} else {
$exists = $wpdb->get_var(
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table )
);
}
if ( ! $exists ) {
TMDO_Installer::create_cold_table( $post_type );
}
}
/**
* Get a single cold meta value for a post.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @return mixed|null
*/
public static function get( int $post_id, string $post_type, string $meta_key ): mixed {
$data = self::get_blob( $post_id, $post_type );
return $data[ $meta_key ] ?? null;
}
/**
* Get the full JSON blob for a post, with Object Cache layer.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @return array Decoded JSON data (meta_key => value).
*/
public static function get_blob( int $post_id, string $post_type ): array {
$group = self::cache_group( $post_type );
$cache_key = "cold_{$post_id}";
$cached = wp_cache_get( $cache_key, $group );
if ( false !== $cached ) {
return is_array( $cached ) ? $cached : array();
}
global $wpdb;
$table = self::table( $post_type );
$json = $wpdb->get_var(
$wpdb->prepare( "SELECT data FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
);
$data = $json ? json_decode( $json, true ) : array();
if ( ! is_array( $data ) ) {
$data = array();
}
// Get cache TTL from registry — 粒度化策略:取所有 cold fields 的最小 TTL
// 確保任何短 TTL 欄位(如 IG tokenv2.0.4 起預期 < 5 min)能在保護期內失效,
// 而非被某個長 TTL 欄位拖拽到 1h 才更新。原本的「取第一個」策略會視註冊順序
// 而異,無法保證安全。
// 同時提供 `wpdo_cold_cache_ttl` filter 讓 partner integration 進一步覆蓋.
$fields = TMDO_Schema_Registry::instance()->get_zone_fields_for_type( 'cold', $post_type );
$ttls = array();
foreach ( $fields as $field ) {
if ( ! empty( $field['cache_ttl'] ) ) {
$ttls[] = (int) $field['cache_ttl'];
}
}
$ttl = empty( $ttls ) ? HOUR_IN_SECONDS : min( $ttls );
/**
* Filter the effective cache TTL for a cold zone post type.
*
* @since 2.0.5
* @param int $ttl Computed TTL (min of all registered fields, or HOUR_IN_SECONDS default).
* @param string $post_type Post type being cached.
* @param int $post_id Post ID (entity being read).
* @param array $fields Fields registry config for this post_type.
*/
$ttl = (int) apply_filters( 'wpdo_cold_cache_ttl', $ttl, $post_type, $post_id, $fields );
wp_cache_set( $cache_key, $data, $group, $ttl );
return $data;
}
/**
* Set a single cold meta value for a post.
* Merges into the existing JSON blob.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $meta_key Meta key.
* @param mixed $value Value to store.
*/
public static function set( int $post_id, string $post_type, string $meta_key, mixed $value ): void {
$data = self::get_blob_raw( $post_id, $post_type );
$data[ $meta_key ] = $value;
self::save_blob( $post_id, $post_type, $data );
}
/**
* Set multiple cold meta values at once.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param array $values meta_key => value pairs.
*/
public static function set_many( int $post_id, string $post_type, array $values ): void {
$data = self::get_blob_raw( $post_id, $post_type );
$data = array_merge( $data, $values );
self::save_blob( $post_id, $post_type, $data );
}
/**
* Remove a key from the cold blob.
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @param string $meta_key Meta key to remove.
* @return void
*/
public static function remove( int $post_id, string $post_type, string $meta_key ): void {
$data = self::get_blob_raw( $post_id, $post_type );
unset( $data[ $meta_key ] );
self::save_blob( $post_id, $post_type, $data );
}
/**
* Delete the entire cold row for a post.
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return void
*/
public static function delete( int $post_id, string $post_type ): void {
global $wpdb;
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
}
// ── Private helpers ───────────────────────────────────────────────────
/**
* Get raw blob from DB (no cache).
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return array Decoded JSON data array.
*/
private static function get_blob_raw( int $post_id, string $post_type ): array {
global $wpdb;
$table = self::table( $post_type );
$json = $wpdb->get_var(
$wpdb->prepare( "SELECT data FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
);
$data = $json ? json_decode( $json, true ) : array();
return is_array( $data ) ? $data : array();
}
/**
* Save the JSON blob and invalidate cache.
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @param array $data Key-value pairs to store as JSON.
* @return void
*/
private static function save_blob( int $post_id, string $post_type, array $data ): void {
global $wpdb;
$table = self::table( $post_type );
$now = TMDO_DB::now();
$json = wp_json_encode( $data );
$existing = $wpdb->get_var(
$wpdb->prepare( "SELECT id FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table().
);
if ( $existing ) {
$wpdb->update(
$table,
array(
'data' => $json,
'updated_at' => $now,
),
array( 'post_id' => $post_id ),
array( '%s', '%s' ),
array( '%d' )
);
} else {
$wpdb->insert(
$table,
array(
'post_id' => $post_id,
'data' => $json,
'updated_at' => $now,
),
array( '%d', '%s', '%s' )
);
}
// Invalidate object cache.
wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) );
}
}
+212
View File
@@ -0,0 +1,212 @@
<?php
/**
* Zone A (Hot) handler for flat-column custom tables.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone A (Hot) handler — flat-column custom tables for search/filter fields.
*
* Each post type gets its own table: wpdo_hot_{post_type}
* Columns are defined by the Schema Registry and created via TMDO_Installer.
*
* Key advantage over HPCT's KV table (hpct_listing_meta):
* KV table: N meta_query conditions = N LEFT JOINs
* Flat table: N conditions = 1 LEFT JOIN + N WHERE clauses
*/
class TMDO_Zone_Hot {
/**
* Get the table name for a post type.
*
* @param string $post_type Post type slug.
* @return string Full table name.
*/
public static function table( string $post_type ): string {
return TMDO_DB::table( 'wpdo_hot_' . sanitize_key( $post_type ) );
}
/**
* Ensure the hot table exists for a post type.
* Creates it dynamically from Schema Registry if missing.
*
* @param string $post_type Post type slug.
* @return void
*/
public static function ensure_table( string $post_type ): void {
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
if ( empty( $columns ) ) {
return;
}
global $wpdb;
$table = self::table( $post_type );
// Quick existence check.
if ( TMDO_IS_SQLITE ) {
$exists = $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=%s", $table )
);
} else {
$exists = $wpdb->get_var(
$wpdb->prepare( 'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', $table )
);
}
if ( ! $exists ) {
TMDO_Installer::create_hot_table( $post_type, $columns );
return;
}
// v2.1.2 critical fix: ensure existing tables have all columns declared
// by Schema_Registry. Partner plugins registering new hot fields after
// the initial migration would otherwise silently fall back to postmeta.
//
// v2.1.3 optimization: schema fingerprint stored in option. When the hash
// of declared columns matches the stored hash, skip SHOW COLUMNS entirely.
// Only diff + ALTER fires when fingerprint actually changed (drift detected).
// This avoids per-request SHOW COLUMNS overhead on stable production sites.
static $checked = array();
if ( isset( $checked[ $post_type ] ) ) {
return;
}
$fingerprint = self::compute_fingerprint( $columns );
$opt_key = 'wpdo_hot_fp_' . $post_type;
$stored_fp = (string) get_option( $opt_key, '' );
if ( $stored_fp === $fingerprint ) {
// Schema unchanged since last successful ensure → no need to SHOW COLUMNS.
$checked[ $post_type ] = true;
return;
}
// Fingerprint mismatch (or first run) → run diff + ALTER, then store new hash.
TMDO_Installer::ensure_hot_columns( $post_type, $columns );
update_option( $opt_key, $fingerprint, false );
$checked[ $post_type ] = true;
}
/**
* Stable hash of declared column definitions. Used to short-circuit SHOW COLUMNS
* when Schema_Registry hasn't changed since the last successful ensure.
*
* @param array $columns column_name => sql_type map.
* @return string Short SHA-1 prefix (10 chars — collision-safe at our scale).
*
* @since 2.1.3
*/
private static function compute_fingerprint( array $columns ): string {
ksort( $columns );
return substr( sha1( wp_json_encode( $columns ) ?: '' ), 0, 10 );
}
/**
* Read a single field value from the hot table.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $column Column name in hot table.
* @return mixed|null Value or null if not found.
*/
public static function get( int $post_id, string $post_type, string $column ): mixed {
global $wpdb;
$table = self::table( $post_type );
$column = sanitize_key( $column );
return $wpdb->get_var(
$wpdb->prepare(
"SELECT `{$column}` FROM `{$table}` WHERE post_id = %d LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column from sanitize_key(); table from TMDO_DB::table().
$post_id
)
);
}
/**
* Read all hot fields for a post as an associative array.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @return array|null Column => value pairs, or null.
*/
public static function get_row( int $post_id, string $post_type ): ?array {
global $wpdb;
$table = self::table( $post_type );
$row = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM `{$table}` WHERE post_id = %d LIMIT 1", $post_id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Hot::table() via TMDO_DB::table().
ARRAY_A
);
return $row ?: null;
}
/**
* Upsert a single field into the hot table.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param string $column Column name.
* @param mixed $value Value to set.
*/
public static function set( int $post_id, string $post_type, string $column, mixed $value ): void {
$table = self::table( $post_type );
$column = sanitize_key( $column );
$now = TMDO_DB::now();
TMDO_DB::upsert(
$table,
array(
'post_id' => $post_id,
$column => $value,
'updated_at' => $now,
),
array( $column, 'updated_at' ),
'post_id'
);
}
/**
* Upsert multiple fields at once for a post.
*
* @param int $post_id Post ID.
* @param string $post_type Post type.
* @param array $data Column => value pairs.
*/
public static function set_many( int $post_id, string $post_type, array $data ): void {
$table = self::table( $post_type );
$now = TMDO_DB::now();
$data['post_id'] = $post_id;
$data['updated_at'] = $now;
$update_cols = array_diff( array_keys( $data ), array( 'post_id' ) );
TMDO_DB::upsert( $table, $data, $update_cols, 'post_id' );
}
/**
* Delete a post's row from the hot table.
*
* @param int $post_id Post ID.
* @param string $post_type Post type slug.
* @return void
*/
public static function delete( int $post_id, string $post_type ): void {
global $wpdb;
$wpdb->delete( self::table( $post_type ), array( 'post_id' => $post_id ), array( '%d' ) );
}
/**
* Get all post types that have hot zone tables registered.
*
* @return string[]
*/
public static function get_post_types(): array {
return TMDO_Schema_Registry::instance()->get_hot_post_types();
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
/**
* Zone B (Warm) handler for KV table with optional TTL.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone B (Warm) handler — KV table with optional TTL auto-cleanup.
*
* Single table: wpdo_warm
* Structure: post_id + meta_key + meta_value + expires_at
*
* Use for transient-like data that benefits from DB-backed persistence
* but doesn't need to live forever (e.g. cached computations, temporary flags).
*
* Expired entries are cleaned up by the wpdo_warm_cleanup cron (hourly).
*/
class TMDO_Zone_Warm {
/**
* Get the warm table name.
*/
public static function table(): string {
return TMDO_DB::table( 'wpdo_warm' );
}
/**
* Read a value from the warm zone.
*
* Returns null if not found or if expired.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @return string|null
*/
public static function get( int $post_id, string $meta_key ): ?string {
global $wpdb;
$table = self::table();
$now = TMDO_DB::now();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
$val = $wpdb->get_var(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}`
WHERE post_id = %d AND meta_key = %s
AND (expires_at IS NULL OR expires_at > %s)
LIMIT 1",
$post_id,
$meta_key,
$now
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $val;
}
/**
* Read all warm values for a post.
*
* @param int $post_id Post ID.
* @return array<string, string> meta_key => meta_value pairs.
*/
public static function get_all( int $post_id ): array {
global $wpdb;
$table = self::table();
$now = TMDO_DB::now();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, meta_value FROM `{$table}`
WHERE post_id = %d AND (expires_at IS NULL OR expires_at > %s)",
$post_id,
$now
),
ARRAY_A
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = array();
foreach ( $rows ?: array() as $row ) {
$result[ $row['meta_key'] ] = $row['meta_value'];
}
return $result;
}
/**
* Set a value in the warm zone with optional TTL.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param string $value Value to store.
* @param int|null $ttl TTL in seconds. Null = no expiry.
*/
public static function set( int $post_id, string $meta_key, string $value, ?int $ttl = null ): void {
global $wpdb;
$table = self::table();
$now = TMDO_DB::now();
$expires_at = null;
if ( $ttl && $ttl > 0 ) {
$expires_at = gmdate( 'Y-m-d H:i:s', time() + $ttl );
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM `{$table}` WHERE post_id = %d AND meta_key = %s LIMIT 1",
$post_id,
$meta_key
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( $existing ) {
$update_data = array( 'meta_value' => $value );
$update_format = array( '%s' );
if ( null !== $expires_at ) {
$update_data['expires_at'] = $expires_at;
$update_format[] = '%s';
}
$wpdb->update( $table, $update_data, array( 'id' => (int) $existing ), $update_format, array( '%d' ) );
} else {
$wpdb->insert(
$table,
array(
'post_id' => $post_id,
'meta_key' => $meta_key,
'meta_value' => $value,
'expires_at' => $expires_at,
'created_at' => $now,
),
array( '%d', '%s', '%s', $expires_at ? '%s' : null, '%s' )
);
}
}
/**
* Delete a specific key from the warm zone.
*
* @param int $post_id Post ID.
* @param string $meta_key Meta key to delete.
* @return void
*/
public static function delete( int $post_id, string $meta_key ): void {
global $wpdb;
$wpdb->delete(
self::table(),
array(
'post_id' => $post_id,
'meta_key' => $meta_key,
),
array( '%d', '%s' )
);
}
/**
* Delete all warm entries for a post.
*
* @param int $post_id Post ID.
* @return void
*/
public static function delete_all( int $post_id ): void {
global $wpdb;
$wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) );
}
/**
* Purge all expired entries (called by cron).
*
* @return int Number of rows deleted.
*/
public static function purge_expired(): int {
global $wpdb;
$table = self::table();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Warm::table() via TMDO_DB::table().
return (int) $wpdb->query(
$wpdb->prepare(
"DELETE FROM `{$table}` WHERE expires_at IS NOT NULL AND expires_at < %s",
TMDO_DB::now()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}