Merge pull request 'hardening 2.0: 2meet-data-optimizer-hivepress-addon (staged rollout, dev22 first)' (#1) from hardening/2.0 into main
Tests / PHP Lint (push) Successful in 5s
Tests / Unit Tests (push) Successful in 1m6s
Tests / Integration Tests (push) Successful in 1m18s

This commit was merged in pull request #1.
This commit is contained in:
2026-09-26 10:24:16 +08:00
47 changed files with 748 additions and 3636 deletions
+1 -1
View File
@@ -138,7 +138,7 @@ wp wpdo hivepress benchmark</pre>
. esc_html__( '偵測到衝突外掛:', 'tmdo-hivepress' ) . '</strong> ' . esc_html__( '偵測到衝突外掛:', 'tmdo-hivepress' ) . '</strong> '
. esc_html( implode( ', ', $conflicts ) ) . esc_html( implode( ', ', $conflicts ) )
. '</p><p>' . '</p><p>'
. esc_html__( 'WPDO HivePress integration 已自動退讓。請執行 `wp wpdo hivepress migrate-from-hpct` 後停用舊外掛。', 'tmdo-hivepress' ) . esc_html__( 'WPDO HivePress integration 已自動退讓。停用舊外掛後即由 WPDO 接手(舊外掛自有資料表的資料不會匯入)。', 'tmdo-hivepress' )
. '</p></div>'; . '</p></div>';
} }
+1 -14
View File
@@ -20,8 +20,6 @@ add_action(
$aliases = array( $aliases = array(
'TMDO_Admin_HivePress' => 'WPDO_Admin_HivePress', 'TMDO_Admin_HivePress' => 'WPDO_Admin_HivePress',
'TMDO_CLI_HivePress' => 'WPDO_CLI_HivePress', 'TMDO_CLI_HivePress' => 'WPDO_CLI_HivePress',
'TMDO_Favorites_Interceptor' => 'WPDO_Favorites_Interceptor',
'TMDO_HPCT_Import' => 'WPDO_HPCT_Import',
'TMDO_HP_Bootstrap' => 'WPDO_HP_Bootstrap', 'TMDO_HP_Bootstrap' => 'WPDO_HP_Bootstrap',
'TMDO_HivePress' => 'WPDO_HivePress', 'TMDO_HivePress' => 'WPDO_HivePress',
'TMDO_HivePress_Attribute_Bridge' => 'WPDO_HivePress_Attribute_Bridge', 'TMDO_HivePress_Attribute_Bridge' => 'WPDO_HivePress_Attribute_Bridge',
@@ -29,9 +27,9 @@ add_action(
'TMDO_HivePress_Blocks_Adapter' => 'WPDO_HivePress_Blocks_Adapter', 'TMDO_HivePress_Blocks_Adapter' => 'WPDO_HivePress_Blocks_Adapter',
'TMDO_HivePress_Bookings_Adapter' => 'WPDO_HivePress_Bookings_Adapter', 'TMDO_HivePress_Bookings_Adapter' => 'WPDO_HivePress_Bookings_Adapter',
'TMDO_HivePress_Bootstrap' => 'WPDO_HivePress_Bootstrap', 'TMDO_HivePress_Bootstrap' => 'WPDO_HivePress_Bootstrap',
'TMDO_HivePress_Comment_Router' => 'WPDO_HivePress_Comment_Router',
'TMDO_HivePress_Conflict_Guard' => 'WPDO_HivePress_Conflict_Guard', 'TMDO_HivePress_Conflict_Guard' => 'WPDO_HivePress_Conflict_Guard',
'TMDO_HivePress_Core_Adapter' => 'WPDO_HivePress_Core_Adapter', 'TMDO_HivePress_Core_Adapter' => 'WPDO_HivePress_Core_Adapter',
'TMDO_HivePress_Entity_Groups' => 'WPDO_HivePress_Entity_Groups',
'TMDO_HivePress_Cron_Optimizer' => 'WPDO_HivePress_Cron_Optimizer', 'TMDO_HivePress_Cron_Optimizer' => 'WPDO_HivePress_Cron_Optimizer',
'TMDO_HivePress_Detector' => 'WPDO_HivePress_Detector', 'TMDO_HivePress_Detector' => 'WPDO_HivePress_Detector',
'TMDO_HivePress_Favorites_Adapter' => 'WPDO_HivePress_Favorites_Adapter', 'TMDO_HivePress_Favorites_Adapter' => 'WPDO_HivePress_Favorites_Adapter',
@@ -48,18 +46,7 @@ add_action(
'TMDO_HivePress_Tags_Adapter' => 'WPDO_HivePress_Tags_Adapter', 'TMDO_HivePress_Tags_Adapter' => 'WPDO_HivePress_Tags_Adapter',
'TMDO_Hivepress_Term_Comment_Fields' => 'WPDO_Hivepress_Term_Comment_Fields', 'TMDO_Hivepress_Term_Comment_Fields' => 'WPDO_Hivepress_Term_Comment_Fields',
'TMDO_Hivepress_Transient_Filter' => 'WPDO_Hivepress_Transient_Filter', 'TMDO_Hivepress_Transient_Filter' => 'WPDO_Hivepress_Transient_Filter',
'TMDO_Listing_Meta_Interceptor' => 'WPDO_Listing_Meta_Interceptor',
'TMDO_Listing_Meta_Query' => 'WPDO_Listing_Meta_Query',
'TMDO_Listing_Stats' => 'WPDO_Listing_Stats', 'TMDO_Listing_Stats' => 'WPDO_Listing_Stats',
'TMDO_Memberships_Interceptor' => 'WPDO_Memberships_Interceptor',
'TMDO_Memberships_Query' => 'WPDO_Memberships_Query',
'TMDO_Messages_Interceptor' => 'WPDO_Messages_Interceptor',
'TMDO_Messages_Query' => 'WPDO_Messages_Query',
'TMDO_Requests_Interceptor' => 'WPDO_Requests_Interceptor',
'TMDO_Requests_Query' => 'WPDO_Requests_Query',
'TMDO_Reviews_Interceptor' => 'WPDO_Reviews_Interceptor',
'TMDO_Reviews_Query' => 'WPDO_Reviews_Query',
'TMDO_Statistics_Interceptor' => 'WPDO_Statistics_Interceptor',
); );
foreach ( $aliases as $tmdo_class => $wpdo_alias ) { foreach ( $aliases as $tmdo_class => $wpdo_alias ) {
if ( class_exists( $tmdo_class, false ) && ! class_exists( $wpdo_alias, false ) ) { if ( class_exists( $tmdo_class, false ) && ! class_exists( $wpdo_alias, false ) ) {
+5 -61
View File
@@ -2,8 +2,7 @@
/** /**
* Bootstrap for 2meet Data Optimizer HivePress AddOn. * Bootstrap for 2meet Data Optimizer HivePress AddOn.
* *
* 載入 13 adapter + 7 interceptor + 5 query interceptor + 補助組件 + * 載入 13 adapter + 補助組件 + admin tab + CLI namespace。
* admin tab + CLI namespace。
* *
* 注意:HP 的 V3.0.0 family bootstrap 內部也有一個 class TMDO_HivePress_Bootstrap * 注意:HP 的 V3.0.0 family bootstrap 內部也有一個 class TMDO_HivePress_Bootstrap
* (在 includes/hivepress/class-tmdo-hivepress-bootstrap.php)。為避免衝突,本 * (在 includes/hivepress/class-tmdo-hivepress-bootstrap.php)。為避免衝突,本
@@ -44,7 +43,6 @@ final class TMDO_HP_Bootstrap {
require_once $base . 'includes/hivepress/class-tmdo-hivepress-detector.php'; require_once $base . 'includes/hivepress/class-tmdo-hivepress-detector.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php'; require_once $base . 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-bootstrap.php'; require_once $base . 'includes/hivepress/class-tmdo-hivepress-bootstrap.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-comment-router.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php'; require_once $base . 'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php';
// 13 adapters // 13 adapters
@@ -65,40 +63,11 @@ final class TMDO_HP_Bootstrap {
require_once $base . 'includes/hivepress/class-tmdo-hivepress-benchmark.php'; require_once $base . 'includes/hivepress/class-tmdo-hivepress-benchmark.php';
require_once $base . 'includes/hivepress/class-tmdo-hivepress-rest.php'; require_once $base . 'includes/hivepress/class-tmdo-hivepress-rest.php';
// 7 HPCT interceptors.
// reviews / messages / memberships / requests extend the core's
// TMDO_Standard_Post_Interceptor — requiring them against a core older than
// v0.2.0 would fatal at parse time, so gate those four on the base class.
$has_standard_base = class_exists( 'TMDO_Standard_Post_Interceptor' );
foreach ( array(
'reviews', 'messages', 'favorites', 'memberships',
'statistics', 'requests', 'listing-meta',
) as $intslug ) {
$needs_base = in_array( $intslug, array( 'reviews', 'messages', 'memberships', 'requests' ), true );
if ( $needs_base && ! $has_standard_base ) {
continue;
}
$file = $base . "includes/interceptors/class-tmdo-{$intslug}-interceptor.php";
if ( file_exists( $file ) ) {
require_once $file;
}
}
// 5 query interceptors
foreach ( array(
'reviews', 'messages', 'memberships', 'requests', 'listing-meta',
) as $qslug ) {
$file = $base . "includes/query/class-tmdo-{$qslug}-query.php";
if ( file_exists( $file ) ) {
require_once $file;
}
}
// Misc HP integration files // Misc HP integration files
require_once $base . 'includes/class-tmdo-hivepress.php'; require_once $base . 'includes/class-tmdo-hivepress.php';
require_once $base . 'includes/class-tmdo-hivepress-transient-filter.php'; require_once $base . 'includes/class-tmdo-hivepress-transient-filter.php';
require_once $base . 'includes/class-tmdo-hpct-import.php';
require_once $base . 'includes/class-tmdo-hivepress-term-comment-fields.php'; require_once $base . 'includes/class-tmdo-hivepress-term-comment-fields.php';
require_once $base . 'includes/class-tmdo-hivepress-entity-groups.php';
require_once $base . 'includes/class-tmdo-listing-stats.php'; require_once $base . 'includes/class-tmdo-listing-stats.php';
} }
@@ -111,34 +80,6 @@ final class TMDO_HP_Bootstrap {
TMDO_HivePress_Bootstrap::boot(); TMDO_HivePress_Bootstrap::boot();
} }
// 7 HPCT interceptors
foreach ( array(
'TMDO_Reviews_Interceptor',
'TMDO_Messages_Interceptor',
'TMDO_Favorites_Interceptor',
'TMDO_Memberships_Interceptor',
'TMDO_Statistics_Interceptor',
'TMDO_Requests_Interceptor',
'TMDO_Listing_Meta_Interceptor',
) as $cls ) {
if ( class_exists( $cls ) ) {
( new $cls() )->register_hooks();
}
}
// 5 query interceptors
foreach ( array(
'TMDO_Reviews_Query',
'TMDO_Messages_Query',
'TMDO_Memberships_Query',
'TMDO_Requests_Query',
'TMDO_Listing_Meta_Query',
) as $cls ) {
if ( class_exists( $cls ) ) {
( new $cls() )->register_hooks();
}
}
// HP transient filter (init:5) // HP transient filter (init:5)
if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) { if ( class_exists( 'TMDO_Hivepress_Transient_Filter' ) ) {
add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 ); add_action( 'init', array( 'TMDO_Hivepress_Transient_Filter', 'init' ), 5 );
@@ -156,6 +97,9 @@ final class TMDO_HP_Bootstrap {
TMDO_Hivepress_Term_Comment_Fields::register(); TMDO_Hivepress_Term_Comment_Fields::register();
} }
// Post + user entity groups (hp_listing_core / hp_request_core / hp_vendor_core / hp_user).
TMDO_HivePress_Entity_Groups::register();
// Admin tab // Admin tab
if ( is_admin() ) { if ( is_admin() ) {
$admin_file = TMDO_HIVEPRESS_PATH . 'admin/class-tmdo-admin-hivepress.php'; $admin_file = TMDO_HIVEPRESS_PATH . 'admin/class-tmdo-admin-hivepress.php';
@@ -0,0 +1,377 @@
<?php
/**
* HivePress entity groups owned by the HivePress AddOn.
*
* Groups → flat tables:
* post:hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
* post:hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
* post:hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
* user:hp_user → wp_wpdo_user_hp_user
*
* Fields only the Hot zone used to cover (ADR-003), as their own groups:
* post:hp_draft_flags (hp_listing, hp_request)
* post:hp_booking_core (hp_booking)
* post:hp_membership_plan_core (hp_membership_plan)
* post:hp_membership_core (hp_membership)
* They register FIRST so the legacy last-registered owner of hp_expired_time
* stays hp_request_core.
*
* The term/comment groups (hp_taxonomy, hp_review) live in
* TMDO_Hivepress_Term_Comment_Fields.
*
* ⚠️ These definitions must stay byte-identical to the ones live sites
* persisted in wpdo_registry_meta (originally registered by core's
* TMDO_Post_Fields / TMDO_Member_Fields). Any drift changes the schema hash
* and triggers ALTER TABLE on flat tables that may hold aeav_only data.
* The three post groups share keys (hp_status, hp_user, …): each declares
* its post type so a post's key resolves to its own type's group, while
* lookups without a post type keep last-registered-wins — keep their order.
*
* @package TMDO_HIVEPRESS
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers the HivePress post + user entity groups.
*/
final class TMDO_HivePress_Entity_Groups {
/**
* Hook into WPDO entity field registration and the REST default post type.
*
* @return void
*/
public static function register(): void {
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
add_filter( 'tmdo_rest_default_post_type', array( __CLASS__, 'rest_default_post_type' ) );
}
/**
* GET /wpdo/v1/listings serves HivePress listings when no post type is given.
*
* @return string
*/
public static function rest_default_post_type(): string {
return 'hp_listing';
}
/**
* Register the groups (order matters, see file header).
*
* @return void
*/
public static function register_entity_fields(): void {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
self::register_hp_draft_flags_group();
self::register_hp_booking_core_group();
self::register_hp_membership_plan_core_group();
self::register_hp_membership_core_group();
self::register_hp_listing_core_group();
self::register_hp_request_core_group();
self::register_hp_vendor_core_group();
self::register_hp_user_group();
}
// ── Group definitions ────────────────────────────────────────────────────
/**
* Draft flag shared by listings and requests (user-dashboard filtering).
*
* @return void
*/
private static function register_hp_draft_flags_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_draft_flags',
array(
array(
'key' => 'hp_drafted',
'type' => 'integer',
'searchable' => true,
'label' => 'Drafted flag (0/1)',
),
),
array( 'post_types' => array( 'hp_listing', 'hp_request' ) )
);
}
/**
* HivePress Bookings (post_type=hp_booking): slot-availability lookups.
*
* @return void
*/
private static function register_hp_booking_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_booking_core',
array(
array(
'key' => 'hp_start_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Booking start unix ts',
),
array(
'key' => 'hp_end_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Booking end unix ts',
),
array(
'key' => 'hp_quantity',
'type' => 'integer',
'label' => 'Places booked',
),
),
array( 'post_types' => array( 'hp_booking' ) )
);
}
/**
* HivePress Memberships plan (post_type=hp_membership_plan).
*
* @return void
*/
private static function register_hp_membership_plan_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_membership_plan_core',
array(
array(
'key' => 'hp_expire_period',
'type' => 'integer',
'label' => 'Plan expiry period (days)',
),
array(
'key' => 'hp_primary',
'type' => 'integer',
'searchable' => true,
'label' => 'Primary plan flag (0/1)',
),
),
array( 'post_types' => array( 'hp_membership_plan' ) )
);
}
/**
* HivePress Memberships subscription (post_type=hp_membership).
*
* @return void
*/
private static function register_hp_membership_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_membership_core',
array(
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
),
array( 'post_types' => array( 'hp_membership' ) )
);
}
/**
* HivePress listing core meta keys (post_type=hp_listing).
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
* copy-then-cutover the 230 dev10 rows to this group's table.
*
* @return void
*/
private static function register_hp_listing_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_listing_core',
array(
array(
'key' => 'hp_price',
'type' => 'decimal',
'searchable' => true,
'label' => 'Listing price',
),
array(
'key' => 'hp_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
'label' => 'Listing status',
),
array(
'key' => 'hp_featured',
'type' => 'integer',
'searchable' => true,
'label' => 'Featured flag (0/1)',
),
array(
'key' => 'hp_verified',
'type' => 'integer',
'searchable' => true,
'label' => 'Verified flag (0/1)',
),
array(
'key' => 'hp_vendor',
'type' => 'integer',
'searchable' => true,
'label' => 'Vendor user ID',
),
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
array(
'key' => 'hp_featured_time',
'type' => 'integer',
'label' => 'Featured-until unix ts',
),
array(
'key' => 'hp_view_count',
'type' => 'integer',
'searchable' => true,
'label' => 'View counter',
),
array(
'key' => 'hp_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
array(
'key' => 'hp_rating_count',
'type' => 'integer',
'label' => 'Rating count',
),
array(
'key' => 'hp_hourly_rate',
'type' => 'decimal',
'label' => 'Hourly rate',
),
),
array( 'post_types' => array( 'hp_listing' ) )
);
}
/**
* HivePress request core meta keys (post_type=hp_request).
*
* @return void
*/
private static function register_hp_request_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_request_core',
array(
array(
'key' => 'hp_status',
'type' => 'enum',
'searchable' => true,
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
'label' => 'Request status',
),
array(
'key' => 'hp_user',
'type' => 'integer',
'searchable' => true,
'label' => 'Request author user ID',
),
array(
'key' => 'hp_expired_time',
'type' => 'integer',
'searchable' => true,
'label' => 'Expiry unix ts',
),
array(
'key' => 'hp_budget',
'type' => 'decimal',
'searchable' => true,
'label' => 'Budget amount',
),
array(
'key' => 'hp_view_count',
'type' => 'integer',
'label' => 'View counter',
),
),
array( 'post_types' => array( 'hp_request' ) )
);
}
/**
* HivePress vendor core meta keys (post_type=hp_vendor).
*
* @return void
*/
private static function register_hp_vendor_core_group(): void {
TMDO_Entity_Registry::register_group(
'post',
'hp_vendor_core',
array(
array(
'key' => 'hp_user',
'type' => 'integer',
'searchable' => true,
'label' => 'Vendor user ID (linked WP user)',
),
array(
'key' => 'hp_verified',
'type' => 'integer',
'searchable' => true,
'label' => 'Verified flag (0/1)',
),
array(
'key' => 'hp_hourly_rate',
'type' => 'decimal',
'searchable' => true,
'label' => 'Hourly rate',
),
array(
'key' => 'hp_rating_count',
'type' => 'integer',
'label' => 'Rating count',
),
array(
'key' => 'hp_rating',
'type' => 'decimal',
'searchable' => true,
'label' => 'Average rating',
),
),
array( 'post_types' => array( 'hp_vendor' ) )
);
}
/**
* HivePress per-user fields (favorites + avatar attachment).
*
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
* the migration engine safe_unserialize()s it (allowed_classes=false to block
* PHP-object injection) then the json type encodes back to a JSON array column.
*
* @return void
*/
private static function register_hp_user_group(): void {
TMDO_Entity_Registry::register_group(
'user',
'hp_user',
array(
array(
'key' => 'hp_favorited_listings',
'type' => 'json',
'label' => 'HivePress favorited listing IDs (array)',
),
array(
'key' => 'hp_image',
'type' => 'text',
'label' => 'HivePress avatar attachment ID',
),
)
);
}
}
-248
View File
@@ -1,248 +0,0 @@
<?php
/**
* HPCT Import integration for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* HPCT Import — reads hp-custom-tables' feature flags and migration records,
* maps them to WPDO's 7-state lifecycle, and copies progress data.
*
* After import, WPDO takes ownership of all 9 HPCT modules and their
* corresponding hpct_* tables. The user is advised to deactivate hp-custom-tables.
*
* Usage:
* - Admin UI: Tools > WP Data Optimizer > HPCT Import tab
* - CLI: wp wpdo import-hpct
*/
class TMDO_HPCT_Import {
/** Option key that marks import as completed. */
public const IMPORTED_OPTION = 'wpdo_hpct_imported';
/**
* Check if HPCT import has already been completed.
*/
public static function is_imported(): bool {
return (bool) get_option( self::IMPORTED_OPTION, false );
}
/**
* Check if hp-custom-tables is active and has data to import.
*/
public static function can_import(): bool {
if ( self::is_imported() ) {
return false;
}
return TMDO_Compatibility::is_hpct_active();
}
/**
* Preview: return what will be imported without actually doing it.
*
* @return array{modules: array, migration_records: int, has_data: bool}
*/
public static function preview(): array {
$hpct_features = get_option( 'hpct_features', array() );
if ( ! is_array( $hpct_features ) ) {
$hpct_features = array();
}
$modules = array();
foreach ( TMDO_Feature_Flags::HPCT_MODULES as $module ) {
$hpct_status = $hpct_features[ $module ] ?? 'disabled';
$wpdo_state = TMDO_Feature_Flags::map_hpct_status( $hpct_status );
$modules[] = array(
'module' => $module,
'hpct_status' => $hpct_status,
'wpdo_state' => $wpdo_state,
);
}
// Count HPCT migration records.
global $wpdb;
$hpct_migrations_table = $wpdb->prefix . 'hpct_migrations';
$migration_count = 0;
// Check if hpct_migrations table exists.
if ( TMDO_IS_SQLITE ) {
$table_exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = %s",
$hpct_migrations_table
)
);
} else {
$table_exists = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s',
$hpct_migrations_table
)
);
}
if ( $table_exists ) {
$migration_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$hpct_migrations_table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix . 'hpct_migrations'.
}
return array(
'modules' => $modules,
'migration_records' => $migration_count,
'has_data' => ! empty( $hpct_features ),
);
}
/**
* Execute the import.
*
* @return array{success: bool, message: string, imported: array}
*/
public static function run(): array {
if ( self::is_imported() ) {
return array(
'success' => false,
'message' => 'HPCT import has already been completed.',
'imported' => array(),
);
}
// Step 1: Read HPCT feature flags.
$hpct_features = get_option( 'hpct_features', array() );
if ( ! is_array( $hpct_features ) ) {
$hpct_features = array();
}
$imported = array();
// Step 2: Map each HPCT module state to WPDO state.
foreach ( TMDO_Feature_Flags::HPCT_MODULES as $module ) {
$hpct_status = $hpct_features[ $module ] ?? 'disabled';
$wpdo_state = TMDO_Feature_Flags::map_hpct_status( $hpct_status );
TMDO_Feature_Flags::set( $module, $wpdo_state );
$imported[] = array(
'module' => $module,
'hpct_status' => $hpct_status,
'wpdo_state' => $wpdo_state,
);
}
// Step 3: Copy HPCT migration records to WPDO.
self::copy_migration_records();
// Step 4: Mark import as completed.
update_option( self::IMPORTED_OPTION, true );
return array(
'success' => true,
'message' => sprintf(
'Successfully imported %d HPCT modules. You can now deactivate hp-custom-tables.',
count( $imported )
),
'imported' => $imported,
);
}
/**
* Copy HPCT migration progress records to wpdo_migrations.
*/
private static function copy_migration_records(): void {
global $wpdb;
$hpct_table = $wpdb->prefix . 'hpct_migrations';
$wpdo_table = TMDO_DB::table( 'wpdo_migrations' );
$now = TMDO_DB::now();
// Check if hpct_migrations table exists.
$exists = false;
if ( TMDO_IS_SQLITE ) {
$exists = (bool) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = %s",
$hpct_table
)
);
} else {
$exists = (bool) $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s',
$hpct_table
)
);
}
if ( ! $exists ) {
return;
}
$hpct_records = $wpdb->get_results( "SELECT * FROM `{$hpct_table}`", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix . 'hpct_migrations'.
if ( ! $hpct_records ) {
return;
}
foreach ( $hpct_records as $record ) {
$module = $record['module'] ?? '';
if ( ! $module ) {
continue;
}
// Map HPCT status to WPDO state.
$hpct_status = $record['status'] ?? 'pending';
$state = self::map_migration_status( $hpct_status );
// Check if WPDO already has a record for this module.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from TMDO_DB::table().
$existing = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM `{$wpdo_table}` WHERE module = %s",
$module
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$data = array(
'module' => $module,
'zone' => '',
'state' => $state,
'total_rows' => (int) ( $record['total_rows'] ?? 0 ),
'processed_rows' => (int) ( $record['processed_rows'] ?? 0 ),
'last_offset' => (int) ( $record['last_offset'] ?? 0 ),
'error_count' => (int) ( $record['error_count'] ?? 0 ),
'started_at' => $record['started_at'] ?? null,
'completed_at' => $record['completed_at'] ?? null,
'updated_at' => $now,
);
if ( $existing ) {
$wpdb->update( $wpdo_table, $data, array( 'id' => (int) $existing ) );
} else {
$data['created_at'] = $now;
$wpdb->insert( $wpdo_table, $data );
}
}
}
/**
* Map HPCT migration record status to WPDO migration state.
*
* @param string $hpct_status HPCT migration status string.
* @return string Corresponding WPDO migration state.
*/
private static function map_migration_status( string $hpct_status ): string {
return match ( $hpct_status ) {
'pending' => 'idle',
'running' => 'backfill',
'completed' => 'verify',
'failed' => 'idle',
default => 'idle',
};
}
}
@@ -8,17 +8,13 @@
* Booking model (post_type=hp_booking) — fields are best-effort from the * Booking model (post_type=hp_booking) — fields are best-effort from the
* addon's documented field aliases: * addon's documented field aliases:
* *
* start_time / end_time → unix timestamp range queries (hot) * start_time / end_time → unix timestamp range queries
* status → enum filter (hot) * quantity → places booked, hp_quantity (only written when
* guests → numeric count (hot for slot-availability) * "Allow multiple places" is on)
* listing → post_parent (already a posts column) * listing → post_parent (already a posts column)
* *
* Hot-zone target: `wp_wpdo_hot_hp_booking` with covering indexes on * These keys are flattened by the `hp_booking_core` entity group
* `(listing_id, start_time)` for slot-availability queries and * (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
* `(status, start_time)` for vendor dashboard listings.
*
* The schema MAY drift from real Booking model when the addon is finally
* installed; reconcile with `wp wpdo doctor` then.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -64,73 +60,6 @@ if ( ! class_exists( 'TMDO_HivePress_Bookings_Adapter' ) ) {
return '1.0.0'; return '1.0.0';
} }
/**
* Register hot fields for hp_booking post type.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$registry->register_many(
$this->plugin_slug(),
array(
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_start_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_start_time',
'indexed' => true,
),
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_end_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_end_time',
'indexed' => true,
),
array(
'post_type' => 'hp_booking',
'meta_key' => 'hp_guests',
'zone' => 'hot',
'data_type' => 'int(11) NOT NULL DEFAULT 0',
'column' => 'hp_guests',
'indexed' => false,
),
)
);
}
/**
* Doctor probe — verify hot table when addon is active.
*
* @return array{ok:bool, message:string}
*/
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 . 'wpdo_hot_hp_booking';
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
return array(
'ok' => $exists,
'message' => $exists
? sprintf( 'hivepress-bookings: %s present', $table )
: sprintf( 'hivepress-bookings: %s missing (addon installed but not migrated)', $table ),
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-bookings doctor failed: %s', $e->getMessage() ),
);
}
}
/** /**
* 8-D self-score. * 8-D self-score.
* *
@@ -145,15 +74,6 @@ if ( ! class_exists( 'TMDO_HivePress_Bookings_Adapter' ) ) {
) )
); );
} }
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array( 'hot_hp_booking' );
}
} }
} // end if ( ! class_exists ) } // end if ( ! class_exists )
@@ -2,15 +2,15 @@
/** /**
* Core HivePress adapter — `hivepress` (the parent plugin). * Core HivePress adapter — `hivepress` (the parent plugin).
* *
* Owns three primary HivePress models: * Covers the primary HivePress models:
* *
* - `Listing` (post_type=hp_listing) — Hot zone columns for search/filter * - `Listing` (post_type=hp_listing) — cold blob for the gallery.
* (drafted, featured, verified, expired_time, featured_time). * - `Vendor` (post_type=hp_vendor) — cold blob for the image.
* - `Vendor` (post_type=hp_vendor) — Hot column `verified`; cold blob * - `User` (entity=user) — no zone fields; its `hp_user`
* for image / description (display-only). * entity group is registered by TMDO_HivePress_Entity_Groups.
* - `User` (entity=user) — Entity group `hp_user_core` for *
* `hp_verified` and the WP-stock `first_name` / `last_name` / * Search/filter keys are flattened by TMDO_HivePress_Entity_Groups
* `description`. * (ADR-003: no Hot zone fields).
* *
* This adapter SUPERSEDES the legacy `class-tmdo-hivepress.php` (242 lines). * This adapter SUPERSEDES the legacy `class-tmdo-hivepress.php` (242 lines).
* Behavioural parity: * Behavioural parity:
@@ -24,14 +24,9 @@
* adapter only registers `hivepress` core fields. * adapter only registers `hivepress` core fields.
* - Vendor cold fields (`hp_image`, `hp_images`) — preserved. * - Vendor cold fields (`hp_image`, `hp_images`) — preserved.
* *
* NEW additions vs legacy: * (This adapter used to register a second `hp_user` definition —
* * hp_verified / hp_avatar_id, keys HivePress never writes — which the
* - `hp_drafted` (Listing) — Hot tinyint(1) for user-dashboard filtering. * registry silently dropped; TMDO_HivePress_Entity_Groups owns it now.)
* - `hp_expired_time` / `hp_featured_time` (Listing) — Hot bigint indexed
* for cron expiry scan acceleration (replaces postmeta full scan).
* - `hp_user_core` entity group — first_name / last_name / hp_verified
* promoted from usermeta to wp_wpdo_user_hp_user (already created by
* TMDO_Member_Fields v2.7.0).
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -115,37 +110,10 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
* @param TMDO_Schema_Registry $registry Schema registry singleton. * @param TMDO_Schema_Registry $registry Schema registry singleton.
*/ */
public function on_register_fields( TMDO_Schema_Registry $registry ): void { public function on_register_fields( TMDO_Schema_Registry $registry ): void {
// ── Listing hot fields (search/filter accelerators) ── // ── Cold blobs (display-only) ──
$registry->register_many( $registry->register_many(
$this->plugin_slug(), $this->plugin_slug(),
array( array(
// Drafted — used in user dashboard "my drafts" filter.
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_drafted',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_drafted',
'indexed' => true,
),
// Expired time — replaces `meta_query` clause in hourly cron.
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_expired_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_expired_time',
'indexed' => true,
),
// Featured time — same expiry pattern as above.
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_featured_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_featured_time',
'indexed' => true,
),
// Vendor cold blob — image attachment id (display-only). // Vendor cold blob — image attachment id (display-only).
array( array(
'post_type' => 'hp_vendor', 'post_type' => 'hp_vendor',
@@ -166,47 +134,6 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
); );
} }
/**
* Register entity field group for HP User model.
*
* The `hp_user` group is already created by `TMDO_Member_Fields`
* (v2.7.0). We add adapter ownership here for cross-cutting tooling
* (`wp wpdo hivepress doctor` reports owner; admin UI shows source).
*
* The `register_entity_fields()` helper from `TMDO_Anti_EAV_Aware`
* delegates to `TMDO_Entity_Registry::register_group()` which has
* internal dedup — so re-registering with same group name is a no-op.
*/
public function on_register_entity_fields(): void {
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
return;
}
// Mirror the (canonical) group definition from TMDO_Member_Fields::HP_USER_FIELDS.
// Source of truth lives in TMDO_Member_Fields; we register defensively for the
// case where Member_Fields didn't load (custom builds without that integration).
$this->register_entity_fields(
'user',
'hp_user',
array(
array(
'key' => 'hp_verified',
'type' => 'boolean',
'default' => false,
'searchable' => true,
'label' => 'HivePress KYC verified',
),
array(
'key' => 'hp_avatar_id',
'type' => 'integer',
'default' => 0,
'searchable' => false,
'label' => 'HivePress avatar attachment id',
),
)
);
}
// ── Query optimization (preserved from legacy class-tmdo-hivepress.php) ── // ── Query optimization (preserved from legacy class-tmdo-hivepress.php) ──
/** /**
@@ -318,57 +245,6 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
// ── Doctor / score / migrations ──────────────────────────────────── // ── Doctor / score / migrations ────────────────────────────────────
/**
* Health probe: verify hot tables exist + sample row count.
*
* @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',
);
}
$details = array();
$ok = true;
foreach ( self::HOT_MODELS as $post_type ) {
$table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $post_type );
try {
$exists = (bool) $wpdb->get_var(
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
);
$details[ $post_type ] = array(
'table' => $table,
'exists' => $exists,
);
if ( $exists ) {
$details[ $post_type ]['rows'] = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM `{$table}`" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Sanitized via sanitize_key + wpdb prefix.
);
} else {
$ok = false;
}
} catch ( \Throwable $e ) {
$details[ $post_type ] = array(
'table' => $table,
'error' => $e->getMessage(),
);
$ok = false;
}
}
return array(
'ok' => $ok,
'message' => $ok
? 'hivepress core: hot tables OK'
: 'hivepress core: one or more hot tables missing or unreadable',
'details' => $details,
);
}
/** /**
* 8-dimension self-score for the core adapter. * 8-dimension self-score for the core adapter.
* *
@@ -390,18 +266,6 @@ if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
) )
); );
} }
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array(
'hot_hp_listing',
'hot_hp_vendor',
);
}
} }
} // end if ( ! class_exists ) } // end if ( ! class_exists )
@@ -8,27 +8,9 @@
* - listing → comment_post_ID * - listing → comment_post_ID
* - added_at → comment_date * - added_at → comment_date
* *
* Problem: `wp_comments` has no UNIQUE constraint on (user_id, comment_post_ID, comment_type). * Nothing is stored in comment meta, so there is no EAV surface to flatten:
* A double-tap on the favourite button can race and create two duplicate * the adapter only reports detection and doctor status. (It used to mirror
* favourite rows. HivePress relies on application-level dedup (SELECT before * favorites into `wpdo_comment_hp_favorite`, a table nothing ever created.)
* 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 * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -41,18 +23,15 @@ if ( ! defined( 'ABSPATH' ) ) {
if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) { if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
/** /**
* Favorites addon adapter. * Favorites addon adapter (detection and doctor only).
*/ */
final class TMDO_HivePress_Favorites_Adapter implements TMDO_HivePress_Adapter { final class TMDO_HivePress_Favorites_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait; use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */ /** Comment_type of the Favorite model. */
public const COMMENT_TYPE = 'hp_favorite'; 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. * Constructor — bind register hooks via inherited trait.
*/ */
@@ -79,156 +58,6 @@ if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
public function minimum_addon_version(): string { public function minimum_addon_version(): string {
return '1.2.0'; 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 ) } // end if ( ! class_exists )
@@ -3,16 +3,14 @@
* Marketplace adapter — `hivepress-marketplace` (DETECT-ONLY in v3.0.0). * Marketplace adapter — `hivepress-marketplace` (DETECT-ONLY in v3.0.0).
* *
* The marketplace addon turns listings into purchasable items, integrating * The marketplace addon turns listings into purchasable items, integrating
* with WooCommerce. It adds: * with WooCommerce. Its filterable, sortable listing attribute `price` is
* stored as `hp_price` → decimal(10,2), used in price-range filters. It is
* flattened by the `hp_listing_core` entity group
* (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
* *
* hp_purchase_price → decimal(10,2), used in price-range filters (HOT) * Its other listing attributes (price tiers / extras / discounts, quantity,
* hp_purchase_count → counter (WARM zone, low-criticality) * subscription period, …) are not search filters, and it keeps no per-listing
* * sales counter.
* Note: `hp_purchase_price` is also conditionally registered by the existing
* legacy hivepress integration (Sprint 1 left that path) — the dedup in
* Schema_Registry is provider+key based, so registering again under the
* `hivepress-marketplace` provider is safe and lets `wp wpdo doctor` show
* the right ownership.
* *
* Detect-only: not installed in dev environment. Schema reserved for the * Detect-only: not installed in dev environment. Schema reserved for the
* day the addon ships. * day the addon ships.
@@ -61,63 +59,6 @@ if ( ! class_exists( 'TMDO_HivePress_Marketplace_Adapter' ) ) {
return '1.0.0'; return '1.0.0';
} }
/**
* Register marketplace hot fields on the listing post type.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$registry->register_many(
$this->plugin_slug(),
array(
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_purchase_price',
'zone' => 'hot',
'data_type' => 'decimal(10,2) DEFAULT NULL',
'column' => 'hp_purchase_price',
'indexed' => true,
),
array(
'post_type' => 'hp_listing',
'meta_key' => 'hp_purchase_count',
'zone' => 'warm',
'ttl' => null,
),
)
);
}
/**
* Doctor probe — verify hot column exists on listing hot table.
*
* @return array{ok:bool, message:string}
*/
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 . 'wpdo_hot_hp_listing';
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
return array(
'ok' => $exists,
'message' => $exists
? 'hivepress-marketplace: shares wp_wpdo_hot_hp_listing'
: 'hivepress-marketplace: hot listing table missing',
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-marketplace doctor failed: %s', $e->getMessage() ),
);
}
}
/** /**
* 8-D self-score. * 8-D self-score.
* *
@@ -5,18 +5,13 @@
* Two post-type models: * Two post-type models:
* *
* - Membership_Plan (post_type=hp_membership_plan) * - Membership_Plan (post_type=hp_membership_plan)
* hot fields: hp_expire_period (int), hp_primary (bool) * hp_expire_period (int), hp_primary (bool)
* *
* - Membership (post_type=hp_membership) * - Membership (post_type=hp_membership)
* hot fields: hp_expired_time (unix ts) — checked on every page load * hp_expired_time (unix ts) — checked on every page load
* *
* Both fields registered to Hot zone so: * Flattened by the `hp_membership_plan_core` / `hp_membership_core` entity
* - `hp_expired_time < NOW()` lookups become indexed range scans * groups (TMDO_HivePress_Entity_Groups); the adapter registers no zone fields.
* (replaces O(N) postmeta scan on each request).
* - `hp_primary = 1` lookups for "default plan" become O(1).
*
* Tables `wp_wpdo_hot_hp_membership_plan` and `wp_wpdo_hot_hp_membership`
* are created by `TMDO_Schema_Manager` from the registered hot columns.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -62,81 +57,6 @@ if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
return '2.0.0'; return '2.0.0';
} }
/**
* Register hot fields for both post types.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$registry->register_many(
$this->plugin_slug(),
array(
// Plan fields.
array(
'post_type' => 'hp_membership_plan',
'meta_key' => 'hp_expire_period',
'zone' => 'hot',
'data_type' => 'int(11) NOT NULL DEFAULT 0',
'column' => 'hp_expire_period',
'indexed' => false,
),
array(
'post_type' => 'hp_membership_plan',
'meta_key' => 'hp_primary',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_primary',
'indexed' => true,
),
// Membership (subscription) field — hottest path: every page load.
array(
'post_type' => 'hp_membership',
'meta_key' => 'hp_expired_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_expired_time',
'indexed' => true,
),
)
);
}
/**
* Doctor probe — verify both hot tables.
*
* @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',
);
}
$details = array();
$ok = true;
foreach ( array( 'hp_membership_plan', 'hp_membership' ) as $pt ) {
$table = $wpdb->prefix . 'wpdo_hot_' . sanitize_key( $pt );
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$details[ $pt ]['table'] = $table;
$details[ $pt ]['exists'] = $exists;
if ( ! $exists ) {
$ok = false;
}
} catch ( \Throwable $e ) {
$details[ $pt ]['error'] = $e->getMessage();
$ok = false;
}
}
return array(
'ok' => $ok,
'message' => $ok ? 'hivepress-memberships: hot tables OK' : 'hivepress-memberships: hot tables missing',
'details' => $details,
);
}
/** /**
* 8-D self-score. * 8-D self-score.
* *
@@ -152,18 +72,6 @@ if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
public function suitability_score(): array { public function suitability_score(): array {
return $this->compose_score( array() ); return $this->compose_score( array() );
} }
/**
* FSM modules this adapter contributes.
*
* @return array<int,string>
*/
public function migrations(): array {
return array(
'hot_hp_membership_plan',
'hot_hp_membership',
);
}
} }
} // end if ( ! class_exists ) } // end if ( ! class_exists )
@@ -3,28 +3,12 @@
* Messages adapter — `hivepress-messages`. * Messages adapter — `hivepress-messages`.
* *
* The Message model extends Comment (`comment_type = hp_message`) and stores * The Message model extends Comment (`comment_type = hp_message`) and stores
* the recipient_id in `comment_karma` — a creative re-use of an unused column, * the recipient in `comment_karma`, the sender in `user_id` and the read flag
* but it leaves the field unindexed for "messages received by user N" * in `comment_approved`.
* queries. Result: every dashboard "unread count" badge does an O(N)
* `WHERE comment_type = 'hp_message' AND comment_karma = N` scan over
* the entire wp_comments table.
* *
* Fix: shadow table `wp_wpdo_comment_hp_message` with proper recipient_id * Nothing is stored in comment meta, so there is no EAV surface to flatten:
* column + index. Adapter mirrors every hp_message write into the shadow, * the adapter only reports detection and doctor status. (It used to mirror
* and the comment-router rewrites recipient/unread queries to hit the index. * messages into `wpdo_comment_hp_message`, a table nothing ever created.)
*
* Schema:
*
* CREATE TABLE wp_wpdo_comment_hp_message (
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
* sender_id BIGINT UNSIGNED NOT NULL,
* recipient_id BIGINT UNSIGNED NOT NULL,
* listing_id BIGINT UNSIGNED NOT NULL,
* is_read TINYINT(1) NOT NULL DEFAULT 0,
* sent_at DATETIME NOT NULL,
* KEY idx_recipient_unread (recipient_id, is_read),
* KEY idx_thread (listing_id, sent_at)
* );
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -37,18 +21,15 @@ if ( ! defined( 'ABSPATH' ) ) {
if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) { if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
/** /**
* Messages addon adapter. * Messages addon adapter (detection and doctor only).
*/ */
final class TMDO_HivePress_Messages_Adapter implements TMDO_HivePress_Adapter { final class TMDO_HivePress_Messages_Adapter implements TMDO_HivePress_Adapter {
use TMDO_HivePress_Adapter_Trait; use TMDO_HivePress_Adapter_Trait;
/** Comment_type owned by this adapter. */ /** Comment_type of the Message model. */
public const COMMENT_TYPE = 'hp_message'; public const COMMENT_TYPE = 'hp_message';
/** Shadow table name (without wpdb prefix). */
public const TABLE = 'wpdo_comment_hp_message';
/** /**
* Constructor — bind register hooks via inherited trait. * Constructor — bind register hooks via inherited trait.
*/ */
@@ -75,189 +56,6 @@ if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
public function minimum_addon_version(): string { public function minimum_addon_version(): string {
return '1.4.0'; return '1.4.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',
'sender_id' => 'BIGINT UNSIGNED NOT NULL',
'recipient_id' => 'BIGINT UNSIGNED NOT NULL',
'listing_id' => 'BIGINT UNSIGNED NOT NULL',
'is_read' => 'TINYINT(1) NOT NULL DEFAULT 0',
'sent_at' => 'DATETIME NOT NULL',
),
'indexes' => array(
'idx_recipient_unread' => '(recipient_id, is_read)',
'idx_thread' => '(listing_id, sent_at)',
),
'doctor_callback' => array( $this, 'doctor_check' ),
)
);
}
/**
* Bind sync hooks on hp_message comment insert / update / delete.
*
* The recipient_id is stored in comment_karma by HivePress; we mirror
* it into a properly-named indexed column. is_read tracks comment_approved
* (HP uses 0=unread / 1=read) — same column, mirrored.
*/
public function on_register_event_hooks(): void {
add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 );
add_action( 'wp_set_comment_status', array( $this, 'mirror_status_change' ), 10, 2 );
add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 );
}
/**
* Mirror a new hp_message 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;
$sender_id = (int) ( $comment->user_id ?? 0 );
$recipient_id = (int) ( $comment->comment_karma ?? 0 );
$listing_id = (int) ( $comment->comment_post_ID ?? 0 );
$is_read = (int) ( $comment->comment_approved ?? 0 );
if ( $sender_id <= 0 || $recipient_id <= 0 ) {
// Malformed message — nothing useful to mirror.
return;
}
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
'INSERT INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, sender_id, recipient_id, listing_id, is_read, sent_at) VALUES (%d, %d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE is_read = VALUES(is_read)',
$comment_id,
$sender_id,
$recipient_id,
$listing_id,
$is_read,
$comment->comment_date ?? current_time( 'mysql' )
)
);
}
/**
* Update is_read when a message is marked read (comment_approved flip).
*
* @param int $comment_id Comment ID.
* @param string $status New status ('approve', 'hold', 'spam', 'trash').
*/
public function mirror_status_change( int $comment_id, string $status ): void {
global $wpdb;
// Look up the comment to confirm it's hp_message before touching.
if ( ! function_exists( 'get_comment' ) ) {
return;
}
$comment = get_comment( $comment_id );
if ( ! $comment || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
return;
}
$is_read = ( 'approve' === $status ) ? 1 : 0;
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . self::TABLE,
array( 'is_read' => $is_read ),
array( 'comment_id' => $comment_id ),
array( '%d' ),
array( '%d' )
);
}
/**
* Remove shadow row on hp_message deletion.
*
* @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.
*
* @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-messages: 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-messages: %s rows=%d', $table, $rows ),
'details' => array(
'table' => $table,
'rows' => $rows,
),
);
} catch ( \Throwable $e ) {
return array(
'ok' => false,
'message' => sprintf( 'hivepress-messages doctor failed: %s', $e->getMessage() ),
);
}
}
/**
* 8-D self-score.
*
* D1: 1.0 — adapter never calls *_meta()
* D2: 1.0 — uses wpdb prefix exclusively
* D3: 1.0 — shadow table covers recipient/unread access
* D4: 1.0 — expected_columns + indexes registered
* D5: 1.0 — listens to wp_insert_comment + status change for sync
* D6: 1.0 — zero options/transients
* D7: 1.0 — only queries own shadow table
* D8: 1.0 — idx_recipient_unread covers the hot dashboard query
*/
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_message' );
}
} }
} // end if ( ! class_exists ) } // end if ( ! class_exists )
@@ -5,25 +5,13 @@
* Two related models: * Two related models:
* *
* - Request (post_type=hp_request) * - Request (post_type=hp_request)
* hot fields: hp_drafted (bool), hp_expired_time (unix ts) * hp_drafted / hp_expired_time are flattened by the `hp_draft_flags`
* — same access pattern as Listing (user dashboard + expiry cron) * and `hp_request_core` entity groups (TMDO_HivePress_Entity_Groups).
* *
* - Offer (comment_type=hp_offer, parent post_type=hp_request) * - Offer (comment_type=hp_offer, parent post_type=hp_request)
* comment-column based; benefit comes from comment-router using * stored in comment columns only — no comment meta to flatten.
* (comment_post_ID, comment_type) covering index + dedicated shadow * (The adapter used to mirror offers into `wpdo_comment_hp_offer`,
* table for "approved offers per request" aggregation. * a table nothing ever created.)
*
* Shadow table for offers (analogous to favorites pattern):
*
* CREATE TABLE wp_wpdo_comment_hp_offer (
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
* request_id BIGINT UNSIGNED NOT NULL,
* bidder_id BIGINT UNSIGNED NOT NULL,
* approved TINYINT(1) NOT NULL DEFAULT 0,
* created_at DATETIME NOT NULL,
* KEY idx_request_approved (request_id, approved),
* KEY idx_bidder (bidder_id)
* );
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -45,9 +33,6 @@ if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
/** Comment_type for the Offer side of Requests. */ /** Comment_type for the Offer side of Requests. */
public const OFFER_COMMENT_TYPE = 'hp_offer'; public const OFFER_COMMENT_TYPE = 'hp_offer';
/** Shadow table for offers (without wpdb prefix). */
public const OFFER_TABLE = 'wpdo_comment_hp_offer';
/** /**
* Constructor — bind register hooks via inherited trait. * Constructor — bind register hooks via inherited trait.
*/ */
@@ -74,194 +59,6 @@ if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
public function minimum_addon_version(): string { public function minimum_addon_version(): string {
return '1.2.0'; return '1.2.0';
} }
/**
* Register hot fields for hp_request post type.
*
* @param TMDO_Schema_Registry $registry Schema registry singleton.
*/
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$registry->register_many(
$this->plugin_slug(),
array(
array(
'post_type' => 'hp_request',
'meta_key' => 'hp_drafted',
'zone' => 'hot',
'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
'column' => 'hp_drafted',
'indexed' => true,
),
array(
'post_type' => 'hp_request',
'meta_key' => 'hp_expired_time',
'zone' => 'hot',
'data_type' => 'bigint(20) UNSIGNED NOT NULL DEFAULT 0',
'column' => 'hp_expired_time',
'indexed' => true,
),
)
);
}
/**
* Declare offer shadow table.
*
* @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::OFFER_TABLE,
'primary_key' => 'comment_id',
'post_type_link' => 'hp_request',
'expected_columns' => array(
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
'request_id' => 'BIGINT UNSIGNED NOT NULL',
'bidder_id' => 'BIGINT UNSIGNED NOT NULL',
'approved' => 'TINYINT(1) NOT NULL DEFAULT 0',
'created_at' => 'DATETIME NOT NULL',
),
'indexes' => array(
'idx_request_approved' => '(request_id, approved)',
'idx_bidder' => '(bidder_id)',
),
'doctor_callback' => array( $this, 'doctor_check' ),
)
);
}
/**
* Mirror new hp_offer comments into the shadow table.
*/
public function on_register_event_hooks(): void {
add_action( 'wp_insert_comment', array( $this, 'mirror_offer_insert' ), 10, 2 );
add_action( 'edit_comment', array( $this, 'mirror_offer_update' ), 10, 2 );
add_action( 'deleted_comment', array( $this, 'mirror_offer_delete' ), 10, 2 );
}
/**
* Insert a hp_offer mirror row.
*
* @param int $comment_id Comment ID.
* @param object|\WP_Comment $comment Comment object.
*/
public function mirror_offer_insert( int $comment_id, $comment ): void {
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::OFFER_COMMENT_TYPE ) {
return;
}
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
'INSERT INTO `' . $wpdb->prefix . self::OFFER_TABLE . '` (comment_id, request_id, bidder_id, approved, created_at) VALUES (%d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE approved = VALUES(approved)',
$comment_id,
(int) ( $comment->comment_post_ID ?? 0 ),
(int) ( $comment->user_id ?? 0 ),
(int) ( $comment->comment_approved ?? 0 ),
$comment->comment_date ?? current_time( 'mysql' )
)
);
}
/**
* Update mirror row when comment is edited (approval flip is the hot path).
*
* @param int $comment_id Comment ID.
* @param array $data Comment data array passed by edit_comment.
*/
public function mirror_offer_update( int $comment_id, $data ): void {
unset( $data );
global $wpdb;
// Re-fetch comment object for accurate state then re-mirror via insert path.
if ( function_exists( 'get_comment' ) ) {
$comment = get_comment( $comment_id );
if ( $comment ) {
$this->mirror_offer_insert( $comment_id, $comment );
}
}
unset( $wpdb );
}
/**
* Remove mirror row on hp_offer deletion.
*
* @param int $comment_id Comment ID.
* @param object|\WP_Comment $comment Comment object.
*/
public function mirror_offer_delete( int $comment_id, $comment ): void {
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::OFFER_COMMENT_TYPE ) {
return;
}
global $wpdb;
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . self::OFFER_TABLE,
array( 'comment_id' => $comment_id ),
array( '%d' )
);
}
/**
* Doctor probe — request hot table + offer shadow table.
*
* @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',
);
}
$details = array();
$ok = true;
foreach ( array(
'hp_request_hot' => $wpdb->prefix . 'wpdo_hot_hp_request',
'hp_offer_shadow' => $wpdb->prefix . self::OFFER_TABLE,
) as $key => $table ) {
try {
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
$details[ $key ] = array(
'table' => $table,
'exists' => $exists,
);
if ( ! $exists ) {
$ok = false;
}
} catch ( \Throwable $e ) {
$details[ $key ] = array(
'table' => $table,
'error' => $e->getMessage(),
);
$ok = false;
}
}
return array(
'ok' => $ok,
'message' => $ok ? 'hivepress-requests: tables OK' : 'hivepress-requests: one or more tables missing',
'details' => $details,
);
}
/**
* 8-D self-score.
*/
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(
'hot_hp_request',
'comment_hp_offer',
);
}
} }
} // end if ( ! class_exists ) } // end if ( ! class_exists )
@@ -10,14 +10,12 @@
* - approved → comment_approved * - approved → comment_approved
* - listing → comment_post_ID * - listing → comment_post_ID
* *
* No postmeta is used; no entity-bridge group needed. Hot-path optimization * No postmeta is used; no entity-bridge group needed. Queries use the core
* comes from `TMDO_HivePress_Comment_Router` rewriting comment-query SQL to * `wp_comments` indexes on `comment_post_ID` / `comment_type`, and the
* use `comment_post_ID + comment_type + comment_approved` index, and from * `wp_wpdo_comment_hp_review` flat table is maintained by the WPDO Comment
* the `wp_wpdo_comment_hp_review` flat table that WPDO Comment entity bridge * entity bridge (since v2.13.x).
* already maintains (since v2.13.x).
* *
* This adapter's role: * This adapter's role:
* - Declare the comment_type to the comment-router
* - Doctor probe of the entity-bridge table * - Doctor probe of the entity-bridge table
* - Self-report 8-D suitability score * - Self-report 8-D suitability score
* *
@@ -124,7 +122,7 @@ if ( ! class_exists( 'TMDO_HivePress_Reviews_Adapter' ) ) {
* D5: 1.0 — no cross-adapter writes needed yet * D5: 1.0 — no cross-adapter writes needed yet
* D6: 1.0 — zero options/transients written * D6: 1.0 — zero options/transients written
* D7: 1.0 — only queries own entity-bridge table * D7: 1.0 — only queries own entity-bridge table
* D8: 1.0 — comment-router uses (comment_post_ID, comment_type) covering index * D8: 1.0 — reads use the core wp_comments (comment_post_ID, comment_type) indexes
*/ */
public function suitability_score(): array { public function suitability_score(): array {
return $this->compose_score( array() ); return $this->compose_score( array() );
@@ -2,13 +2,13 @@
/** /**
* Social Links adapter — `hivepress-social-links`. * Social Links adapter — `hivepress-social-links`.
* *
* Adds social URL fields to vendor profiles (Facebook, Instagram, Twitter, * The addon adds one attribute per social link the admin enables, separately
* LinkedIn, YouTube, etc.). Stored in postmeta as `hp_social_*` keys. * for listings (`hp_listing_social_links`) and vendors (`hp_vendor_social_links`).
* Each attribute is stored in postmeta as `hp_{name}` (e.g. `hp_facebook`).
* *
* Display-only — never used in search/filter — so the right zone is Cold * Display-only — never used in search/filter — so the right zone is Cold
* (object cache + JSON blob fallback). Listing page reads ~7 social URLs; * (object cache + JSON blob fallback): one blob per post replaces one
* caching them as a single blob keyed on vendor_id eliminates 7 postmeta * postmeta lookup per link on every render.
* lookups per render.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -27,25 +27,6 @@ if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
use TMDO_HivePress_Adapter_Trait; use TMDO_HivePress_Adapter_Trait;
/**
* Social URL meta keys owned by this addon.
*
* Sources (HivePress 1.0.3 default field set):
* facebook_url, instagram_url, twitter_url, linkedin_url,
* youtube_url, telegram_url, whatsapp_url
*
* @var array<int,string>
*/
private const SOCIAL_KEYS = array(
'hp_facebook_url',
'hp_instagram_url',
'hp_twitter_url',
'hp_linkedin_url',
'hp_youtube_url',
'hp_telegram_url',
'hp_whatsapp_url',
);
/** /**
* Constructor — bind register hooks via inherited trait. * Constructor — bind register hooks via inherited trait.
*/ */
@@ -74,24 +55,44 @@ if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
} }
/** /**
* Register vendor social URL fields to Cold zone. * Register the enabled social link fields to the Cold zone.
* *
* @param TMDO_Schema_Registry $registry Schema registry singleton. * @param TMDO_Schema_Registry $registry Schema registry singleton.
*/ */
public function on_register_fields( TMDO_Schema_Registry $registry ): void { public function on_register_fields( TMDO_Schema_Registry $registry ): void {
$fields = array(); $fields = array();
foreach ( self::SOCIAL_KEYS as $key ) { foreach ( $this->enabled_meta_keys() as $post_type => $keys ) {
$fields[] = array( foreach ( $keys as $key ) {
'post_type' => 'hp_vendor', $fields[] = array(
'meta_key' => $key, 'post_type' => $post_type,
'zone' => 'cold', 'meta_key' => $key,
'cache_group' => 'wpdo_cold_hp_vendor', 'zone' => 'cold',
'cache_ttl' => HOUR_IN_SECONDS, 'cache_group' => 'wpdo_cold_' . $post_type,
); 'cache_ttl' => HOUR_IN_SECONDS,
);
}
} }
$registry->register_many( $this->plugin_slug(), $fields ); $registry->register_many( $this->plugin_slug(), $fields );
} }
/**
* Meta keys of the social links enabled in the addon settings.
*
* @return array<string, array<int, string>> post_type => meta keys.
*/
private function enabled_meta_keys(): array {
$keys = array();
foreach ( array( 'listing', 'vendor' ) as $model ) {
foreach ( (array) get_option( 'hp_' . $model . '_social_links', array() ) as $name ) {
$name = sanitize_key( (string) $name );
if ( '' !== $name ) {
$keys[ 'hp_' . $model ][] = 'hp_' . $name;
}
}
}
return $keys;
}
/** /**
* Doctor probe — cold zone uses cache; verify cache layer is reachable. * Doctor probe — cold zone uses cache; verify cache layer is reachable.
* *
@@ -100,7 +101,7 @@ if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
public function doctor_check(): array { public function doctor_check(): array {
return array( return array(
'ok' => true, 'ok' => true,
'message' => sprintf( 'hivepress-social-links: %d cold fields registered', count( self::SOCIAL_KEYS ) ), 'message' => sprintf( 'hivepress-social-links: %d cold fields registered', array_sum( array_map( 'count', $this->enabled_meta_keys() ) ) ),
); );
} }
@@ -118,12 +118,9 @@ if ( ! class_exists( 'TMDO_HivePress_Bootstrap' ) ) {
self::bind_adapter( $slug, $version ); self::bind_adapter( $slug, $version );
} }
// Step 5: register cross-cutting components (router + cron optimizer). // Step 5: register the cron optimizer. It is gated by its own opt-in
// Both are gated by their own opt-in option and only activate when // option and only activates when the operator flips it; safe to call
// the operator flips it; safe to call here unconditionally. // here unconditionally.
if ( class_exists( 'TMDO_HivePress_Comment_Router' ) ) {
TMDO_HivePress_Comment_Router::register();
}
if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) { if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) {
TMDO_HivePress_Cron_Optimizer::register(); TMDO_HivePress_Cron_Optimizer::register();
} }
@@ -1,166 +0,0 @@
<?php
/**
* Comment query router for HivePress comment-type models.
*
* Rewrites `WP_Comment_Query` SQL clauses to JOIN the per-addon shadow tables
* built by Sprint 2 adapters. Currently handles four `comment_type` values:
*
* hp_message → wp_wpdo_comment_hp_message (recipient_id, is_read indexed)
* hp_favorite → wp_wpdo_comment_hp_favorite (UNIQUE user_id+listing_id)
* hp_offer → wp_wpdo_comment_hp_offer (request_id, approved indexed)
* hp_review → wp_wpdo_comment_hp_review (managed by entity-bridge)
*
* Disabled by default. Operator opts in via:
*
* wp option update wpdo_hivepress_comment_router_enabled 1
*
* Once enabled, comment queries with `type` matching one of the above gain a
* shadow-table LEFT JOIN at `comments_clauses` filter time. The JOIN is
* deliberately LEFT so missing shadow rows (during dual_write phase before
* backfill completes) don't drop legitimate comments.
*
* Per Karpathy guideline: this router only handles the four comment_types
* Sprint 2 actually needs — no extension hooks for "future addons".
*
* @package WP_Data_Optimizer
* @since 3.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'TMDO_HivePress_Comment_Router' ) ) {
/**
* Comment query rewriter.
*
* Single-instance, registered via static `register()`. Stateless apart from
* the option-cached enabled flag.
*/
final class TMDO_HivePress_Comment_Router {
/** Option key controlling the rewriter. */
public const OPTION_ENABLED = 'wpdo_hivepress_comment_router_enabled';
/**
* Map of comment_type → shadow table (without wpdb prefix).
*
* @var array<string,string>
*/
private const SHADOW_MAP = array(
'hp_message' => 'wpdo_comment_hp_message',
'hp_favorite' => 'wpdo_comment_hp_favorite',
'hp_offer' => 'wpdo_comment_hp_offer',
'hp_review' => 'wpdo_comment_hp_review',
);
/**
* Whether the router is bound this request.
*
* @var bool
*/
private static bool $bound = false;
/**
* Bind the comments_clauses filter (idempotent).
*
* Bootstrap calls this from each adapter's on_register_query_hooks()
* so router activation follows the same gate as adapter binding.
*/
public static function register(): void {
if ( self::$bound ) {
return;
}
if ( ! self::is_enabled() ) {
return;
}
add_filter( 'comments_clauses', array( __CLASS__, 'rewrite' ), 20, 2 );
self::$bound = true;
}
/**
* Reset internal state (test only).
*
* @internal
*/
public static function reset_for_tests(): void {
self::$bound = false;
}
/**
* Whether router is enabled via the operator option.
*/
public static function is_enabled(): bool {
if ( ! function_exists( 'get_option' ) ) {
return false;
}
return (bool) (int) get_option( self::OPTION_ENABLED, 0 );
}
/**
* Filter callback: rewrite comments_clauses for known hp comment types.
*
* @param array $clauses SQL clauses (join, where, fields, ...).
* @param \WP_Comment_Query $query The query object.
* @return array Possibly-modified clauses.
*/
public static function rewrite( array $clauses, $query ): array {
$type = self::query_comment_type( $query );
if ( '' === $type || ! isset( self::SHADOW_MAP[ $type ] ) ) {
return $clauses;
}
global $wpdb;
$shadow = $wpdb->prefix . self::SHADOW_MAP[ $type ];
$alias = 'wpdo_shadow';
// LEFT JOIN keeps row visibility when shadow lags (during backfill).
$join = ( $clauses['join'] ?? '' ) . " LEFT JOIN `{$shadow}` AS {$alias} ON {$alias}.comment_id = {$wpdb->comments}.comment_ID";
// Filter pushdown: hp_message recipient lookup uses comment_karma in HP.
// We rewrite WHERE clauses that filter by comment_karma (= recipient hack)
// to use the indexed shadow column.
$where = (string) ( $clauses['where'] ?? '' );
if ( 'hp_message' === $type && '' !== $where ) {
$where = preg_replace(
'/' . preg_quote( $wpdb->comments, '/' ) . '\.comment_karma\s*=\s*(\d+)/',
$alias . '.recipient_id = $1',
$where
);
}
$clauses['join'] = $join;
$clauses['where'] = $where;
/**
* Filter: wpdo_hivepress_comment_router_clauses
*
* Allows fine-grained tweaks per comment_type. Intended for adapter
* tests + future custom-column lookups.
*
* @param array $clauses Modified clauses.
* @param string $type Matched comment_type.
* @param string $alias Shadow table alias.
*/
return apply_filters( 'wpdo_hivepress_comment_router_clauses', $clauses, $type, $alias );
}
/**
* Extract the comment_type from a WP_Comment_Query.
*
* @param \WP_Comment_Query $query Query object.
*/
private static function query_comment_type( $query ): string {
if ( ! is_object( $query ) || ! isset( $query->query_vars ) ) {
return '';
}
$type = $query->query_vars['type'] ?? '';
if ( is_array( $type ) ) {
$type = reset( $type );
}
return is_string( $type ) ? $type : '';
}
}
} // end if ( ! class_exists )
@@ -10,9 +10,8 @@
* *
* Strategy: detect, log, render admin notice with guidance, AND short-circuit * Strategy: detect, log, render admin notice with guidance, AND short-circuit
* the WPDO HivePress bootstrap via `wpdo_hivepress_should_bind` filter so * the WPDO HivePress bootstrap via `wpdo_hivepress_should_bind` filter so
* adapters are NOT bound. Operator must explicitly run * adapters are NOT bound. The operator deactivates the legacy plugins to let
* `wp wpdo hivepress migrate-from-hpct` to switch to WPDO and deactivate * WPDO take over; data kept in their own tables is not imported.
* the legacy plugins.
* *
* @package WP_Data_Optimizer * @package WP_Data_Optimizer
* @since 3.0.0 * @since 3.0.0
@@ -163,7 +162,7 @@ if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
$message = sprintf( $message = sprintf(
/* translators: %s: comma-separated list of conflicting plugin names. */ /* translators: %s: comma-separated list of conflicting plugin names. */
__( 'wp-data-optimizer detected legacy HivePress data-layer plugins: %s. WPDO HivePress integration is paused to prevent dual-write corruption. Run `wp wpdo hivepress migrate-from-hpct` to migrate, then deactivate the legacy plugins.', 'tmdo-hivepress' ), __( 'wp-data-optimizer detected legacy HivePress data-layer plugins: %s. WPDO HivePress integration is paused to prevent dual-write corruption. Deactivate the legacy plugins to let WPDO take over (data in their own tables is not imported).', 'tmdo-hivepress' ),
implode( ', ', $names ) implode( ', ', $names )
); );
@@ -206,7 +205,7 @@ if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
} }
$message = sprintf( $message = sprintf(
'Conflict detected: %s. wp-data-optimizer fully supersedes these. Run: wp wpdo hivepress migrate-from-hpct', 'Conflict detected: %s. wp-data-optimizer fully supersedes these. Deactivate them to let WPDO take over.',
implode( ', ', $conflicts ) implode( ', ', $conflicts )
); );
@@ -119,7 +119,7 @@ if ( ! interface_exists( 'TMDO_HivePress_Adapter' ) ) {
* *
* Adapters with hot zone fields used in search/filter MUST register a * Adapters with hot zone fields used in search/filter MUST register a
* `pre_get_posts` / `pre_get_comments` filter that defers to * `pre_get_posts` / `pre_get_comments` filter that defers to
* `TMDO_HivePress_Query_Router` / `..._Comment_Router`. * `TMDO_HivePress_Query_Router`.
* *
* Called on `init:5` after Schema_Registry is fully populated. * Called on `init:5` after Schema_Registry is fully populated.
*/ */
@@ -1,117 +0,0 @@
<?php
/**
* Favorites interceptor for HivePress user favorites.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Favorites interceptor — syncs HivePress user favorites to hpct_favorites table.
*
* Special: operates on user_meta (hp_favorited_listings), not post_meta.
*/
class TMDO_Favorites_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'favorites';
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_filter( 'update_user_metadata', array( $this, 'filter_update_user_meta' ), 10, 5 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Filters update_user_metadata to sync hp_favorited_listings.
*
* @param mixed $check Whether to short-circuit.
* @param int $user_id User 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_user_meta( $check, int $user_id, string $meta_key, $meta_value, $prev_value ) {
if ( 'hp_favorited_listings' !== $meta_key || ! $this->is_active() ) {
return $check;
}
try {
$this->sync_favorites( $user_id, $meta_value );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_user_metadata', $e->getMessage() );
}
return $check;
}
/**
* Deletes favorites records when a listing 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_favorites' ), array( 'listing_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Syncs user favorites to the hpct_favorites table.
*
* @param int $user_id User ID.
* @param mixed $meta_value Favorites meta value (array or serialized).
* @return void
*/
private function sync_favorites( int $user_id, $meta_value ): void {
global $wpdb;
$table = TMDO_DB::table( 'hpct_favorites' );
$now = TMDO_DB::now();
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
// Input is user-controlled wp_usermeta value via update_user_meta hook.
$listing_ids = is_array( $meta_value )
? $meta_value
: (array) TMDO_Safe_Unserialize::run( $meta_value );
$listing_ids = array_filter( array_map( 'absint', $listing_ids ) );
// Delete all existing and re-insert.
$wpdb->delete( $table, array( 'user_id' => $user_id ), array( '%d' ) );
$insert_sql = TMDO_IS_SQLITE ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- insert_sql is 'INSERT IGNORE'/'INSERT OR IGNORE'; table name from TMDO_DB::table().
foreach ( $listing_ids as $listing_id ) {
$wpdb->query(
$wpdb->prepare(
"{$insert_sql} INTO `{$table}` (user_id, listing_id, created_at) VALUES (%d, %d, %s)",
$user_id,
$listing_id,
$now
)
);
}
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
@@ -1,339 +0,0 @@
<?php
/**
* Listing Meta interceptor for hp_listing post type.
*
* Intercepts get/update_post_metadata for hp_listing posts.
* Operates on hpct_listing_meta KV table. Only intercepts meta_keys
* prefixed with hp_ or _hp_.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Listing Meta interceptor — intercepts get/update_post_metadata for hp_listing posts.
*
* Operates on hpct_listing_meta KV table. Only intercepts meta_keys
* prefixed with hp_ or _hp_.
*/
class TMDO_Listing_Meta_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'listing_meta';
/**
* Registers WordPress hooks for this interceptor.
*
* Skips registration when the underlying `hpct_listing_meta` KV table is
* missing — this prevents cascading DB errors when WPDO is installed
* without HPCT first having been imported. Solves audit finding R-2.
*
* @return void
*/
public function register_hooks(): void {
if ( ! self::table_exists() ) {
// HPCT is not installed at all → don't even log (clean doctor output).
// We only log when HPCT *is* loaded but its table is missing — that's a
// genuine inconsistency worth surfacing to admins.
if ( class_exists( 'HPCT_Core' ) ) {
self::log_skip_once();
}
return;
}
add_filter( 'get_post_metadata', array( $this, 'filter_get_meta' ), 10, 4 );
add_filter( 'update_post_metadata', array( $this, 'filter_update_meta' ), 10, 5 );
add_filter( 'add_post_metadata', array( $this, 'filter_add_meta' ), 10, 5 );
add_filter( 'delete_post_metadata', array( $this, 'filter_delete_meta' ), 10, 5 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Cached check for the existence of the hpct_listing_meta table.
*
* Result is request-cached to avoid repeated SHOW TABLES calls.
*
* @return bool True when the table exists.
*/
private static function table_exists(): bool {
static $exists = null;
if ( null !== $exists ) {
return $exists;
}
global $wpdb;
$table = TMDO_DB::table( 'hpct_listing_meta' );
// SHOW TABLES LIKE returns the table name when present, or NULL when absent.
$found = $wpdb->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' )
);
}
}
@@ -1,76 +0,0 @@
<?php
/**
* Memberships interceptor for HivePress Membership posts.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Memberships interceptor — intercepts HivePress Membership reads/writes.
*
* Operates on hpct_memberships table.
*/
class TMDO_Memberships_Interceptor extends TMDO_Standard_Post_Interceptor {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'memberships';
/**
* Meta key → flat column mapping.
*/
public const FIELD_MAP = array(
'hp_plan' => 'plan_id',
'hp_user' => 'user_id',
'hp_price' => 'price',
'hp_order' => 'order_id',
'hp_start_date' => 'start_date',
'hp_end_date' => 'end_date',
);
/**
* Returns the WordPress post_type slug handled by this interceptor.
*
* @return string
*/
protected function get_post_type(): string {
return 'hp_membership';
}
/**
* Returns the table key passed to TMDO_DB::table().
*
* @return string
*/
protected function get_table_key(): string {
return 'hpct_memberships';
}
/**
* Builds the INSERT row for a newly created membership post.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param string $now Formatted datetime string.
* @return array{values: array, formats: array}
*/
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
return array(
'values' => array(
'post_id' => $post_id,
'user_id' => (int) $post->post_author,
'status' => $post->post_status,
'created_at' => $now,
'updated_at' => $now,
),
'formats' => array( '%d', '%d', '%s', '%s', '%s' ),
);
}
}
@@ -1,78 +0,0 @@
<?php
/**
* Messages interceptor for HivePress Message posts.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Messages interceptor — intercepts HivePress Message reads/writes.
*
* Operates on hpct_messages table.
*/
class TMDO_Messages_Interceptor extends TMDO_Standard_Post_Interceptor {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'messages';
/**
* Meta key → flat column mapping.
*/
public const FIELD_MAP = array(
'hp_sender' => 'sender_id',
'hp_recipient' => 'recipient_id',
'hp_listing' => 'listing_id',
'hp_read' => 'is_read',
);
/**
* Returns the WordPress post_type slug handled by this interceptor.
*
* @return string
*/
protected function get_post_type(): string {
return 'hp_message';
}
/**
* Returns the table key passed to TMDO_DB::table().
*
* @return string
*/
protected function get_table_key(): string {
return 'hpct_messages';
}
/**
* Builds the INSERT row for a newly created message post.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param string $now Formatted datetime string.
* @return array{values: array, formats: array}
*/
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
return array(
'values' => array(
'post_id' => $post_id,
'thread_id' => (int) $post->post_parent,
'sender_id' => (int) $post->post_author,
'subject' => sanitize_text_field( $post->post_title ),
'body' => $post->post_content,
'status' => $post->post_status,
'sent_at' => $now,
'created_at' => $now,
'updated_at' => $now,
),
'formats' => array( '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s' ),
);
}
}
@@ -1,74 +0,0 @@
<?php
/**
* Requests interceptor for HivePress Request posts.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Requests interceptor — intercepts HivePress Request reads/writes.
*
* Operates on hpct_requests table.
*/
class TMDO_Requests_Interceptor extends TMDO_Standard_Post_Interceptor {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'requests';
/**
* Meta key → flat column mapping.
*/
public const FIELD_MAP = array(
'hp_vendor' => 'vendor_id',
'hp_listing' => 'listing_id',
'hp_budget' => 'budget',
);
/**
* Returns the WordPress post_type slug handled by this interceptor.
*
* @return string
*/
protected function get_post_type(): string {
return 'hp_request';
}
/**
* Returns the table key passed to TMDO_DB::table().
*
* @return string
*/
protected function get_table_key(): string {
return 'hpct_requests';
}
/**
* Builds the INSERT row for a newly created request post.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param string $now Formatted datetime string.
* @return array{values: array, formats: array}
*/
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
return array(
'values' => array(
'post_id' => $post_id,
'user_id' => (int) $post->post_author,
'status' => $post->post_status,
'message' => $post->post_content,
'created_at' => $now,
'updated_at' => $now,
),
'formats' => array( '%d', '%d', '%s', '%s', '%s', '%s' ),
);
}
}
@@ -1,116 +0,0 @@
<?php
/**
* Reviews interceptor for HivePress Review posts.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Reviews interceptor — intercepts HivePress Review reads/writes.
*
* Operates on hpct_reviews table.
*/
class TMDO_Reviews_Interceptor extends TMDO_Standard_Post_Interceptor {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'reviews';
/**
* Meta key → flat column mapping.
*/
public const FIELD_MAP = array(
'hp_rating' => 'rating',
'hp_listing' => 'listing_id',
'_hp_vendor' => 'vendor_id',
);
/**
* Returns the WordPress post_type slug handled by this interceptor.
*
* @return string
*/
protected function get_post_type(): string {
return 'hp_review';
}
/**
* Returns the table key passed to TMDO_DB::table().
*
* @return string
*/
protected function get_table_key(): string {
return 'hpct_reviews';
}
/**
* Registers WordPress hooks, including the extra get_post_metadata hook.
*
* @return void
*/
public function register_hooks(): void {
parent::register_hooks();
add_filter( 'get_post_metadata', array( $this, 'filter_get_meta' ), 10, 4 );
}
/**
* Builds the INSERT row for a newly created review post.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param string $now Formatted datetime string.
* @return array{values: array, formats: array}
*/
protected function build_insert_data( int $post_id, \WP_Post $post, string $now ): array {
return array(
'values' => array(
'post_id' => $post_id,
'user_id' => (int) $post->post_author,
'status' => $post->post_status,
'title' => sanitize_text_field( $post->post_title ),
'content' => $post->post_content,
'created_at' => $now,
'updated_at' => $now,
),
'formats' => array( '%d', '%d', '%s', '%s', '%s', '%s', '%s' ),
);
}
/**
* Reads hp_rating directly from the reviews flat table.
*
* @param mixed $value Short-circuit value.
* @param int $post_id Post ID.
* @param string $meta_key Meta key.
* @param bool $single Whether to return a single value.
* @return mixed
*/
public function filter_get_meta( $value, int $post_id, string $meta_key, bool $single ) {
if ( ! $this->is_enabled() || 'hp_rating' !== $meta_key || 'hp_review' !== get_post_type( $post_id ) ) {
return $value;
}
return $this->intercept(
function () use ( $post_id, $single ) {
global $wpdb;
$rating = $wpdb->get_var(
$wpdb->prepare(
'SELECT rating FROM ' . TMDO_DB::table( 'hpct_reviews' ) . ' WHERE post_id = %d LIMIT 1',
$post_id
)
);
return null !== $rating ? ( $single ? $rating : array( $rating ) ) : null;
},
fn() => $value,
'get_post_metadata'
);
}
}
@@ -1,139 +0,0 @@
<?php
/**
* Statistics interceptor for listing view tracking.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Statistics interceptor — tracks listing view events in hpct_statistics.
*
* Special: append-only (INSERT only, no UPDATE). Records events from
* both _hp_views meta update and HivePress listing/viewed action.
*/
class TMDO_Statistics_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'statistics';
/**
* Registers WordPress hooks for this interceptor.
*
* @return void
*/
public function register_hooks(): void {
add_filter( 'update_post_metadata', array( $this, 'filter_view_meta' ), 10, 5 );
add_action( 'hivepress/v1/models/listing/viewed', array( $this, 'action_listing_viewed' ), 10, 1 );
add_action( 'before_delete_post', array( $this, 'action_delete_post' ), 10, 2 );
}
/**
* Filters update_post_metadata to record a view event when _hp_views is updated.
*
* @param mixed $check Whether to short-circuit.
* @param int $post_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_view_meta( $check, int $post_id, string $meta_key, $meta_value, $prev_value ) {
if ( '_hp_views' !== $meta_key || 'hp_listing' !== get_post_type( $post_id ) ) {
return $check;
}
if ( ! $this->is_active() ) {
return $check;
}
try {
$this->record_event( $post_id, 'view' );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'update_post_metadata', $e->getMessage() );
}
return $check;
}
/**
* Records a view event when a HivePress listing is viewed.
*
* @param mixed $listing Listing object or ID.
* @return void
*/
public function action_listing_viewed( $listing ): void {
if ( ! $this->is_active() ) {
return;
}
$listing_id = is_object( $listing ) && method_exists( $listing, 'get_id' )
? $listing->get_id()
: ( is_numeric( $listing ) ? (int) $listing : 0 );
if ( ! $listing_id ) {
return;
}
try {
$this->record_event( $listing_id, 'hp_action_view' );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'listing_viewed', $e->getMessage() );
}
}
/**
* Deletes statistics records when a listing 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_statistics' ), array( 'listing_id' => $post_id ), array( '%d' ) );
} catch ( \Throwable $e ) {
TMDO_Logger::error( $this->module, 'before_delete_post', $e->getMessage() );
}
}
/**
* Records a statistics event to the hpct_statistics table.
*
* @param int $listing_id Listing post ID.
* @param string $event_type Event type identifier.
* @return void
*/
private function record_event( int $listing_id, string $event_type ): void {
global $wpdb;
$user_id = get_current_user_id();
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );
$ip_hash = $ip ? hash( 'sha256', $ip ) : '';
$wpdb->insert(
TMDO_DB::table( 'hpct_statistics' ),
array(
'listing_id' => $listing_id,
'event_type' => $event_type,
'user_id' => $user_id,
'ip_hash' => $ip_hash,
'referrer' => substr( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ) ), 0, 500 ),
'created_at' => TMDO_DB::now(),
),
array( '%d', '%s', '%d', '%s', '%s', '%s' )
);
}
}
@@ -1,236 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_listing meta queries.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_listing meta queries.
*
* The hpct_listing_meta is a key-value table (like wp_postmeta), so each
* meta_query condition needs its own JOIN alias with a meta_key condition:
*
* LEFT JOIN wp_hpct_listing_meta AS wpdo_lm_0
* ON (wp_posts.ID = wpdo_lm_0.listing_id AND wpdo_lm_0.meta_key = 'hp_price')
* WHERE wpdo_lm_0.meta_value >= 100
*
* All hp_* and _hp_* meta keys are intercepted. Any other meta keys are
* left in the meta_query to be handled by wp_postmeta as usual.
*/
class TMDO_Listing_Meta_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'listing_meta';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_listing' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_listing_meta';
}
/**
* Returns the join column for this KV table.
*
* @return string Join column name.
*/
protected function get_join_column(): string {
return 'listing_id';
}
/**
* Not used — this class overrides pre_get_posts directly.
*
* @return array Empty array.
*/
protected function get_meta_key_map(): array {
return array();
}
// ── Override pre_get_posts for KV-table pattern ───────────────────────
/**
* Intercepts pre_get_posts to handle KV-table meta_query conditions.
*
* @param \WP_Query $query The WP_Query object.
* @return void
*/
public function pre_get_posts( \WP_Query $query ): void {
if ( ! $this->should_intercept( $query ) ) {
return;
}
$raw_meta_query = (array) $query->get( 'meta_query' );
if ( empty( $raw_meta_query ) ) {
return;
}
$our_clauses = array();
$remaining = array();
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
$remaining[ $k ] = $clause;
continue;
}
if ( $this->is_hp_key( $clause['key'] ) ) {
$our_clauses[] = array(
'meta_key' => $clause['key'],
'value' => $clause['value'] ?? '',
'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
);
} else {
$remaining[ $k ] = $clause;
}
}
if ( empty( $our_clauses ) ) {
return;
}
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
$remaining['relation'] = $raw_meta_query['relation'];
}
$query->set( 'meta_query', $remaining );
$query->set( $this->query_var(), $our_clauses );
}
// ── Override posts_join for KV-table: one JOIN alias per condition ─────
/**
* Appends LEFT JOINs for each KV-table meta_query condition.
*
* @param string $join Current JOIN SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified JOIN SQL.
*/
public function posts_join( ?string $join, \WP_Query $query ): string {
$join = (string) ( $join ?? '' );
$clauses = $this->get_clauses( $query );
if ( empty( $clauses ) ) {
return $join;
}
global $wpdb;
$table = TMDO_DB::table( $this->get_table() );
$join_col = esc_sql( $this->get_join_column() );
foreach ( $clauses as $i => $clause ) {
$alias = $this->alias_for( $i );
$meta_key = $clause['meta_key'];
if ( false === strpos( $join, "`{$alias}`" ) ) {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table from TMDO_DB::table(); $alias and $join_col are sanitize_key()-validated.
$join .= $wpdb->prepare(
" LEFT JOIN `{$table}` AS `{$alias}`"
. " ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`{$join_col}`"
. " AND `{$alias}`.`meta_key` = %s)",
$meta_key
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
return $join;
}
// ── Override posts_where for KV-table ─────────────────────────────────
/**
* Appends WHERE conditions for KV-table meta_value comparisons.
*
* @param string $where Current WHERE SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified WHERE SQL.
*/
public function posts_where( ?string $where, \WP_Query $query ): string {
$where = (string) ( $where ?? '' );
$clauses = $this->get_clauses( $query );
if ( empty( $clauses ) ) {
return $where;
}
foreach ( $clauses as $i => $clause ) {
$alias = $this->alias_for( $i );
$col = "`{$alias}`.`meta_value`";
$compare = $this->sanitize_compare( $clause['compare'] );
$type = $clause['type'];
$value = $clause['value'];
$where .= $this->build_condition( $col, $compare, $type, $value );
}
return $where;
}
// ── Override posts_groupby ─────────────────────────────────────────────
/**
* Ensures GROUP BY is set when KV-table clauses are active.
*
* @param string $groupby Current GROUP BY SQL.
* @param \WP_Query $query The WP_Query object.
* @return string Modified GROUP BY SQL.
*/
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
$clauses = $this->get_clauses( $query );
if ( empty( $clauses ) ) {
return $groupby;
}
global $wpdb;
if ( '' === trim( $groupby ) ) {
$groupby = "`{$wpdb->posts}`.`ID`";
}
return $groupby;
}
// ── Helpers ───────────────────────────────────────────────────────────
/**
* Generates a unique SQL alias for a KV-table JOIN at a given index.
*
* @param int $index Zero-based clause index.
* @return string SQL alias string.
*/
private function alias_for( int $index ): string {
return "wpdo_lm_{$index}";
}
/**
* Checks whether a meta key belongs to HivePress (hp_ or _hp_ prefix).
*
* @param string $meta_key Meta key to check.
* @return bool True if the key is an HP key.
*/
private function is_hp_key( string $meta_key ): bool {
return str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' );
}
}
@@ -1,67 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_membership post type.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_membership post type.
*
* Intercepted meta keys:
* hp_plan => plan_id (BIGINT)
* hp_user => user_id (BIGINT)
* hp_price => price (VARCHAR)
* hp_order => order_id (BIGINT)
* hp_start_date => start_date (DATETIME)
* hp_end_date => end_date (DATETIME)
*/
class TMDO_Memberships_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'memberships';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_membership' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_memberships';
}
/**
* Returns the meta_key to column mapping for hpct_memberships.
*
* @return array<string, string> Meta key to column name map.
*/
protected function get_meta_key_map(): array {
return array(
'hp_plan' => 'plan_id',
'hp_user' => 'user_id',
'hp_price' => 'price',
'hp_order' => 'order_id',
'hp_start_date' => 'start_date',
'hp_end_date' => 'end_date',
);
}
}
@@ -1,63 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_message post type.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_message post type.
*
* Intercepted meta keys:
* hp_sender => sender_id (BIGINT)
* hp_recipient => recipient_id (BIGINT)
* hp_listing => listing_id (BIGINT)
* hp_read => is_read (TINYINT)
*/
class TMDO_Messages_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'messages';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_message' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_messages';
}
/**
* Returns the meta_key to column mapping for hpct_messages.
*
* @return array<string, string> Meta key to column name map.
*/
protected function get_meta_key_map(): array {
return array(
'hp_sender' => 'sender_id',
'hp_recipient' => 'recipient_id',
'hp_listing' => 'listing_id',
'hp_read' => 'is_read',
);
}
}
@@ -1,61 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_request post type.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_request post type.
*
* Intercepted meta keys:
* hp_vendor => vendor_id (BIGINT)
* hp_listing => listing_id (BIGINT)
* hp_budget => budget (VARCHAR)
*/
class TMDO_Requests_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'requests';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_request' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_requests';
}
/**
* Returns the meta_key to column mapping for hpct_requests.
*
* @return array<string, string> Meta key to column name map.
*/
protected function get_meta_key_map(): array {
return array(
'hp_vendor' => 'vendor_id',
'hp_listing' => 'listing_id',
'hp_budget' => 'budget',
);
}
}
@@ -1,64 +0,0 @@
<?php
/**
* WP_Query interceptor for hp_review post type.
*
* @package WP_Data_Optimizer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WP_Query interceptor for hp_review post type.
*
* Rewrites meta_query conditions for known review meta keys to query
* hpct_reviews directly, eliminating wp_postmeta JOIN for review queries.
*
* Intercepted meta keys:
* hp_listing => listing_id (BIGINT)
* _hp_vendor => vendor_id (BIGINT)
* hp_rating => rating (TINYINT)
*/
class TMDO_Reviews_Query extends TMDO_Query_Interceptor_Base {
/**
* Returns the module identifier.
*
* @return string Module name.
*/
protected function get_module(): string {
return 'reviews';
}
/**
* Returns the post types this interceptor handles.
*
* @return string[] Post type slugs.
*/
protected function get_post_types(): array {
return array( 'hp_review' );
}
/**
* Returns the custom table name.
*
* @return string Table name without prefix.
*/
protected function get_table(): string {
return 'hpct_reviews';
}
/**
* Returns the meta_key to column mapping for hpct_reviews.
*
* @return array<string, string> Meta key to column name map.
*/
protected function get_meta_key_map(): array {
return array(
'hp_listing' => 'listing_id',
'_hp_vendor' => 'vendor_id',
'hp_rating' => 'rating',
);
}
}
+1 -2
View File
@@ -38,7 +38,6 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-detector.php', 'includes/hivepress/class-tmdo-hivepress-detector.php',
'includes/hivepress/class-tmdo-hivepress-conflict-guard.php', 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php',
'includes/hivepress/class-tmdo-hivepress-bootstrap.php', 'includes/hivepress/class-tmdo-hivepress-bootstrap.php',
'includes/hivepress/class-tmdo-hivepress-comment-router.php',
'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php', 'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php',
'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php', 'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php',
'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php', 'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php',
@@ -46,7 +45,7 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-rest.php', 'includes/hivepress/class-tmdo-hivepress-rest.php',
'includes/class-tmdo-hivepress-transient-filter.php', 'includes/class-tmdo-hivepress-transient-filter.php',
'includes/class-tmdo-hivepress-term-comment-fields.php', 'includes/class-tmdo-hivepress-term-comment-fields.php',
'includes/class-tmdo-hpct-import.php', 'includes/class-tmdo-hivepress-entity-groups.php',
'includes/class-tmdo-listing-stats.php', 'includes/class-tmdo-listing-stats.php',
'admin/class-tmdo-admin-hivepress.php', 'admin/class-tmdo-admin-hivepress.php',
'cli/class-tmdo-cli-hivepress.php', 'cli/class-tmdo-cli-hivepress.php',
@@ -12,147 +12,17 @@ use PHPUnit\Framework\TestCase;
* Exercises against real MariaDB (via tests/integration/bootstrap.php) the * Exercises against real MariaDB (via tests/integration/bootstrap.php) the
* scenarios that unit tests can only stub: * scenarios that unit tests can only stub:
* *
* - Shadow table CREATE actually works with the declared expected_columns
* - UNIQUE constraint on (user_id, listing_id) really prevents dup favorites
* - mirror_insert() roundtrips through real $wpdb (SQL syntax valid)
* - 7-state FSM lifecycle moves WPDO_Feature_Flags correctly * - 7-state FSM lifecycle moves WPDO_Feature_Flags correctly
* *
* @group integration * @group integration
*/ */
class HivepressIntegrationTest extends TestCase { class HivepressIntegrationTest extends TestCase {
/** Test-scope shadow table for favorites. */
private const TABLE_FAVORITE = 'wp_itest_wpdo_comment_hp_favorite';
/** Test-scope shadow table for messages. */
private const TABLE_MESSAGE = 'wp_itest_wpdo_comment_hp_message';
public static function setUpBeforeClass(): void {
// HivePress integration files are loaded at file-top level so the
// Testable* subclass definitions below can resolve their parents at
// parse time. Schema setup happens here.
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' );
$wpdb->query( "CREATE TABLE `" . self::TABLE_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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' );
$wpdb->query( "CREATE TABLE `" . self::TABLE_MESSAGE . "` (
comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
sender_id BIGINT UNSIGNED NOT NULL,
recipient_id BIGINT UNSIGNED NOT NULL,
listing_id BIGINT UNSIGNED NOT NULL,
is_read TINYINT(1) NOT NULL DEFAULT 0,
sent_at DATETIME NOT NULL,
KEY idx_recipient_unread (recipient_id, is_read),
KEY idx_thread (listing_id, sent_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" );
}
public static function tearDownAfterClass(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_FAVORITE . '`' );
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE_MESSAGE . '`' );
}
protected function setUp(): void { protected function setUp(): void {
global $wpdb;
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_FAVORITE . '`' );
$wpdb->query( 'TRUNCATE TABLE `' . self::TABLE_MESSAGE . '`' );
// Clear FSM module state between tests. // Clear FSM module state between tests.
$GLOBALS['_wp_options']['wpdo_features'] = array(); $GLOBALS['_wp_options']['wpdo_features'] = array();
} }
// ── Schema tests ────────────────────────────────────────────────────────
public function test_favorite_table_has_unique_user_listing_constraint(): void {
global $wpdb;
$row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_FAVORITE . '`', 'ARRAY_A' );
$this->assertNotNull( $row );
$ddl = $row['Create Table'] ?? '';
$this->assertStringContainsString( 'UNIQUE KEY', $ddl );
$this->assertStringContainsString( 'unique_user_listing', $ddl );
}
public function test_message_table_has_recipient_unread_index(): void {
global $wpdb;
$row = $wpdb->get_row( 'SHOW CREATE TABLE `' . self::TABLE_MESSAGE . '`', 'ARRAY_A' );
$this->assertNotNull( $row );
$ddl = $row['Create Table'] ?? '';
$this->assertStringContainsString( 'idx_recipient_unread', $ddl );
$this->assertStringContainsString( 'recipient_id', $ddl );
}
// ── mirror_insert real roundtrip ────────────────────────────────────────
public function test_favorite_mirror_insert_writes_real_row(): void {
global $wpdb;
$adapter = new WPDO_HivePress_Favorites_Adapter();
$comment = new stdClass();
$comment->comment_type = 'hp_favorite';
$comment->user_id = 11;
$comment->comment_post_ID = 222;
$comment->comment_date = '2026-05-04 12:00:00';
$adapter->mirror_insert( 1001, $comment );
$rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '`' );
$this->assertSame( 1, $rows );
$saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_FAVORITE . '` WHERE comment_id = 1001', 'ARRAY_A' );
$this->assertSame( '11', (string) $saved['user_id'] );
$this->assertSame( '222', (string) $saved['listing_id'] );
}
public function test_favorite_unique_constraint_blocks_double_tap(): void {
global $wpdb;
$adapter = new WPDO_HivePress_Favorites_Adapter();
// Two different comment_ids but same user+listing — UNIQUE should win.
$comment = new stdClass();
$comment->comment_type = 'hp_favorite';
$comment->user_id = 7;
$comment->comment_post_ID = 99;
$comment->comment_date = '2026-05-04 12:00:00';
$adapter->mirror_insert( 2001, $comment );
$adapter->mirror_insert( 2002, $comment ); // Race-style duplicate.
$rows = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_FAVORITE . '` WHERE user_id = 7 AND listing_id = 99' );
$this->assertSame( 1, $rows, 'UNIQUE(user_id, listing_id) must reject duplicate.' );
}
public function test_message_mirror_insert_promotes_recipient_id(): void {
global $wpdb;
$adapter = new WPDO_HivePress_Messages_Adapter();
$comment = new stdClass();
$comment->comment_type = 'hp_message';
$comment->user_id = 5; // sender
$comment->comment_karma = 42; // recipient (HP hack)
$comment->comment_post_ID = 99;
$comment->comment_approved = 0;
$comment->comment_date = '2026-05-04 12:00:00';
$adapter->mirror_insert( 3001, $comment );
$saved = $wpdb->get_row( 'SELECT * FROM `' . self::TABLE_MESSAGE . '` WHERE comment_id = 3001', 'ARRAY_A' );
$this->assertNotNull( $saved );
$this->assertSame( '42', (string) $saved['recipient_id'] );
$this->assertSame( '5', (string) $saved['sender_id'] );
// recipient_unread index supports the dashboard query.
$unread_count = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM `' . self::TABLE_MESSAGE . '` WHERE recipient_id = 42 AND is_read = 0' );
$this->assertSame( 1, $unread_count );
}
// ── FSM lifecycle ─────────────────────────────────────────────────────── // ── FSM lifecycle ───────────────────────────────────────────────────────
public function test_fsm_lifecycle_progression(): void { public function test_fsm_lifecycle_progression(): void {
@@ -189,9 +59,3 @@ class HivepressIntegrationTest extends TestCase {
$this->assertSame( 0, $count, 'Optimizer must short-circuit when module is idle.' ); $this->assertSame( 0, $count, 'Optimizer must short-circuit when module is idle.' );
} }
} }
// Production adapters use `$wpdb->prefix . self::TABLE` to compose the full
// table name. Integration test bootstrap sets prefix to `wp_itest_`, so the
// production mirror_insert() naturally writes to wp_itest_wpdo_comment_hp_*.
// No subclassing or test-double needed — the production code path is exercised
// directly against real MariaDB with isolated prefix.
@@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Integration tests for WPDO_Listing_Meta_Interceptor — PR-3 R-2 + P-C1 fixes.
*
* Verifies:
* 1. R-2: Interceptor SKIPS hook registration when hpct_listing_meta is missing
* (prevents the "wp_hpct_listing_meta doesn't exist" cascading errors that
* forced 2meet-infocards to bypass the interceptor in production).
* 2. P-C1: upsert_meta() uses 1 SQL round-trip via WPDO_DB::upsert().
*
* @covers WPDO_Listing_Meta_Interceptor
*/
class ListingMetaInterceptorTest extends TestCase {
/**
* R-2: When hpct_listing_meta table is missing, register_hooks() must skip
* gracefully and write a single explanatory error log entry.
*
* @runInSeparateProcess
*/
public function test_register_hooks_skips_when_table_missing(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}hpct_listing_meta`" );
// Simulate HPCT_Core being present — register_hooks() only logs the skip
// when HPCT is loaded but its table is missing (genuine inconsistency).
if ( ! class_exists( 'HPCT_Core' ) ) {
// phpcs:ignore Generic.Commenting.InlineComment.InvalidEndChar -- runtime stub
eval( 'class HPCT_Core {}' ); // @phpcs:ignore
}
// Ensure wpdo_errors exists for the skip log; harmless if pre-created.
$wpdb->query(
"CREATE TABLE IF NOT EXISTS `{$wpdb->prefix}wpdo_errors` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
module varchar(50) NOT NULL DEFAULT '',
zone varchar(10) NOT NULL DEFAULT '',
hook varchar(255) NOT NULL DEFAULT '',
message longtext NOT NULL,
context longtext,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY idx_module (module)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Snapshot pre-call count of skip messages.
$pre = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_errors` WHERE module = %s AND hook = %s",
'listing_meta',
'register_hooks'
)
);
$interceptor = new WPDO_Listing_Meta_Interceptor();
$interceptor->register_hooks();
$post = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM `{$wpdb->prefix}wpdo_errors` WHERE module = %s AND hook = %s",
'listing_meta',
'register_hooks'
)
);
$this->assertSame( $pre + 1, $post, 'Expected exactly one new skip message after register_hooks()' );
}
/**
* P-C1: WPDO_DB::upsert() must produce an INSERT ... ON DUPLICATE KEY UPDATE
* statement (single round-trip), and writes must succeed via the composite
* unique key (listing_id, meta_key).
*
* @runInSeparateProcess
*/
public function test_upsert_uses_composite_unique_key(): void {
global $wpdb;
$table = $wpdb->prefix . 'hpct_listing_meta';
// Clean slate.
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
$wpdb->query(
"CREATE TABLE `{$table}` (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
listing_id bigint(20) unsigned NOT NULL DEFAULT 0,
meta_key varchar(255) NOT NULL DEFAULT '',
meta_value longtext,
PRIMARY KEY (id),
UNIQUE KEY ui_listing_meta (listing_id, meta_key(191))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// First upsert: INSERT.
$result1 = WPDO_DB::upsert(
$table,
array(
'listing_id' => 4242,
'meta_key' => 'hp_price',
'meta_value' => '100.00',
),
array( 'meta_value' ),
array( 'listing_id', 'meta_key' ),
array( '%d', '%s', '%s' )
);
$this->assertNotFalse( $result1 );
// Second upsert: UPDATE via the composite key.
$result2 = WPDO_DB::upsert(
$table,
array(
'listing_id' => 4242,
'meta_key' => 'hp_price',
'meta_value' => '250.50',
),
array( 'meta_value' ),
array( 'listing_id', 'meta_key' ),
array( '%d', '%s', '%s' )
);
$this->assertNotFalse( $result2 );
// Verify only one row exists for (4242, 'hp_price') with the latest value.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_value FROM `{$table}` WHERE listing_id = %d AND meta_key = %s",
4242,
'hp_price'
),
ARRAY_A
);
$this->assertCount( 1, $rows, 'Composite UNIQUE must collapse two upserts into a single row' );
$this->assertSame( '250.50', $rows[0]['meta_value'] );
// Cleanup.
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
/**
* Verify table_exists() returns false when table missing, true after CREATE.
*
* @runInSeparateProcess
*/
public function test_table_exists_detection(): void {
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}hpct_listing_meta`" );
// Use reflection to invoke private static.
$ref = new ReflectionClass( WPDO_Listing_Meta_Interceptor::class );
$method = $ref->getMethod( 'table_exists' );
$method->setAccessible( true );
$this->assertFalse( $method->invoke( null ), 'table_exists must return false when table missing' );
}
}
+1 -10
View File
@@ -29,8 +29,6 @@ define( 'TMDO_HIVEPRESS_FILE', TMDO_HIVEPRESS_PATH . '2meet-data-optimizer-hivep
define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' ); define( 'TMDO_HIVEPRESS_VERSION', '0.1.0' );
// ── AddOn classes ────────────────────────────────────────────────────────── // ── AddOn classes ──────────────────────────────────────────────────────────
// Superset of the unit bootstrap's list: the integration suite also exercises
// the interceptors, which the unit suite does not load.
$tmdo_hp_files = array( $tmdo_hp_files = array(
'includes/class-tmdo-hivepress.php', 'includes/class-tmdo-hivepress.php',
@@ -39,7 +37,6 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-detector.php', 'includes/hivepress/class-tmdo-hivepress-detector.php',
'includes/hivepress/class-tmdo-hivepress-conflict-guard.php', 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php',
'includes/hivepress/class-tmdo-hivepress-bootstrap.php', 'includes/hivepress/class-tmdo-hivepress-bootstrap.php',
'includes/hivepress/class-tmdo-hivepress-comment-router.php',
'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php', 'includes/hivepress/class-tmdo-hivepress-cron-optimizer.php',
'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php', 'includes/hivepress/class-tmdo-hivepress-attribute-bridge.php',
'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php', 'includes/hivepress/class-tmdo-hivepress-suitability-scorer.php',
@@ -47,7 +44,7 @@ $tmdo_hp_files = array(
'includes/hivepress/class-tmdo-hivepress-rest.php', 'includes/hivepress/class-tmdo-hivepress-rest.php',
'includes/class-tmdo-hivepress-transient-filter.php', 'includes/class-tmdo-hivepress-transient-filter.php',
'includes/class-tmdo-hivepress-term-comment-fields.php', 'includes/class-tmdo-hivepress-term-comment-fields.php',
'includes/class-tmdo-hpct-import.php', 'includes/class-tmdo-hivepress-entity-groups.php',
'admin/class-tmdo-admin-hivepress.php', 'admin/class-tmdo-admin-hivepress.php',
'cli/class-tmdo-cli-hivepress.php', 'cli/class-tmdo-cli-hivepress.php',
); );
@@ -55,12 +52,6 @@ $tmdo_hp_files = array(
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/hivepress/adapters/*.php' ) ?: array() as $tmdo_hp_glob ) { foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/hivepress/adapters/*.php' ) ?: array() as $tmdo_hp_glob ) {
$tmdo_hp_files[] = 'includes/hivepress/adapters/' . basename( $tmdo_hp_glob ); $tmdo_hp_files[] = 'includes/hivepress/adapters/' . basename( $tmdo_hp_glob );
} }
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/interceptors/*.php' ) ?: array() as $tmdo_hp_glob ) {
$tmdo_hp_files[] = 'includes/interceptors/' . basename( $tmdo_hp_glob );
}
foreach ( glob( TMDO_HIVEPRESS_PATH . 'includes/query/*.php' ) ?: array() as $tmdo_hp_glob ) {
$tmdo_hp_files[] = 'includes/query/' . basename( $tmdo_hp_glob );
}
foreach ( $tmdo_hp_files as $tmdo_hp_file ) { foreach ( $tmdo_hp_files as $tmdo_hp_file ) {
$tmdo_hp_path = TMDO_HIVEPRESS_PATH . $tmdo_hp_file; $tmdo_hp_path = TMDO_HIVEPRESS_PATH . $tmdo_hp_file;
-17
View File
@@ -33,19 +33,6 @@ class HivePressBookingsAdapterTest extends TestCase {
$this->assertSame( 'HivePress\\Bookings\\Plugin', $this->adapter->detection_class() ); $this->assertSame( 'HivePress\\Bookings\\Plugin', $this->adapter->detection_class() );
} }
public function test_on_register_fields_reserves_booking_hot_columns(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
$start = $registry->get_field( 'hp_booking', 'hp_start_time' );
$this->assertSame( 'hot', $start['zone'] );
$this->assertTrue( (bool) $start['indexed'] );
$end = $registry->get_field( 'hp_booking', 'hp_end_time' );
$this->assertSame( 'hot', $end['zone'] );
$this->assertTrue( (bool) $end['indexed'] );
}
public function test_score_reflects_unverified_perf_dimension(): void { public function test_score_reflects_unverified_perf_dimension(): void {
$score = $this->adapter->suitability_score(); $score = $this->adapter->suitability_score();
// D8 reduced to 0.9 because schema is best-effort until addon installs. // D8 reduced to 0.9 because schema is best-effort until addon installs.
@@ -53,8 +40,4 @@ class HivePressBookingsAdapterTest extends TestCase {
// Aggregate = (7 × 1.0 + 0.9) / 8 × 10 = 9.875 → 9.88. // Aggregate = (7 × 1.0 + 0.9) / 8 × 10 = 9.875 → 9.88.
$this->assertSame( 9.88, $score['aggregate'] ); $this->assertSame( 9.88, $score['aggregate'] );
} }
public function test_migrations_lists_booking_module(): void {
$this->assertSame( array( 'hot_hp_booking' ), $this->adapter->migrations() );
}
} }
-92
View File
@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Tests for WPDO_HivePress_Comment_Router.
*
* @covers WPDO_HivePress_Comment_Router
*/
class HivePressCommentRouterTest extends TestCase {
use WpdbMockTrait;
protected function setUp(): void {
$this->install_wpdb_mock();
WPDO_HivePress_Comment_Router::reset_for_tests();
// Default disabled.
$GLOBALS['_wp_options'][ WPDO_HivePress_Comment_Router::OPTION_ENABLED ] = 0;
}
public function test_disabled_by_default(): void {
$this->assertFalse( WPDO_HivePress_Comment_Router::is_enabled() );
}
public function test_enabled_when_option_set(): void {
$GLOBALS['_wp_options'][ WPDO_HivePress_Comment_Router::OPTION_ENABLED ] = 1;
$this->assertTrue( WPDO_HivePress_Comment_Router::is_enabled() );
}
public function test_register_is_idempotent(): void {
$GLOBALS['_wp_options'][ WPDO_HivePress_Comment_Router::OPTION_ENABLED ] = 1;
WPDO_HivePress_Comment_Router::register();
WPDO_HivePress_Comment_Router::register(); // Second call must be no-op.
$this->assertTrue( true ); // No throw = pass
}
public function test_rewrite_skips_unknown_comment_type(): void {
$query = new stdClass();
$query->query_vars = array( 'type' => 'comment' ); // not in shadow map
$clauses = array( 'join' => '', 'where' => '1=1' );
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, $query );
$this->assertSame( $clauses, $out );
}
public function test_rewrite_adds_left_join_for_hp_favorite(): void {
$query = new stdClass();
$query->query_vars = array( 'type' => 'hp_favorite' );
$clauses = array( 'join' => '', 'where' => '1=1' );
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, $query );
$this->assertStringContainsString( 'LEFT JOIN', $out['join'] );
$this->assertStringContainsString( 'wpdo_comment_hp_favorite', $out['join'] );
$this->assertStringContainsString( 'AS wpdo_shadow', $out['join'] );
}
public function test_rewrite_promotes_recipient_for_hp_message(): void {
$query = new stdClass();
$query->query_vars = array( 'type' => 'hp_message' );
$clauses = array(
'join' => '',
'where' => 'wp_comments.comment_karma = 42 AND comment_approved = 0',
);
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, $query );
// recipient lookup should be rewritten to use shadow column.
$this->assertStringContainsString( 'wpdo_shadow.recipient_id = 42', $out['where'] );
// Other predicates preserved.
$this->assertStringContainsString( 'comment_approved = 0', $out['where'] );
}
public function test_rewrite_handles_array_type(): void {
$query = new stdClass();
$query->query_vars = array( 'type' => array( 'hp_review', 'comment' ) );
$clauses = array( 'join' => '', 'where' => '' );
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, $query );
// First element of type array should match (hp_review).
$this->assertStringContainsString( 'wpdo_comment_hp_review', $out['join'] );
}
public function test_rewrite_returns_unchanged_when_query_invalid(): void {
$clauses = array( 'join' => '', 'where' => '1=1' );
$out = WPDO_HivePress_Comment_Router::rewrite( $clauses, 'not an object' );
$this->assertSame( $clauses, $out );
}
}
+10
View File
@@ -47,6 +47,16 @@ class HivePressConflictGuardTest extends TestCase {
$this->assertSame( '', $out ); $this->assertSame( '', $out );
} }
public function test_guidance_does_not_point_to_a_missing_command(): void {
// There is no HPCT importer: `wp wpdo hivepress migrate-from-hpct` never
// existed and TMDO_HPCT_Import had no caller once core dropped HPCT.
$root = dirname( __DIR__, 2 );
foreach ( array( 'includes/hivepress/class-tmdo-hivepress-conflict-guard.php', 'admin/class-tmdo-admin-hivepress.php' ) as $file ) {
$this->assertStringNotContainsString( 'migrate-from-hpct', (string) file_get_contents( $root . '/' . $file ), $file );
}
$this->assertFileDoesNotExist( $root . '/includes/class-tmdo-hpct-import.php' );
}
public function test_memo_cache_avoids_repeat_probing(): void { public function test_memo_cache_avoids_repeat_probing(): void {
WPDO_HivePress_Conflict_Guard::reset_for_tests(); WPDO_HivePress_Conflict_Guard::reset_for_tests();
$first = WPDO_HivePress_Conflict_Guard::detect_conflicts(); $first = WPDO_HivePress_Conflict_Guard::detect_conflicts();
-29
View File
@@ -46,29 +46,10 @@ class HivePressCoreAdapterTest extends TestCase {
$this->assertSame( '1.7.0', $this->adapter->minimum_addon_version() ); $this->assertSame( '1.7.0', $this->adapter->minimum_addon_version() );
} }
public function test_migrations_returns_listing_and_vendor_modules(): void {
$mods = $this->adapter->migrations();
$this->assertContains( 'hot_hp_listing', $mods );
$this->assertContains( 'hot_hp_vendor', $mods );
}
public function test_on_register_fields_populates_schema_registry(): void { public function test_on_register_fields_populates_schema_registry(): void {
$registry = WPDO_Schema_Registry::instance(); $registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry ); $this->adapter->on_register_fields( $registry );
// Listing hot fields.
$drafted = $registry->get_field( 'hp_listing', 'hp_drafted' );
$this->assertIsArray( $drafted );
$this->assertSame( 'hot', $drafted['zone'] );
$this->assertTrue( (bool) $drafted['indexed'] );
$expired = $registry->get_field( 'hp_listing', 'hp_expired_time' );
$this->assertSame( 'hot', $expired['zone'] );
$this->assertStringContainsString( 'bigint', $expired['data_type'] );
$featured_time = $registry->get_field( 'hp_listing', 'hp_featured_time' );
$this->assertSame( 'hot', $featured_time['zone'] );
// Vendor cold blob. // Vendor cold blob.
$vendor_image = $registry->get_field( 'hp_vendor', 'hp_image' ); $vendor_image = $registry->get_field( 'hp_vendor', 'hp_image' );
$this->assertIsArray( $vendor_image ); $this->assertIsArray( $vendor_image );
@@ -87,16 +68,6 @@ class HivePressCoreAdapterTest extends TestCase {
} }
} }
public function test_doctor_check_reports_table_status(): void {
$result = $this->adapter->doctor_check();
$this->assertIsArray( $result );
$this->assertArrayHasKey( 'ok', $result );
$this->assertArrayHasKey( 'message', $result );
$this->assertArrayHasKey( 'details', $result );
// The stub $wpdb returns null for SHOW TABLES, so tables appear missing → ok=false.
$this->assertFalse( $result['ok'] );
}
public function test_optimize_query_extracts_hot_clauses(): void { public function test_optimize_query_extracts_hot_clauses(): void {
$registry = WPDO_Schema_Registry::instance(); $registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry ); $this->adapter->on_register_fields( $registry );
+12 -59
View File
@@ -6,6 +6,9 @@ use PHPUnit\Framework\TestCase;
/** /**
* Tests for WPDO_HivePress_Favorites_Adapter. * Tests for WPDO_HivePress_Favorites_Adapter.
* *
* The adapter no longer mirrors favorites into a shadow table: that table was
* never created, so every mirror write failed.
*
* @covers WPDO_HivePress_Favorites_Adapter * @covers WPDO_HivePress_Favorites_Adapter
*/ */
class HivePressFavoritesAdapterTest extends TestCase { class HivePressFavoritesAdapterTest extends TestCase {
@@ -32,72 +35,22 @@ class HivePressFavoritesAdapterTest extends TestCase {
public function test_constants_exposed(): void { public function test_constants_exposed(): void {
$this->assertSame( 'hp_favorite', WPDO_HivePress_Favorites_Adapter::COMMENT_TYPE ); $this->assertSame( 'hp_favorite', WPDO_HivePress_Favorites_Adapter::COMMENT_TYPE );
$this->assertSame( 'wpdo_comment_hp_favorite', WPDO_HivePress_Favorites_Adapter::TABLE );
} }
public function test_on_register_custom_tables_declares_shadow_table(): void { public function test_registers_no_shadow_table(): void {
$registry = WPDO_Custom_Table_Registry::instance(); $registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry ); $this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all(); $this->assertSame( array(), $registry->all() );
$this->assertCount( 1, $tables );
$cfg = reset( $tables );
$this->assertSame( 'hivepress-favorites', $cfg['provider'] );
$this->assertSame( 'wpdo_comment_hp_favorite', $cfg['table_name'] );
$this->assertSame( 'comment_id', $cfg['primary_key'] );
$this->assertArrayHasKey( 'unique_user_listing', $cfg['indexes'] );
$this->assertStringContainsString( 'UNIQUE', $cfg['indexes']['unique_user_listing'] );
} }
public function test_mirror_insert_skips_non_favorite_comments(): void { public function test_binds_no_comment_hooks(): void {
// Stub $wpdb tracks queries. $GLOBALS['_wp_filter_callbacks'] = array();
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass(); $this->adapter->on_register_event_hooks();
$comment->comment_type = 'comment'; // ← not hp_favorite
$comment->user_id = 1;
$comment->comment_post_ID = 99;
$this->adapter->mirror_insert( 123, $comment ); $this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertSame( array(), $wpdb->queries ); $this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
}
public function test_mirror_insert_writes_for_hp_favorite(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_favorite';
$comment->user_id = 7;
$comment->comment_post_ID = 42;
$comment->comment_date = '2026-05-04 12:00:00';
$this->adapter->mirror_insert( 100, $comment );
$this->assertCount( 1, $wpdb->queries );
$this->assertStringContainsString( 'INSERT IGNORE', $wpdb->queries[0] );
$this->assertStringContainsString( 'wpdo_comment_hp_favorite', $wpdb->queries[0] );
}
public function test_mirror_insert_skips_when_user_or_listing_missing(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_favorite';
$comment->user_id = 0;
$comment->comment_post_ID = 99;
$this->adapter->mirror_insert( 100, $comment );
$this->assertSame( array(), $wpdb->queries );
}
public function test_doctor_check_reports_missing_table_in_stub_env(): void {
$result = $this->adapter->doctor_check();
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'wpdo_comment_hp_favorite', $result['message'] );
} }
public function test_score_aggregates_to_perfect_ten(): void { public function test_score_aggregates_to_perfect_ten(): void {
@@ -105,7 +58,7 @@ class HivePressFavoritesAdapterTest extends TestCase {
$this->assertSame( 10.0, $score['aggregate'] ); $this->assertSame( 10.0, $score['aggregate'] );
} }
public function test_migrations_lists_comment_module(): void { public function test_migrations_is_empty(): void {
$this->assertSame( array( 'comment_hp_favorite' ), $this->adapter->migrations() ); $this->assertSame( array(), $this->adapter->migrations() );
} }
} }
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* The AddOn owns the HivePress entity groups core used to register.
*
* Their definitions are persisted (wpdo_registry_meta schema hash) and the
* flat tables on live sites were built from them, so they are pinned to the
* persisted hashes: any drift would ALTER flat tables that may hold
* aeav_only data. (Values recorded on dev20, 2026-09-25.)
*/
class HivePressEntityGroupsTest extends TestCase {
private const PERSISTED_HASHES = array(
'post/hp_listing_core' => 'fb5ab760',
'post/hp_request_core' => '27d6f4f5',
'post/hp_vendor_core' => '5366e5c8',
'user/hp_user' => '61ac526f',
);
/**
* Lookups that do not pass a post type (or run with scoped routing off,
* until a site's rows are reconciled) keep the legacy owner of keys shared
* by several post groups: the LAST group registered. Pinned so the
* registration order cannot change silently.
*/
private const SHARED_KEY_OWNERS = array(
'hp_status' => 'hp_request_core',
'hp_expired_time' => 'hp_request_core',
'hp_view_count' => 'hp_request_core',
'hp_verified' => 'hp_vendor_core',
'hp_rating' => 'hp_vendor_core',
'hp_rating_count' => 'hp_vendor_core',
'hp_hourly_rate' => 'hp_vendor_core',
'hp_user' => 'hp_vendor_core',
);
protected function setUp(): void {
TMDO_Entity_Registry::init();
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
TMDO_Entity_Registry::register_adapter( 'user', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
TMDO_HivePress_Entity_Groups::register_entity_fields();
}
protected function tearDown(): void {
unset( $GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] );
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'] );
}
public function test_definitions_match_persisted_schema_hashes(): void {
foreach ( self::PERSISTED_HASHES as $id => $hash ) {
list( $type, $group ) = explode( '/', $id );
$fields = TMDO_Entity_Registry::get_group_fields( $type, $group );
$this->assertNotEmpty( $fields, "{$id} not registered" );
$this->assertSame( $hash, substr( TMDO_Schema_Manager::calculate_schema_hash( $fields ), 0, 8 ), "{$id} drifted from the persisted definition" );
}
}
public function test_shared_keys_keep_their_current_owner(): void {
foreach ( self::SHARED_KEY_OWNERS as $key => $group ) {
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key )['group'], $key );
}
}
public function test_shared_keys_follow_the_post_type_when_scoped_routing_is_on(): void {
$GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '1';
foreach ( array( 'hp_listing' => 'hp_listing_core', 'hp_request' => 'hp_request_core', 'hp_vendor' => 'hp_vendor_core' ) as $post_type => $group ) {
foreach ( TMDO_Entity_Registry::get_group_keys( 'post', $group ) as $key ) {
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key, $post_type )['group'], "{$post_type}:{$key}" );
}
}
$this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'hp_rating', 'post' ), 'HivePress keys stay native on other post types' );
}
/**
* Fields only the Hot zone used to cover (ADR-003), as their own groups.
*/
private const HOT_ONLY_GROUPS = array(
'hp_draft_flags' => array( array( 'hp_listing', 'hp_request' ), array( 'hp_drafted' ) ),
'hp_booking_core' => array( array( 'hp_booking' ), array( 'hp_start_time', 'hp_end_time', 'hp_quantity' ) ),
'hp_membership_plan_core' => array( array( 'hp_membership_plan' ), array( 'hp_expire_period', 'hp_primary' ) ),
'hp_membership_core' => array( array( 'hp_membership' ), array( 'hp_expired_time' ) ),
);
public function test_hot_only_fields_have_entity_groups_scoped_to_their_post_types(): void {
foreach ( self::HOT_ONLY_GROUPS as $group => list( $post_types, $keys ) ) {
$this->assertSame( $post_types, TMDO_Entity_Registry::get_group_post_types( 'post', $group ), $group );
$this->assertSame( $keys, TMDO_Entity_Registry::get_group_keys( 'post', $group ), $group );
}
}
public function test_hot_only_fields_route_to_their_group_when_scoped_routing_is_on(): void {
$GLOBALS['_wp_options'][ TMDO_Entity_Registry::SCOPED_ROUTING_OPTION ] = '1';
foreach ( self::HOT_ONLY_GROUPS as $group => list( $post_types, $keys ) ) {
foreach ( $post_types as $post_type ) {
foreach ( $keys as $key ) {
$this->assertSame( $group, TMDO_Entity_Registry::get_field( 'post', $key, $post_type )['group'], "{$post_type}:{$key}" );
}
}
}
$this->assertSame( 'hp_listing_core', TMDO_Entity_Registry::get_field( 'post', 'hp_expired_time', 'hp_listing' )['group'] );
}
public function test_hot_only_groups_add_no_collisions(): void {
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
}
public function test_reconciler_plans_the_misrouted_hivepress_keys(): void {
$moves = array();
foreach ( TMDO_Post_Group_Reconciler::plan() as $m ) {
$moves[] = "{$m['source']}>{$m['target']}:{$m['key']}";
}
sort( $moves );
$this->assertSame(
array(
'hp_request_core>hp_listing_core:hp_expired_time',
'hp_request_core>hp_listing_core:hp_status',
'hp_request_core>hp_listing_core:hp_view_count',
'hp_request_core>hp_membership_core:hp_expired_time',
'hp_vendor_core>hp_listing_core:hp_hourly_rate',
'hp_vendor_core>hp_listing_core:hp_rating',
'hp_vendor_core>hp_listing_core:hp_rating_count',
'hp_vendor_core>hp_listing_core:hp_verified',
'hp_vendor_core>hp_request_core:hp_user',
),
$moves
);
}
public function test_rest_listings_default_to_hivepress_listings(): void {
TMDO_HivePress_Entity_Groups::register();
$this->assertSame( 'hp_listing', TMDO_REST_API::default_post_type() );
}
public function test_core_adapter_does_not_register_a_conflicting_group(): void {
( new TMDO_HivePress_Core_Adapter() )->on_register_entity_fields();
$this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
}
}
-13
View File
@@ -33,19 +33,6 @@ class HivePressMarketplaceAdapterTest extends TestCase {
$this->assertSame( 'HivePress\\Marketplace\\Plugin', $this->adapter->detection_class() ); $this->assertSame( 'HivePress\\Marketplace\\Plugin', $this->adapter->detection_class() );
} }
public function test_on_register_fields_registers_hot_price_and_warm_counter(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
$price = $registry->get_field( 'hp_listing', 'hp_purchase_price' );
$this->assertSame( 'hot', $price['zone'] );
$this->assertTrue( (bool) $price['indexed'] );
$this->assertStringContainsString( 'decimal', $price['data_type'] );
$count = $registry->get_field( 'hp_listing', 'hp_purchase_count' );
$this->assertSame( 'warm', $count['zone'] );
}
public function test_score_aggregates_to_nine_eight_eight(): void { public function test_score_aggregates_to_nine_eight_eight(): void {
// D8 = 0.9 → aggregate = (7×1.0 + 0.9)/8 × 10 = 9.875 → 9.88. // D8 = 0.9 → aggregate = (7×1.0 + 0.9)/8 × 10 = 9.875 → 9.88.
$this->assertSame( 9.88, $this->adapter->suitability_score()['aggregate'] ); $this->assertSame( 9.88, $this->adapter->suitability_score()['aggregate'] );
-33
View File
@@ -33,41 +33,8 @@ class HivePressMembershipsAdapterTest extends TestCase {
$this->assertSame( '2.0.0', $this->adapter->minimum_addon_version() ); $this->assertSame( '2.0.0', $this->adapter->minimum_addon_version() );
} }
public function test_on_register_fields_registers_three_hot_fields(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
// Plan fields.
$expire_period = $registry->get_field( 'hp_membership_plan', 'hp_expire_period' );
$this->assertSame( 'hot', $expire_period['zone'] );
$this->assertStringContainsString( 'int', $expire_period['data_type'] );
$primary = $registry->get_field( 'hp_membership_plan', 'hp_primary' );
$this->assertSame( 'hot', $primary['zone'] );
$this->assertTrue( (bool) $primary['indexed'] );
// Membership field — the hottest path.
$expired = $registry->get_field( 'hp_membership', 'hp_expired_time' );
$this->assertSame( 'hot', $expired['zone'] );
$this->assertTrue( (bool) $expired['indexed'] );
$this->assertStringContainsString( 'bigint', $expired['data_type'] );
}
public function test_doctor_check_reports_missing_tables_in_stub_env(): void {
$result = $this->adapter->doctor_check();
$this->assertFalse( $result['ok'] );
$this->assertArrayHasKey( 'hp_membership_plan', $result['details'] );
$this->assertArrayHasKey( 'hp_membership', $result['details'] );
}
public function test_score_aggregates_to_perfect_ten(): void { public function test_score_aggregates_to_perfect_ten(): void {
$score = $this->adapter->suitability_score(); $score = $this->adapter->suitability_score();
$this->assertSame( 10.0, $score['aggregate'] ); $this->assertSame( 10.0, $score['aggregate'] );
} }
public function test_migrations_lists_both_hot_modules(): void {
$mods = $this->adapter->migrations();
$this->assertContains( 'hot_hp_membership_plan', $mods );
$this->assertContains( 'hot_hp_membership', $mods );
}
} }
+13 -61
View File
@@ -6,6 +6,9 @@ use PHPUnit\Framework\TestCase;
/** /**
* Tests for WPDO_HivePress_Messages_Adapter. * Tests for WPDO_HivePress_Messages_Adapter.
* *
* The adapter no longer mirrors messages into a shadow table: that table was
* never created, so every mirror write failed.
*
* @covers WPDO_HivePress_Messages_Adapter * @covers WPDO_HivePress_Messages_Adapter
*/ */
class HivePressMessagesAdapterTest extends TestCase { class HivePressMessagesAdapterTest extends TestCase {
@@ -28,77 +31,26 @@ class HivePressMessagesAdapterTest extends TestCase {
$this->assertSame( 'hivepress-messages', $this->adapter->plugin_slug() ); $this->assertSame( 'hivepress-messages', $this->adapter->plugin_slug() );
$this->assertSame( 'HivePress\\Messages\\Plugin', $this->adapter->detection_class() ); $this->assertSame( 'HivePress\\Messages\\Plugin', $this->adapter->detection_class() );
$this->assertSame( 'hp_message', WPDO_HivePress_Messages_Adapter::COMMENT_TYPE ); $this->assertSame( 'hp_message', WPDO_HivePress_Messages_Adapter::COMMENT_TYPE );
$this->assertSame( 'wpdo_comment_hp_message', WPDO_HivePress_Messages_Adapter::TABLE );
} }
public function test_on_register_custom_tables_declares_indexed_recipient(): void { public function test_registers_no_shadow_table(): void {
$registry = WPDO_Custom_Table_Registry::instance(); $registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry ); $this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all(); $this->assertSame( array(), $registry->all() );
$cfg = reset( $tables );
$this->assertSame( 'wpdo_comment_hp_message', $cfg['table_name'] );
$this->assertArrayHasKey( 'recipient_id', $cfg['expected_columns'] );
$this->assertArrayHasKey( 'idx_recipient_unread', $cfg['indexes'] );
$this->assertStringContainsString( 'recipient_id, is_read', $cfg['indexes']['idx_recipient_unread'] );
} }
public function test_mirror_insert_skips_non_message_comments(): void { public function test_binds_no_comment_hooks(): void {
global $wpdb; $GLOBALS['_wp_filter_callbacks'] = array();
$wpdb->queries = array();
$comment = new stdClass(); $this->adapter->on_register_event_hooks();
$comment->comment_type = 'comment';
$comment->user_id = 1;
$comment->comment_karma = 5;
$this->adapter->mirror_insert( 1, $comment ); $this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertSame( array(), $wpdb->queries ); $this->assertArrayNotHasKey( 'wp_set_comment_status', $GLOBALS['_wp_filter_callbacks'] );
$this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
} }
public function test_mirror_insert_promotes_recipient_from_comment_karma(): void { public function test_migrations_is_empty(): void {
global $wpdb; $this->assertSame( array(), $this->adapter->migrations() );
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_message';
$comment->user_id = 7; // sender
$comment->comment_karma = 42; // ← recipient hack lives here
$comment->comment_post_ID = 99;
$comment->comment_approved = 0;
$comment->comment_date = '2026-05-04 12:00:00';
$this->adapter->mirror_insert( 100, $comment );
$this->assertCount( 1, $wpdb->queries );
$sql = $wpdb->queries[0];
$this->assertStringContainsString( 'INSERT INTO', $sql );
$this->assertStringContainsString( 'wpdo_comment_hp_message', $sql );
// The promoted recipient_id should appear in the bound SQL.
$this->assertStringContainsString( '42', $sql );
}
public function test_mirror_insert_skips_when_sender_or_recipient_zero(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_message';
$comment->user_id = 0; // bad sender
$comment->comment_karma = 42;
$comment->comment_post_ID = 99;
$this->adapter->mirror_insert( 100, $comment );
$this->assertSame( array(), $wpdb->queries );
}
public function test_doctor_check_reports_missing_table_in_stub_env(): void {
$result = $this->adapter->doctor_check();
$this->assertFalse( $result['ok'] );
$this->assertStringContainsString( 'wpdo_comment_hp_message', $result['message'] );
}
public function test_score_aggregates_to_perfect_ten(): void {
$this->assertSame( 10.0, $this->adapter->suitability_score()['aggregate'] );
} }
} }
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* ADR-003: post fields converge on entity groups; the Hot zone is frozen.
*
* No HivePress adapter registers zone=hot fields or contributes hot_* FSM
* modules. The fields they used to cover live in TMDO_HivePress_Entity_Groups.
*/
class NoHotZoneRegistrationTest extends TestCase {
private const ADAPTERS = array(
'TMDO_HivePress_Core_Adapter',
'TMDO_HivePress_Requests_Adapter',
'TMDO_HivePress_Bookings_Adapter',
'TMDO_HivePress_Memberships_Adapter',
'TMDO_HivePress_Marketplace_Adapter',
'TMDO_HivePress_Favorites_Adapter',
'TMDO_HivePress_Reviews_Adapter',
'TMDO_HivePress_Social_Links_Adapter',
'TMDO_HivePress_Blocks_Adapter',
'TMDO_HivePress_Seo_Adapter',
'TMDO_HivePress_Messages_Adapter',
'TMDO_HivePress_Statistics_Adapter',
'TMDO_HivePress_Tags_Adapter',
);
protected function setUp(): void {
$prop = ( new ReflectionClass( TMDO_Schema_Registry::class ) )->getProperty( 'instance' );
$prop->setAccessible( true );
$prop->setValue( null, null );
}
public function test_no_adapter_registers_hot_fields(): void {
$registry = TMDO_Schema_Registry::instance();
foreach ( self::ADAPTERS as $class ) {
( new $class() )->on_register_fields( $registry );
}
$this->assertSame( array(), $registry->get_zone_fields( 'hot' ) );
$this->assertSame( 'cold', $registry->get_field_zone( 'hp_listing', 'hp_images' ), 'cold fields stay' );
}
public function test_no_adapter_contributes_hot_modules(): void {
foreach ( self::ADAPTERS as $class ) {
$this->assertSame( array(), ( new $class() )->migrations(), $class );
}
}
}
+9 -51
View File
@@ -35,67 +35,25 @@ class HivePressRequestsAdapterTest extends TestCase {
$this->assertSame( 'hp_offer', WPDO_HivePress_Requests_Adapter::OFFER_COMMENT_TYPE ); $this->assertSame( 'hp_offer', WPDO_HivePress_Requests_Adapter::OFFER_COMMENT_TYPE );
} }
public function test_on_register_fields_registers_request_hot_columns(): void { public function test_registers_no_offer_shadow_table(): void {
$registry = WPDO_Schema_Registry::instance(); // The offer shadow table was never created, so every mirror write failed.
$this->adapter->on_register_fields( $registry );
$drafted = $registry->get_field( 'hp_request', 'hp_drafted' );
$this->assertSame( 'hot', $drafted['zone'] );
$this->assertTrue( (bool) $drafted['indexed'] );
$expired = $registry->get_field( 'hp_request', 'hp_expired_time' );
$this->assertSame( 'hot', $expired['zone'] );
$this->assertTrue( (bool) $expired['indexed'] );
}
public function test_on_register_custom_tables_declares_offer_shadow(): void {
$registry = WPDO_Custom_Table_Registry::instance(); $registry = WPDO_Custom_Table_Registry::instance();
$this->adapter->on_register_custom_tables( $registry ); $this->adapter->on_register_custom_tables( $registry );
$tables = $registry->all(); $this->assertSame( array(), $registry->all() );
$cfg = reset( $tables );
$this->assertSame( 'wpdo_comment_hp_offer', $cfg['table_name'] );
$this->assertSame( 'hp_request', $cfg['post_type_link'] );
$this->assertArrayHasKey( 'idx_request_approved', $cfg['indexes'] );
} }
public function test_mirror_offer_insert_skips_other_comment_types(): void { public function test_binds_no_comment_hooks(): void {
global $wpdb; $GLOBALS['_wp_filter_callbacks'] = array();
$wpdb->queries = array();
$comment = new stdClass(); $this->adapter->on_register_event_hooks();
$comment->comment_type = 'comment'; // ← not hp_offer
$comment->comment_post_ID = 99;
$comment->user_id = 1;
$this->adapter->mirror_offer_insert( 1, $comment ); $this->assertArrayNotHasKey( 'wp_insert_comment', $GLOBALS['_wp_filter_callbacks'] );
$this->assertSame( array(), $wpdb->queries ); $this->assertArrayNotHasKey( 'edit_comment', $GLOBALS['_wp_filter_callbacks'] );
} $this->assertArrayNotHasKey( 'deleted_comment', $GLOBALS['_wp_filter_callbacks'] );
public function test_mirror_offer_insert_writes_for_hp_offer(): void {
global $wpdb;
$wpdb->queries = array();
$comment = new stdClass();
$comment->comment_type = 'hp_offer';
$comment->comment_post_ID = 42;
$comment->user_id = 7;
$comment->comment_approved = 0;
$comment->comment_date = '2026-05-04 12:00:00';
$this->adapter->mirror_offer_insert( 100, $comment );
$this->assertCount( 1, $wpdb->queries );
$this->assertStringContainsString( 'INSERT INTO', $wpdb->queries[0] );
$this->assertStringContainsString( 'wpdo_comment_hp_offer', $wpdb->queries[0] );
} }
public function test_score_aggregates_to_perfect_ten(): void { public function test_score_aggregates_to_perfect_ten(): void {
$this->assertSame( 10.0, $this->adapter->suitability_score()['aggregate'] ); $this->assertSame( 10.0, $this->adapter->suitability_score()['aggregate'] );
} }
public function test_migrations_lists_request_and_offer_modules(): void {
$mods = $this->adapter->migrations();
$this->assertContains( 'hot_hp_request', $mods );
$this->assertContains( 'comment_hp_offer', $mods );
}
} }
+27 -11
View File
@@ -18,6 +18,8 @@ class HivePressSocialLinksAdapterTest extends TestCase {
$prop->setAccessible( true ); $prop->setAccessible( true );
$prop->setValue( null, null ); $prop->setValue( null, null );
unset( $GLOBALS['_wp_options']['hp_vendor_social_links'], $GLOBALS['_wp_options']['hp_listing_social_links'] );
$this->adapter = new WPDO_HivePress_Social_Links_Adapter(); $this->adapter = new WPDO_HivePress_Social_Links_Adapter();
} }
@@ -30,25 +32,39 @@ class HivePressSocialLinksAdapterTest extends TestCase {
$this->assertSame( 'HivePress\\SocialLinks\\Plugin', $this->adapter->detection_class() ); $this->assertSame( 'HivePress\\SocialLinks\\Plugin', $this->adapter->detection_class() );
} }
public function test_on_register_fields_registers_seven_cold_fields(): void { public function test_registers_enabled_links_per_model(): void {
// hivepress-social-links stores each enabled link as the attribute hp_{name},
// with separate admin selections for listings and vendors.
update_option( 'hp_vendor_social_links', array( 'facebook', 'website' ) );
update_option( 'hp_listing_social_links', array( 'instagram' ) );
$registry = WPDO_Schema_Registry::instance(); $registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry ); $this->adapter->on_register_fields( $registry );
// All seven social URL fields should land in cold zone on hp_vendor. $this->assertSame( 'cold', $registry->get_field( 'hp_vendor', 'hp_facebook' )['zone'] );
$facebook = $registry->get_field( 'hp_vendor', 'hp_facebook_url' ); $this->assertSame( 'cold', $registry->get_field( 'hp_vendor', 'hp_website' )['zone'] );
$this->assertSame( 'cold', $facebook['zone'] ); $this->assertSame( 'cold', $registry->get_field( 'hp_listing', 'hp_instagram' )['zone'] );
$instagram = $registry->get_field( 'hp_vendor', 'hp_instagram_url' ); $this->assertNull( $registry->get_field( 'hp_vendor', 'hp_instagram' ) );
$this->assertSame( 'cold', $instagram['zone'] ); $this->assertNull( $registry->get_field( 'hp_vendor', 'hp_facebook_url' ) );
$whatsapp = $registry->get_field( 'hp_vendor', 'hp_whatsapp_url' );
$this->assertSame( 'cold', $whatsapp['zone'] );
} }
public function test_doctor_check_reports_field_count(): void { public function test_registers_nothing_when_no_links_enabled(): void {
$registry = WPDO_Schema_Registry::instance();
$this->adapter->on_register_fields( $registry );
$this->assertNull( $registry->get_field( 'hp_vendor', 'hp_facebook' ) );
$this->assertNull( $registry->get_field( 'hp_listing', 'hp_facebook' ) );
}
public function test_doctor_check_reports_registered_count(): void {
update_option( 'hp_vendor_social_links', array( 'facebook', 'website' ) );
update_option( 'hp_listing_social_links', array( 'instagram' ) );
$result = $this->adapter->doctor_check(); $result = $this->adapter->doctor_check();
$this->assertTrue( $result['ok'] ); $this->assertTrue( $result['ok'] );
$this->assertStringContainsString( '7 cold fields', $result['message'] ); $this->assertStringContainsString( '3 cold fields', $result['message'] );
} }
public function test_score_aggregates_to_perfect_ten(): void { public function test_score_aggregates_to_perfect_ten(): void {
+1 -1
View File
@@ -7,7 +7,7 @@ declare(strict_types=1);
* Several existing wpdo tests (ZoneHotTest, SyncBridgeTest, etc.) replace the * Several existing wpdo tests (ZoneHotTest, SyncBridgeTest, etc.) replace the
* global $wpdb with their own anonymous-class mock and never restore it. When * global $wpdb with their own anonymous-class mock and never restore it. When
* our adapter tests run after those, $wpdb lacks the query/get_col/delete * our adapter tests run after those, $wpdb lacks the query/get_col/delete
* methods our mirror_insert / cron-optimizer code paths call, causing * methods our cron-optimizer code paths call, causing
* cross-test pollution failures. * cross-test pollution failures.
* *
* Trait reinstalls a known $wpdb mock + clears the wpdo_features option so * Trait reinstalls a known $wpdb mock + clears the wpdo_features option so