get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); $exists = ( null !== $found && '' !== $found ); return $exists; } /** * Reset cached table_exists state — for tests and PR-3 schema changes. * * @internal */ public static function reset_table_exists_cache(): void { // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged // Use reflection to clear the static. Cleanest approach in PHP 8.1+. ( function () { static $exists = null; $exists = null; } )(); // The above closure does not actually reset the bound static of table_exists(). // Instead we expose a flag via a class-level static. self::$table_exists_cache_invalidated_at = microtime( true ); } /** * Marker for cache invalidation. Real reset happens by re-calling table_exists() * in a fresh process; tests should isolate via runInSeparateProcess where needed. * * @var float */ private static float $table_exists_cache_invalidated_at = 0.0; /** * Records a single "skipped — table missing" entry in wpdo_errors per request. * * Direct INSERT (not via TMDO_Logger::error) so we avoid cascading the * message to PHP's error_log on every page load. Idempotent within a * single request via static guard. * * @return void */ private static function log_skip_once(): void { static $logged = false; if ( $logged ) { return; } $logged = true; // Direct INSERT — wrapped in try/catch because the wpdo_errors table // might not exist in fresh installs. Skipping the log is acceptable; // breaking register_hooks() is not. try { global $wpdb; $wpdb->insert( $wpdb->prefix . 'wpdo_errors', array( 'module' => 'listing_meta', 'zone' => '', 'hook' => 'register_hooks', 'message' => 'Skipped: hpct_listing_meta table missing. Run `wp wpdo import-hpct` or migrate first.', 'context' => '{}', 'created_at' => current_time( 'mysql' ), ), array( '%s', '%s', '%s', '%s', '%s', '%s' ) ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- intentional: wpdo_errors absence is non-critical. // Silently swallow — wpdo_errors table absence is non-critical. } } /** * Deletes listing meta when a post is deleted. * * @param int $post_id Post ID. * @param \WP_Post $post Post object. * @return void */ public function action_delete_post( int $post_id, \WP_Post $post ): void { if ( 'hp_listing' !== $post->post_type || ! $this->is_active() ) { return; } try { global $wpdb; $wpdb->delete( TMDO_DB::table( 'hpct_listing_meta' ), array( 'listing_id' => $post_id ), array( '%d' ) ); } catch ( \Throwable $e ) { TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() ); } } /** * Filters get_post_metadata for hp_listing posts. * * @param mixed $value Current value or null. * @param int $object_id Post ID. * @param string $meta_key Meta key. * @param bool $single Whether to return single value. * @return mixed Filtered value. */ public function filter_get_meta( $value, int $object_id, string $meta_key, bool $single ) { if ( ! $this->is_enabled() || ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) ) { return $value; } return $this->intercept( function () use ( $object_id, $meta_key, $single ) { global $wpdb; $table = TMDO_DB::table( 'hpct_listing_meta' ); if ( $single ) { $val = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name validated by TMDO_DB::table() + sanitize_key(). $object_id, $meta_key ) ); return null !== $val ? $val : null; } return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name validated by TMDO_DB::table() + sanitize_key(). $object_id, $meta_key ) ) ?: null; }, fn() => $value, 'get_post_metadata' ); } /** * Filters update_post_metadata for hp_listing posts. * * @param mixed $check Whether to short-circuit. * @param int $object_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @param mixed $prev_value Previous meta value. * @return mixed Filtered check value. */ public function filter_update_meta( $check, int $object_id, string $meta_key, $meta_value, $prev_value ) { if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) { return $check; } try { $this->upsert_meta( $object_id, $meta_key, $meta_value ); } catch ( \Throwable $e ) { TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() ); } return $check; } /** * Filters add_post_metadata for hp_listing posts. * * @param mixed $check Whether to short-circuit. * @param int $object_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @param bool $unique Whether meta key should be unique. * @return mixed Filtered check value. */ public function filter_add_meta( $check, int $object_id, string $meta_key, $meta_value, bool $unique ) { if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) { return $check; } try { $this->upsert_meta( $object_id, $meta_key, $meta_value ); } catch ( \Throwable $e ) { TMDO_Logger::error( $this->module, 'add_post_metadata', $e->getMessage() ); } return $check; } /** * Filters delete_post_metadata for hp_listing posts. * * @param mixed $check Whether to short-circuit. * @param int $object_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value to match. * @param bool $delete_all Whether to delete all matching. * @return mixed Filtered check value. */ public function filter_delete_meta( $check, int $object_id, string $meta_key, $meta_value, bool $delete_all ) { if ( ! $this->is_hp_listing( $object_id ) || ! $this->is_hp_key( $meta_key ) || ! $this->is_active() ) { return $check; } try { global $wpdb; $wpdb->delete( TMDO_DB::table( 'hpct_listing_meta' ), array( 'listing_id' => $object_id, 'meta_key' => $meta_key, ), array( '%d', '%s' ) ); } catch ( \Throwable $e ) { TMDO_Logger::error( $this->module, 'delete_post_metadata', $e->getMessage() ); } return $check; } /** * Checks whether the post is an hp_listing. * * @param int $post_id Post ID. * @return bool True if hp_listing post type. */ private function is_hp_listing( int $post_id ): bool { return 'hp_listing' === get_post_type( $post_id ); } /** * Checks whether the meta key is an hp_ or _hp_ key. * * @param string $meta_key Meta key. * @return bool True if key starts with hp_ or _hp_. */ private function is_hp_key( string $meta_key ): bool { return str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' ); } /** * Inserts or updates a listing meta value in the hpct_listing_meta table. * * Single round-trip via TMDO_DB::upsert() — uses ON DUPLICATE KEY UPDATE * (MySQL) or ON CONFLICT(listing_id, meta_key) DO UPDATE (SQLite). * Solves audit finding P-C1 (the previous SELECT + INSERT/UPDATE pattern * cost 2 SQL round-trips per write). * * Requires UNIQUE KEY (listing_id, meta_key) on hpct_listing_meta — * present by design from HPCT v1.0+. * * @param int $listing_id Listing post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @return void */ private function upsert_meta( int $listing_id, string $meta_key, $meta_value ): void { // maybe_serialize array values to match WordPress's native postmeta semantics. $serialized = is_array( $meta_value ) || is_object( $meta_value ) ? maybe_serialize( $meta_value ) : (string) $meta_value; TMDO_DB::upsert( TMDO_DB::table( 'hpct_listing_meta' ), array( 'listing_id' => $listing_id, 'meta_key' => $meta_key, 'meta_value' => $serialized, ), array( 'meta_value' ), // Update only meta_value on conflict. array( 'listing_id', 'meta_key' ), // Composite unique key. array( '%d', '%s', '%s' ) ); } }