b4400a68e5
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
235 lines
7.2 KiB
PHP
235 lines
7.2 KiB
PHP
<?php
|
|
/**
|
|
* Favorites adapter — `hivepress-favorites`.
|
|
*
|
|
* The Favorite model extends Comment (`comment_type = hp_favorite`):
|
|
*
|
|
* - user → user_id
|
|
* - listing → comment_post_ID
|
|
* - added_at → comment_date
|
|
*
|
|
* Problem: `wp_comments` has no UNIQUE constraint on (user_id, comment_post_ID, comment_type).
|
|
* A double-tap on the favourite button can race and create two duplicate
|
|
* favourite rows. HivePress relies on application-level dedup (SELECT before
|
|
* INSERT) which is not race-safe.
|
|
*
|
|
* Fix: shadow table `wp_wpdo_comment_hp_favorite` with UNIQUE(user_id, listing_id).
|
|
* Adapter mirrors every favorite write into the shadow table; the comment
|
|
* router rewrites "is X favorited by user Y" reads to hit the shadow table
|
|
* (1 indexed lookup vs WP_Comment_Query meta scan).
|
|
*
|
|
* Schema (declared via expected_columns; created by TMDO_Schema_Manager
|
|
* when adapter mode flips to dual_write):
|
|
*
|
|
* CREATE TABLE wp_wpdo_comment_hp_favorite (
|
|
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
|
* user_id BIGINT UNSIGNED NOT NULL,
|
|
* listing_id BIGINT UNSIGNED NOT NULL,
|
|
* created_at DATETIME NOT NULL,
|
|
* UNIQUE KEY unique_user_listing (user_id, listing_id),
|
|
* KEY idx_listing (listing_id)
|
|
* );
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
* @since 3.0.0
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
|
|
|
|
/**
|
|
* Favorites addon adapter.
|
|
*/
|
|
final class TMDO_HivePress_Favorites_Adapter implements TMDO_HivePress_Adapter {
|
|
|
|
use TMDO_HivePress_Adapter_Trait;
|
|
|
|
/** Comment_type owned by this adapter. */
|
|
public const COMMENT_TYPE = 'hp_favorite';
|
|
|
|
/** Shadow table name (without wpdb prefix). */
|
|
public const TABLE = 'wpdo_comment_hp_favorite';
|
|
|
|
/**
|
|
* Constructor — bind register hooks via inherited trait.
|
|
*/
|
|
public function __construct() {
|
|
$this->bind_anti_eav_hooks();
|
|
}
|
|
|
|
/** Adapter slug (matches wp.org plugin slug). */
|
|
public function plugin_slug(): string {
|
|
return 'hivepress-favorites';
|
|
}
|
|
|
|
/** Class probed for addon presence. */
|
|
public function detection_class(): string {
|
|
return 'HivePress\\Favorites\\Plugin';
|
|
}
|
|
|
|
/** Version constant probed for addon presence. */
|
|
public function detection_const(): string {
|
|
return 'HIVEPRESS_FAVORITES_VERSION';
|
|
}
|
|
|
|
/** Minimum addon version supported. */
|
|
public function minimum_addon_version(): string {
|
|
return '1.2.0';
|
|
}
|
|
|
|
/**
|
|
* Declare the shadow table to the Custom_Table_Registry.
|
|
*
|
|
* @param TMDO_Custom_Table_Registry $registry Registry singleton.
|
|
*/
|
|
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
|
|
$this->register_custom_table(
|
|
$registry,
|
|
array(
|
|
'table_name' => self::TABLE,
|
|
'primary_key' => 'comment_id',
|
|
'post_type_link' => null,
|
|
'expected_columns' => array(
|
|
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
|
|
'user_id' => 'BIGINT UNSIGNED NOT NULL',
|
|
'listing_id' => 'BIGINT UNSIGNED NOT NULL',
|
|
'created_at' => 'DATETIME NOT NULL',
|
|
),
|
|
'indexes' => array(
|
|
'unique_user_listing' => 'UNIQUE (user_id, listing_id)',
|
|
'idx_listing' => '(listing_id)',
|
|
),
|
|
'doctor_callback' => array( $this, 'doctor_check' ),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sync wp_comments → shadow table on insert.
|
|
*
|
|
* Bound during `on_register_event_hooks()`. The comment-router uses
|
|
* the shadow table for "is favorited" lookups; sync keeps it consistent.
|
|
*/
|
|
public function on_register_event_hooks(): void {
|
|
add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 );
|
|
add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 );
|
|
}
|
|
|
|
/**
|
|
* Mirror a newly-inserted hp_favorite into the shadow table.
|
|
*
|
|
* @param int $comment_id Comment ID.
|
|
* @param object|\WP_Comment $comment Comment object.
|
|
*/
|
|
public function mirror_insert( int $comment_id, $comment ): void {
|
|
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
|
return;
|
|
}
|
|
global $wpdb;
|
|
$user_id = (int) ( $comment->user_id ?? 0 );
|
|
$listing_id = (int) ( $comment->comment_post_ID ?? 0 );
|
|
if ( $user_id <= 0 || $listing_id <= 0 ) {
|
|
return;
|
|
}
|
|
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$wpdb->prepare(
|
|
'INSERT IGNORE INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, user_id, listing_id, created_at) VALUES (%d, %d, %d, %s)',
|
|
$comment_id,
|
|
$user_id,
|
|
$listing_id,
|
|
$comment->comment_date ?? current_time( 'mysql' )
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remove the shadow row when a hp_favorite comment is deleted.
|
|
*
|
|
* @param int $comment_id Comment ID.
|
|
* @param object|\WP_Comment $comment Comment object.
|
|
*/
|
|
public function mirror_delete( int $comment_id, $comment ): void {
|
|
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
|
return;
|
|
}
|
|
global $wpdb;
|
|
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
$wpdb->prefix . self::TABLE,
|
|
array( 'comment_id' => $comment_id ),
|
|
array( '%d' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Doctor probe — verify shadow table exists.
|
|
*
|
|
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
|
*/
|
|
public function doctor_check(): array {
|
|
global $wpdb;
|
|
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
|
return array(
|
|
'ok' => false,
|
|
'message' => 'wpdb global not available',
|
|
);
|
|
}
|
|
$table = $wpdb->prefix . self::TABLE;
|
|
try {
|
|
$exists = (bool) $wpdb->get_var(
|
|
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
|
);
|
|
if ( ! $exists ) {
|
|
return array(
|
|
'ok' => false,
|
|
'message' => sprintf( 'hivepress-favorites: shadow table %s missing', $table ),
|
|
'details' => array( 'table' => $table ),
|
|
);
|
|
}
|
|
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
return array(
|
|
'ok' => true,
|
|
'message' => sprintf( 'hivepress-favorites: %s rows=%d', $table, $rows ),
|
|
'details' => array(
|
|
'table' => $table,
|
|
'rows' => $rows,
|
|
),
|
|
);
|
|
} catch ( \Throwable $e ) {
|
|
return array(
|
|
'ok' => false,
|
|
'message' => sprintf( 'hivepress-favorites doctor failed: %s', $e->getMessage() ),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 8-D self-score.
|
|
*
|
|
* D1: 1.0 — adapter never calls *_meta()
|
|
* D2: 1.0 — uses wpdb prefix, no hardcoded table literals
|
|
* D3: 1.0 — shadow table covers the model
|
|
* D4: 1.0 — expected_columns + indexes registered
|
|
* D5: 1.0 — listens to wp_insert_comment for cross-write consistency
|
|
* D6: 1.0 — zero options/transients
|
|
* D7: 1.0 — only queries own shadow table
|
|
* D8: 1.0 — UNIQUE(user_id, listing_id) covering index
|
|
*/
|
|
public function suitability_score(): array {
|
|
return $this->compose_score( array() );
|
|
}
|
|
|
|
/**
|
|
* FSM modules this adapter contributes.
|
|
*
|
|
* @return array<int,string>
|
|
*/
|
|
public function migrations(): array {
|
|
return array( 'comment_hp_favorite' );
|
|
}
|
|
}
|
|
|
|
} // end if ( ! class_exists )
|