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 token:v2.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 { self::save_patch( $post_id, $post_type, array( $meta_key => $value ) ); } /** * Set multiple cold meta values at once. * * Uses JSON_MERGE_PATCH on MySQL to avoid a read-then-write round trip. * Falls back to read-modify-write on SQLite. * * @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 { self::save_patch( $post_id, $post_type, $values ); } /** * Remove a key from the cold blob. * * Uses JSON_REMOVE on MySQL for an atomic single-key removal without a full * blob read. Falls back to read-modify-write on SQLite. * * @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 { if ( defined( 'TMDO_IS_SQLITE' ) && TMDO_IS_SQLITE ) { $data = self::get_blob_raw( $post_id, $post_type ); unset( $data[ $meta_key ] ); self::save_blob( $post_id, $post_type, $data ); return; } global $wpdb; $table = self::table( $post_type ); $path = '$.' . $meta_key; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table(). $wpdb->query( $wpdb->prepare( "UPDATE `{$table}` SET data = JSON_REMOVE(COALESCE(data, '{}'), %s), updated_at = %s WHERE post_id = %d", $path, TMDO_DB::now(), $post_id ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); } /** * 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 { $table = self::table( $post_type ); $json = wp_json_encode( $data ); TMDO_DB::upsert( $table, array( 'post_id' => $post_id, 'data' => $json, 'updated_at' => TMDO_DB::now(), ), array( 'data', 'updated_at' ), 'post_id', array( '%d', '%s', '%s' ) ); // Invalidate object cache. wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); } /** * Merge a partial key-value map into the cold blob atomically. * * Uses INSERT ... ON DUPLICATE KEY UPDATE with JSON_MERGE_PATCH so concurrent * writers cannot clobber each other's keys (the read-modify-write path did). * On SQLite, falls back to the classic read-modify-write path. * * @param int $post_id Post ID. * @param string $post_type Post type slug. * @param array $patch Partial key-value map to merge in. */ private static function save_patch( int $post_id, string $post_type, array $patch ): void { if ( defined( 'TMDO_IS_SQLITE' ) && TMDO_IS_SQLITE ) { $data = self::get_blob_raw( $post_id, $post_type ); $data = array_merge( $data, $patch ); self::save_blob( $post_id, $post_type, $data ); return; } global $wpdb; $table = self::table( $post_type ); $json = wp_json_encode( $patch ); $now = TMDO_DB::now(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from TMDO_Zone_Cold::table() via TMDO_DB::table(). $wpdb->query( $wpdb->prepare( "INSERT INTO `{$table}` (post_id, data, updated_at) VALUES (%d, %s, %s) ON DUPLICATE KEY UPDATE data = JSON_MERGE_PATCH(COALESCE(data, '{}'), %s), updated_at = %s", $post_id, $json, $now, $json, $now ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching wp_cache_delete( "cold_{$post_id}", self::cache_group( $post_type ) ); } }