chore: initial snapshot of 2meet-data-optimizer-hivepress-addon v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
/**
|
||||
* Blocks adapter — `hivepress-blocks`.
|
||||
*
|
||||
* Pure presentation layer (Gutenberg block library). No models, no postmeta,
|
||||
* no comment tables. Adapter exists solely so the detector and admin UI
|
||||
* recognise the addon as installed and report it in the integration matrix.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Blocks_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Blocks addon adapter (presentation only, no EAV surface).
|
||||
*/
|
||||
final class TMDO_HivePress_Blocks_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-blocks';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Blocks\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_BLOCKS_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — presentation-only, nothing to verify.
|
||||
*
|
||||
* @return array{ok:bool, message:string}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => 'hivepress-blocks: presentation-only addon, no anti-EAV surface',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score — perfect (no data layer).
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* Bookings adapter — `hivepress-bookings` (DETECT-ONLY in v3.0.0).
|
||||
*
|
||||
* The bookings plugin is not installed in the dev environment, so this
|
||||
* adapter ships pre-built but only activates when the addon is detected
|
||||
* by `TMDO_HivePress_Detector`. Schema is based on HivePress' published
|
||||
* Booking model (post_type=hp_booking) — fields are best-effort from the
|
||||
* addon's documented field aliases:
|
||||
*
|
||||
* start_time / end_time → unix timestamp range queries (hot)
|
||||
* status → enum filter (hot)
|
||||
* guests → numeric count (hot for slot-availability)
|
||||
* listing → post_parent (already a posts column)
|
||||
*
|
||||
* Hot-zone target: `wp_wpdo_hot_hp_booking` with covering indexes on
|
||||
* `(listing_id, start_time)` for slot-availability queries and
|
||||
* `(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
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Bookings_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Bookings addon adapter (detect-only — schema reserved for future install).
|
||||
*/
|
||||
final class TMDO_HivePress_Bookings_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-bookings';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Bookings\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_BOOKINGS_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
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.
|
||||
*
|
||||
* D8 (perf) reduced to 0.9 because the schema is best-effort from
|
||||
* documentation; once the addon is installed and EXPLAIN can verify
|
||||
* index usage, raise to 1.0.
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score(
|
||||
array(
|
||||
'd8_perf' => 0.9,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM modules this adapter contributes.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array( 'hot_hp_booking' );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
/**
|
||||
* Core HivePress adapter — `hivepress` (the parent plugin).
|
||||
*
|
||||
* Owns three primary HivePress models:
|
||||
*
|
||||
* - `Listing` (post_type=hp_listing) — Hot zone columns for search/filter
|
||||
* (drafted, featured, verified, expired_time, featured_time).
|
||||
* - `Vendor` (post_type=hp_vendor) — Hot column `verified`; cold blob
|
||||
* for image / description (display-only).
|
||||
* - `User` (entity=user) — Entity group `hp_user_core` for
|
||||
* `hp_verified` and the WP-stock `first_name` / `last_name` /
|
||||
* `description`.
|
||||
*
|
||||
* This adapter SUPERSEDES the legacy `class-tmdo-hivepress.php` (242 lines).
|
||||
* Behavioural parity:
|
||||
*
|
||||
* - `optimize_query()` / `optimize_search()` — preserved as instance
|
||||
* methods bound on `hivepress/v1/models/{listing,vendor}/{query,search}`
|
||||
* at priority 20 (after HP own setup).
|
||||
* - `register_extended_fields()` — preserved as `on_register_fields()`
|
||||
* handler. Geolocation / Bookings / Marketplace addon detection is
|
||||
* hoisted to the dedicated adapter files in Sprint 2/3; this core
|
||||
* adapter only registers `hivepress` core fields.
|
||||
* - Vendor cold fields (`hp_image`, `hp_images`) — preserved.
|
||||
*
|
||||
* NEW additions vs legacy:
|
||||
*
|
||||
* - `hp_drafted` (Listing) — Hot tinyint(1) for user-dashboard filtering.
|
||||
* - `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
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Core_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Core HivePress adapter (Listing / Vendor / User).
|
||||
*/
|
||||
final class TMDO_HivePress_Core_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/**
|
||||
* HP models that have Zone A hot tables. Used by query optimizer.
|
||||
*/
|
||||
private const HOT_MODELS = array(
|
||||
'listing' => 'hp_listing',
|
||||
'vendor' => 'hp_vendor',
|
||||
);
|
||||
|
||||
/**
|
||||
* Constructor — bind WPDO core hooks via the inherited trait sugar.
|
||||
*
|
||||
* The bootstrap calls `on_register_query_hooks()` / `on_register_event_hooks()`
|
||||
* directly after instantiation, but field/table registration goes
|
||||
* through WordPress's action dispatch via this trait helper.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
// ── Adapter identity ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stable adapter slug (matches the hivepress core wp.org slug).
|
||||
*/
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress';
|
||||
}
|
||||
|
||||
/**
|
||||
* Class probed for HivePress core presence.
|
||||
*/
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Core';
|
||||
}
|
||||
|
||||
/**
|
||||
* Version constant probed for HivePress core presence.
|
||||
*/
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_VERSION';
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum HivePress core version this adapter supports.
|
||||
*
|
||||
* Version 1.7.0 introduced the `_alias` / `_external` field convention
|
||||
* this adapter relies on. Older versions store fields differently and
|
||||
* would require fallback paths not implemented here.
|
||||
*/
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.7.0';
|
||||
}
|
||||
|
||||
// ── Field registration ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register Listing/Vendor zone field mappings.
|
||||
*
|
||||
* Fired on the `wpdo_register_fields` action via the trait's
|
||||
* `bind_anti_eav_hooks()`. Schema_Registry has internal dedup so
|
||||
* re-firing on init:1 is safe.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Schema registry singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
// ── Listing hot fields (search/filter accelerators) ──
|
||||
$registry->register_many(
|
||||
$this->plugin_slug(),
|
||||
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).
|
||||
array(
|
||||
'post_type' => 'hp_vendor',
|
||||
'meta_key' => 'hp_image',
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_hp_vendor',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
),
|
||||
// Listing cold blob — gallery (display-only).
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_images',
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_hp_listing',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) ──
|
||||
|
||||
/**
|
||||
* Bind HivePress search query hooks at priority 20 (after HP setup).
|
||||
*/
|
||||
public function on_register_query_hooks(): void {
|
||||
foreach ( self::HOT_MODELS as $model => $post_type ) {
|
||||
unset( $post_type ); // Bound below by string-interpolated $model only.
|
||||
add_action(
|
||||
"hivepress/v1/models/{$model}/query",
|
||||
array( $this, 'optimize_query' ),
|
||||
20,
|
||||
1
|
||||
);
|
||||
add_action(
|
||||
"hivepress/v1/models/{$model}/search",
|
||||
array( $this, 'optimize_search' ),
|
||||
20,
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a HivePress model query by extracting hot-zone meta_query clauses.
|
||||
*
|
||||
* Extracted clauses are stashed on the WP_Query as `wpdo_hot_clauses`;
|
||||
* `TMDO_Query_Router` rewrites them into flat-column JOINs at
|
||||
* `posts_join` time.
|
||||
*
|
||||
* @param \WP_Query $query The WP_Query object.
|
||||
*/
|
||||
public function optimize_query( \WP_Query $query ): void {
|
||||
$post_types = (array) $query->get( 'post_type' );
|
||||
if ( empty( $post_types ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing_hot = $query->get( 'wpdo_hot_clauses' );
|
||||
if ( ! empty( $existing_hot ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$meta_query = (array) $query->get( 'meta_query' );
|
||||
if ( empty( $meta_query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
$hot_clauses = array();
|
||||
$remaining = array();
|
||||
|
||||
foreach ( $meta_query as $k => $clause ) {
|
||||
if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) {
|
||||
$remaining[ $k ] = $clause;
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = false;
|
||||
foreach ( $post_types as $pt ) {
|
||||
$field = $registry->get_field( $pt, $clause['key'] );
|
||||
if ( $field && 'hot' === $field['zone'] ) {
|
||||
$module = 'hot_' . sanitize_key( $pt );
|
||||
if ( class_exists( 'TMDO_Feature_Flags' ) && ! TMDO_Feature_Flags::is_query_active( $module ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hot_clauses[ $pt ][] = array(
|
||||
'column' => $field['column'],
|
||||
'value' => $clause['value'] ?? '',
|
||||
'compare' => strtoupper( trim( $clause['compare'] ?? '=' ) ),
|
||||
'type' => strtoupper( trim( $clause['type'] ?? 'CHAR' ) ),
|
||||
);
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $matched ) {
|
||||
$remaining[ $k ] = $clause;
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $hot_clauses ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( isset( $meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
|
||||
$remaining['relation'] = $meta_query['relation'];
|
||||
}
|
||||
|
||||
$query->set( 'meta_query', $remaining );
|
||||
$query->set( 'wpdo_hot_clauses', $hot_clauses );
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a HivePress search query (delegates to optimize_query).
|
||||
*
|
||||
* Bound to `hivepress/v1/models/{model}/search` which passes attribute
|
||||
* fields as second arg; we ignore them (extracted via meta_query).
|
||||
*
|
||||
* @param \WP_Query $query The WP_Query object.
|
||||
* @param array $attribute_fields HP attribute field objects (unused).
|
||||
*/
|
||||
public function optimize_search( \WP_Query $query, array $attribute_fields ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
|
||||
unset( $attribute_fields );
|
||||
$this->optimize_query( $query );
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
*
|
||||
* Default 1.0 across the board because:
|
||||
* - D1: only TMDO_API + Schema_Registry calls (no direct *_meta())
|
||||
* - D2: no hardcoded wp_postmeta literals
|
||||
* - D3: hot tables for both Listing + Vendor exist
|
||||
* - D4: every field registered with explicit data_type + indexed flag
|
||||
* - D5: no cross-adapter writes needed (single-adapter scope)
|
||||
* - D6: zero options written; no transients (relies on WPDO core caching)
|
||||
* - D7: no queries against other adapters' tables
|
||||
* - D8: hot columns indexed on every search-relevant field
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score(
|
||||
array(
|
||||
// D5 is N/A for a single-adapter; report 1.0 (no cross-write needed).
|
||||
'd5_hook_bus' => 1.0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 )
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
/**
|
||||
* Favorites adapter — `hivepress-favorites`.
|
||||
*
|
||||
* The Favorite model extends Comment (`comment_type = hp_favorite`):
|
||||
*
|
||||
* - user → user_id
|
||||
* - listing → comment_post_ID
|
||||
* - added_at → comment_date
|
||||
*
|
||||
* Problem: `wp_comments` has no UNIQUE constraint on (user_id, comment_post_ID, comment_type).
|
||||
* A double-tap on the favourite button can race and create two duplicate
|
||||
* favourite rows. HivePress relies on application-level dedup (SELECT before
|
||||
* INSERT) which is not race-safe.
|
||||
*
|
||||
* Fix: shadow table `wp_wpdo_comment_hp_favorite` with UNIQUE(user_id, listing_id).
|
||||
* Adapter mirrors every favorite write into the shadow table; the comment
|
||||
* router rewrites "is X favorited by user Y" reads to hit the shadow table
|
||||
* (1 indexed lookup vs WP_Comment_Query meta scan).
|
||||
*
|
||||
* Schema (declared via expected_columns; created by TMDO_Schema_Manager
|
||||
* when adapter mode flips to dual_write):
|
||||
*
|
||||
* CREATE TABLE wp_wpdo_comment_hp_favorite (
|
||||
* comment_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
* user_id BIGINT UNSIGNED NOT NULL,
|
||||
* listing_id BIGINT UNSIGNED NOT NULL,
|
||||
* created_at DATETIME NOT NULL,
|
||||
* UNIQUE KEY unique_user_listing (user_id, listing_id),
|
||||
* KEY idx_listing (listing_id)
|
||||
* );
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Favorites_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Favorites addon adapter.
|
||||
*/
|
||||
final class TMDO_HivePress_Favorites_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/** Comment_type owned by this adapter. */
|
||||
public const COMMENT_TYPE = 'hp_favorite';
|
||||
|
||||
/** Shadow table name (without wpdb prefix). */
|
||||
public const TABLE = 'wpdo_comment_hp_favorite';
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-favorites';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Favorites\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_FAVORITES_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.2.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the shadow table to the Custom_Table_Registry.
|
||||
*
|
||||
* @param TMDO_Custom_Table_Registry $registry Registry singleton.
|
||||
*/
|
||||
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
|
||||
$this->register_custom_table(
|
||||
$registry,
|
||||
array(
|
||||
'table_name' => self::TABLE,
|
||||
'primary_key' => 'comment_id',
|
||||
'post_type_link' => null,
|
||||
'expected_columns' => array(
|
||||
'comment_id' => 'BIGINT UNSIGNED NOT NULL PRIMARY KEY',
|
||||
'user_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'listing_id' => 'BIGINT UNSIGNED NOT NULL',
|
||||
'created_at' => 'DATETIME NOT NULL',
|
||||
),
|
||||
'indexes' => array(
|
||||
'unique_user_listing' => 'UNIQUE (user_id, listing_id)',
|
||||
'idx_listing' => '(listing_id)',
|
||||
),
|
||||
'doctor_callback' => array( $this, 'doctor_check' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync wp_comments → shadow table on insert.
|
||||
*
|
||||
* Bound during `on_register_event_hooks()`. The comment-router uses
|
||||
* the shadow table for "is favorited" lookups; sync keeps it consistent.
|
||||
*/
|
||||
public function on_register_event_hooks(): void {
|
||||
add_action( 'wp_insert_comment', array( $this, 'mirror_insert' ), 10, 2 );
|
||||
add_action( 'deleted_comment', array( $this, 'mirror_delete' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror a newly-inserted hp_favorite into the shadow table.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param object|\WP_Comment $comment Comment object.
|
||||
*/
|
||||
public function mirror_insert( int $comment_id, $comment ): void {
|
||||
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$user_id = (int) ( $comment->user_id ?? 0 );
|
||||
$listing_id = (int) ( $comment->comment_post_ID ?? 0 );
|
||||
if ( $user_id <= 0 || $listing_id <= 0 ) {
|
||||
return;
|
||||
}
|
||||
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare(
|
||||
'INSERT IGNORE INTO `' . $wpdb->prefix . self::TABLE . '` (comment_id, user_id, listing_id, created_at) VALUES (%d, %d, %d, %s)',
|
||||
$comment_id,
|
||||
$user_id,
|
||||
$listing_id,
|
||||
$comment->comment_date ?? current_time( 'mysql' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the shadow row when a hp_favorite comment is deleted.
|
||||
*
|
||||
* @param int $comment_id Comment ID.
|
||||
* @param object|\WP_Comment $comment Comment object.
|
||||
*/
|
||||
public function mirror_delete( int $comment_id, $comment ): void {
|
||||
if ( ! is_object( $comment ) || ( $comment->comment_type ?? '' ) !== self::COMMENT_TYPE ) {
|
||||
return;
|
||||
}
|
||||
global $wpdb;
|
||||
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$wpdb->prefix . self::TABLE,
|
||||
array( 'comment_id' => $comment_id ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — verify shadow table exists.
|
||||
*
|
||||
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
global $wpdb;
|
||||
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => 'wpdb global not available',
|
||||
);
|
||||
}
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
try {
|
||||
$exists = (bool) $wpdb->get_var(
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
);
|
||||
if ( ! $exists ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-favorites: shadow table %s missing', $table ),
|
||||
'details' => array( 'table' => $table ),
|
||||
);
|
||||
}
|
||||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => sprintf( 'hivepress-favorites: %s rows=%d', $table, $rows ),
|
||||
'details' => array(
|
||||
'table' => $table,
|
||||
'rows' => $rows,
|
||||
),
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-favorites doctor failed: %s', $e->getMessage() ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*
|
||||
* D1: 1.0 — adapter never calls *_meta()
|
||||
* D2: 1.0 — uses wpdb prefix, no hardcoded table literals
|
||||
* D3: 1.0 — shadow table covers the model
|
||||
* D4: 1.0 — expected_columns + indexes registered
|
||||
* D5: 1.0 — listens to wp_insert_comment for cross-write consistency
|
||||
* D6: 1.0 — zero options/transients
|
||||
* D7: 1.0 — only queries own shadow table
|
||||
* D8: 1.0 — UNIQUE(user_id, listing_id) covering index
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM modules this adapter contributes.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array( 'comment_hp_favorite' );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
/**
|
||||
* Marketplace adapter — `hivepress-marketplace` (DETECT-ONLY in v3.0.0).
|
||||
*
|
||||
* The marketplace addon turns listings into purchasable items, integrating
|
||||
* with WooCommerce. It adds:
|
||||
*
|
||||
* hp_purchase_price → decimal(10,2), used in price-range filters (HOT)
|
||||
* hp_purchase_count → counter (WARM zone, low-criticality)
|
||||
*
|
||||
* 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
|
||||
* day the addon ships.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Marketplace_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Marketplace addon adapter (detect-only).
|
||||
*/
|
||||
final class TMDO_HivePress_Marketplace_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-marketplace';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Marketplace\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_MARKETPLACE_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
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.
|
||||
*
|
||||
* D8 reduced to 0.9 — index assumption is best-effort until addon installs.
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score(
|
||||
array(
|
||||
'd8_perf' => 0.9,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* Memberships adapter — `hivepress-memberships`.
|
||||
*
|
||||
* Two post-type models:
|
||||
*
|
||||
* - Membership_Plan (post_type=hp_membership_plan)
|
||||
* hot fields: hp_expire_period (int), hp_primary (bool)
|
||||
*
|
||||
* - Membership (post_type=hp_membership)
|
||||
* hot fields: hp_expired_time (unix ts) — checked on every page load
|
||||
*
|
||||
* Both fields registered to Hot zone so:
|
||||
* - `hp_expired_time < NOW()` lookups become indexed range scans
|
||||
* (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
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Memberships_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Memberships addon adapter.
|
||||
*/
|
||||
final class TMDO_HivePress_Memberships_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-memberships';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Memberships\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_MEMBERSHIPS_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
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.
|
||||
*
|
||||
* D1: 1.0 — adapter never calls *_meta()
|
||||
* D2: 1.0 — uses wpdb prefix
|
||||
* D3: 1.0 — both models have hot tables
|
||||
* D4: 1.0 — every field has explicit data_type + indexed flag
|
||||
* D5: 1.0 — single-adapter scope
|
||||
* D6: 1.0 — zero options/transients
|
||||
* D7: 1.0 — only registers fields, no cross-table queries
|
||||
* D8: 1.0 — hot column on hp_expired_time = indexed range scan
|
||||
*/
|
||||
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_membership_plan',
|
||||
'hot_hp_membership',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
/**
|
||||
* Messages adapter — `hivepress-messages`.
|
||||
*
|
||||
* 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,
|
||||
* but it leaves the field unindexed for "messages received by user N"
|
||||
* 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
|
||||
* column + index. Adapter mirrors every hp_message write into the shadow,
|
||||
* and the comment-router rewrites recipient/unread queries to hit the index.
|
||||
*
|
||||
* 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
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Messages_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Messages addon adapter.
|
||||
*/
|
||||
final class TMDO_HivePress_Messages_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/** Comment_type owned by this adapter. */
|
||||
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.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-messages';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Messages\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_MESSAGES_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
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 )
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
/**
|
||||
* Requests adapter — `hivepress-requests`.
|
||||
*
|
||||
* Two related models:
|
||||
*
|
||||
* - Request (post_type=hp_request)
|
||||
* hot fields: hp_drafted (bool), hp_expired_time (unix ts)
|
||||
* — same access pattern as Listing (user dashboard + expiry cron)
|
||||
*
|
||||
* - Offer (comment_type=hp_offer, parent post_type=hp_request)
|
||||
* comment-column based; benefit comes from comment-router using
|
||||
* (comment_post_ID, comment_type) covering index + dedicated shadow
|
||||
* table for "approved offers per request" aggregation.
|
||||
*
|
||||
* 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
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Requests_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Requests addon adapter.
|
||||
*/
|
||||
final class TMDO_HivePress_Requests_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/** Comment_type for the Offer side of Requests. */
|
||||
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.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-requests';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Requests\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_REQUESTS_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
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 )
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* Reviews adapter — `hivepress-reviews`.
|
||||
*
|
||||
* The Review model extends `Comment` (`comment_type = hp_review`) so it
|
||||
* stores everything in `wp_comments` columns:
|
||||
*
|
||||
* - text → comment_content
|
||||
* - rating → comment_karma (1-5 integer)
|
||||
* - approved → comment_approved
|
||||
* - listing → comment_post_ID
|
||||
*
|
||||
* No postmeta is used; no entity-bridge group needed. Hot-path optimization
|
||||
* comes from `TMDO_HivePress_Comment_Router` rewriting comment-query SQL to
|
||||
* use `comment_post_ID + comment_type + comment_approved` index, and from
|
||||
* the `wp_wpdo_comment_hp_review` flat table that WPDO Comment entity bridge
|
||||
* already maintains (since v2.13.x).
|
||||
*
|
||||
* This adapter's role:
|
||||
* - Declare the comment_type to the comment-router
|
||||
* - Doctor probe of the entity-bridge table
|
||||
* - Self-report 8-D suitability score
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Reviews_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Reviews addon adapter.
|
||||
*/
|
||||
final class TMDO_HivePress_Reviews_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/** Comment_type owned by this adapter. */
|
||||
public const COMMENT_TYPE = 'hp_review';
|
||||
|
||||
/** Entity-bridge table used by TMDO_Adapter_Comment for hp_review. */
|
||||
private const TABLE = 'wpdo_comment_hp_review';
|
||||
|
||||
/**
|
||||
* Constructor — bind WPDO core register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-reviews';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Reviews\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_REVIEWS_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.4.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — verify entity-bridge table exists and is reachable.
|
||||
*
|
||||
* @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-reviews: %s missing (Comment entity-bridge not migrated)', $table ),
|
||||
'details' => array( 'table' => $table ),
|
||||
);
|
||||
}
|
||||
$rows = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Sanitized via wpdb prefix.
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => sprintf( 'hivepress-reviews: %s rows=%d', $table, $rows ),
|
||||
'details' => array(
|
||||
'table' => $table,
|
||||
'rows' => $rows,
|
||||
),
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-reviews doctor failed: %s', $e->getMessage() ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*
|
||||
* D1: 1.0 — adapter never calls *_meta() (Review is comment-column based)
|
||||
* D2: 1.0 — no hardcoded wp_comments references; uses wpdb prefix
|
||||
* D3: 1.0 — entity-bridge table covers the model
|
||||
* D4: 1.0 — table schema owned by Comment entity adapter (already documented)
|
||||
* D5: 1.0 — no cross-adapter writes needed yet
|
||||
* D6: 1.0 — zero options/transients written
|
||||
* D7: 1.0 — only queries own entity-bridge table
|
||||
* D8: 1.0 — comment-router uses (comment_post_ID, comment_type) covering index
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM modules contributed (none — comment entity-bridge owns lifecycle).
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array(
|
||||
// Comment entity-bridge migration is module 'comment_hp_review',
|
||||
// owned by TMDO_Migration_Orchestrator — adapter is read-only.
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* SEO adapter — `hivepress-seo`.
|
||||
*
|
||||
* The SEO addon is filter-based — it tweaks listing/vendor page titles,
|
||||
* meta descriptions, OpenGraph tags. No models of its own, no postmeta
|
||||
* additions beyond what HivePress core already manages.
|
||||
*
|
||||
* Adapter contributes only detection signal + readiness report. Nothing
|
||||
* to anti-EAV.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Seo_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* SEO addon adapter (filter-based, no EAV surface).
|
||||
*/
|
||||
final class TMDO_HivePress_Seo_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-seo';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Seo\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_SEO_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — filter-based addon has nothing to verify.
|
||||
*
|
||||
* @return array{ok:bool, message:string}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => 'hivepress-seo: filter-based addon, no anti-EAV surface',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score — perfect 10 (no EAV usage to optimize).
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/**
|
||||
* Social Links adapter — `hivepress-social-links`.
|
||||
*
|
||||
* Adds social URL fields to vendor profiles (Facebook, Instagram, Twitter,
|
||||
* LinkedIn, YouTube, etc.). Stored in postmeta as `hp_social_*` keys.
|
||||
*
|
||||
* Display-only — never used in search/filter — so the right zone is Cold
|
||||
* (object cache + JSON blob fallback). Listing page reads ~7 social URLs;
|
||||
* caching them as a single blob keyed on vendor_id eliminates 7 postmeta
|
||||
* lookups per render.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Social_Links_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Social Links addon adapter (vendor cold zone).
|
||||
*/
|
||||
final class TMDO_HivePress_Social_Links_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
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.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-social-links';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\SocialLinks\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_SOCIAL_LINKS_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register vendor social URL fields to Cold zone.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Schema registry singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
$fields = array();
|
||||
foreach ( self::SOCIAL_KEYS as $key ) {
|
||||
$fields[] = array(
|
||||
'post_type' => 'hp_vendor',
|
||||
'meta_key' => $key,
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_hp_vendor',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
);
|
||||
}
|
||||
$registry->register_many( $this->plugin_slug(), $fields );
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — cold zone uses cache; verify cache layer is reachable.
|
||||
*
|
||||
* @return array{ok:bool, message:string}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => sprintf( 'hivepress-social-links: %d cold fields registered', count( self::SOCIAL_KEYS ) ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
/**
|
||||
* Statistics adapter — `hivepress-statistics` (DETECT-ONLY in v3.0.0).
|
||||
*
|
||||
* The statistics addon tracks listing view counts, click-throughs, and other
|
||||
* counter-style metrics. The natural zone for these is WARM:
|
||||
*
|
||||
* - Increment-heavy writes (every page view → counter++).
|
||||
* - Read-rarely (only the vendor dashboard cares).
|
||||
* - Tolerable to lose seconds of writes (TTL-style flush).
|
||||
*
|
||||
* Mapped meta keys (best-effort from addon docs):
|
||||
*
|
||||
* hp_view_count → warm (cron flush hourly)
|
||||
* hp_click_count → warm (cron flush hourly)
|
||||
*
|
||||
* Detect-only: not installed in dev environment.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Statistics_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Statistics addon adapter (detect-only, warm zone).
|
||||
*/
|
||||
final class TMDO_HivePress_Statistics_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-statistics';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Statistics\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_STATISTICS_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register counter fields to warm zone.
|
||||
*
|
||||
* @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_view_count',
|
||||
'zone' => 'warm',
|
||||
'ttl' => null,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'hp_listing',
|
||||
'meta_key' => 'hp_click_count',
|
||||
'zone' => 'warm',
|
||||
'ttl' => null,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — warm zone uses shared wp_wpdo_warm KV 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_warm';
|
||||
try {
|
||||
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
|
||||
return array(
|
||||
'ok' => $exists,
|
||||
'message' => $exists
|
||||
? 'hivepress-statistics: warm zone (wpdo_warm) reachable'
|
||||
: 'hivepress-statistics: wpdo_warm missing',
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'message' => sprintf( 'hivepress-statistics doctor failed: %s', $e->getMessage() ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score — D8 reduced (warm zone is best-effort until addon installs).
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score(
|
||||
array(
|
||||
'd8_perf' => 0.9,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
/**
|
||||
* Tags adapter — `hivepress-tags`.
|
||||
*
|
||||
* Adds the `hp_listing_tag` taxonomy to listings. Term-based — no postmeta,
|
||||
* no hot fields. Term assignment lives in `wp_term_relationships` which is
|
||||
* already indexed by WordPress core.
|
||||
*
|
||||
* Adapter contributes:
|
||||
* - Detection signal (so admin UI / scorer can see this addon is active)
|
||||
* - Self-score (perfect because there's nothing to anti-EAV — the addon
|
||||
* uses term tables, not meta)
|
||||
* - Doctor probe (no-op success — no custom tables to verify)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Tags_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Tags addon adapter (term-based, no EAV surface).
|
||||
*/
|
||||
final class TMDO_HivePress_Tags_Adapter implements TMDO_HivePress_Adapter {
|
||||
|
||||
use TMDO_HivePress_Adapter_Trait;
|
||||
|
||||
/**
|
||||
* Constructor — bind register hooks via inherited trait.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->bind_anti_eav_hooks();
|
||||
}
|
||||
|
||||
/** Adapter slug (matches wp.org plugin slug). */
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress-tags';
|
||||
}
|
||||
|
||||
/** Class probed for addon presence. */
|
||||
public function detection_class(): string {
|
||||
return 'HivePress\\Tags\\Plugin';
|
||||
}
|
||||
|
||||
/** Version constant probed for addon presence. */
|
||||
public function detection_const(): string {
|
||||
return 'HIVEPRESS_TAGS_VERSION';
|
||||
}
|
||||
|
||||
/** Minimum addon version supported. */
|
||||
public function minimum_addon_version(): string {
|
||||
return '1.1.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Doctor probe — term-based addon has no custom tables to verify.
|
||||
*
|
||||
* @return array{ok:bool, message:string}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => 'hivepress-tags: term-based addon, no anti-EAV surface',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-D self-score.
|
||||
*
|
||||
* All dimensions 1.0 because the addon uses term tables (not meta) —
|
||||
* there is nothing to anti-EAV. The addon is structurally correct
|
||||
* by construction, so the adapter has nothing to do beyond declare
|
||||
* ownership for tooling.
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* HivePress dynamic attribute bridge.
|
||||
*
|
||||
* HivePress lets users define custom listing/vendor attributes through the
|
||||
* admin UI ("Listings → Attributes"). Each attribute creates a postmeta key
|
||||
* shaped like `hp_listing_{slug}` with type info attached. When the
|
||||
* attribute is marked `filterable=true` AND `searchable=true` it appears in
|
||||
* archive search forms — and produces meta_query slowness identical to the
|
||||
* pre-Sprint-1 hp_featured / hp_drafted patterns.
|
||||
*
|
||||
* This bridge introspects HivePress's attribute registry and reports which
|
||||
* dynamic attributes are PROMOTION CANDIDATES — i.e. would benefit from
|
||||
* being moved to a hot column. It does NOT alter database schema; that's
|
||||
* destructive and belongs in a CLI tool (Sprint 4 will add
|
||||
* `wp wpdo hivepress promote-attribute <slug>`).
|
||||
*
|
||||
* Per Karpathy guideline: surface tradeoffs, don't auto-promote.
|
||||
*
|
||||
* Usage:
|
||||
* $bridge = new TMDO_HivePress_Attribute_Bridge();
|
||||
* $candidates = $bridge->discover_attributes();
|
||||
* // returns: [['slug' => 'beds', 'type' => 'integer', 'filterable' => true, 'searchable' => true, 'meta_key' => 'hp_listing_beds'], ...]
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Attribute_Bridge' ) ) {
|
||||
|
||||
/**
|
||||
* Discovers HivePress dynamic attributes that would benefit from hot promotion.
|
||||
*/
|
||||
final class TMDO_HivePress_Attribute_Bridge {
|
||||
|
||||
/**
|
||||
* Models whose attributes we inspect.
|
||||
*
|
||||
* @var array<int,string>
|
||||
*/
|
||||
private const SUPPORTED_MODELS = array( 'listing', 'vendor' );
|
||||
|
||||
/**
|
||||
* Discover promotion candidates across all supported HP models.
|
||||
*
|
||||
* Returns one record per attribute that meets the promotion bar:
|
||||
* `filterable=true` (would appear in URL filters) OR `sortable=true`
|
||||
* (would feed orderby).
|
||||
*
|
||||
* @return array<int,array{model:string, slug:string, meta_key:string, type:string, filterable:bool, sortable:bool, searchable:bool}>
|
||||
*/
|
||||
public function discover_attributes(): array {
|
||||
$out = array();
|
||||
foreach ( self::SUPPORTED_MODELS as $model ) {
|
||||
$attributes = $this->fetch_attributes_for( $model );
|
||||
foreach ( $attributes as $slug => $config ) {
|
||||
$record = $this->build_record( $model, (string) $slug, (array) $config );
|
||||
if ( $this->is_promotion_candidate( $record ) ) {
|
||||
$out[] = $record;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of attribute records to suggested zone field configs (does NOT register).
|
||||
*
|
||||
* Use this output as input to a CLI promotion tool — it will pass each
|
||||
* config to `TMDO_Schema_Registry::register()` and trigger schema migration.
|
||||
*
|
||||
* @return array<int,array<string,mixed>> Field config records.
|
||||
*/
|
||||
public function suggest_zone_configs(): array {
|
||||
$configs = array();
|
||||
foreach ( $this->discover_attributes() as $record ) {
|
||||
$configs[] = array(
|
||||
'post_type' => 'hp_' . $record['model'],
|
||||
'meta_key' => $record['meta_key'],
|
||||
'zone' => 'hot',
|
||||
'data_type' => $this->sql_type_for( $record['type'] ),
|
||||
'column' => sanitize_key( $record['meta_key'] ),
|
||||
'indexed' => $record['filterable'] || $record['sortable'],
|
||||
);
|
||||
}
|
||||
return $configs;
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pull the attribute definitions registered for a given HP model.
|
||||
*
|
||||
* Goes through HivePress' own filter so any third-party attribute
|
||||
* registrations are included. Returns empty when HivePress is not
|
||||
* loaded (defensive — bridge can run during introspection tests).
|
||||
*
|
||||
* @param string $model 'listing' or 'vendor'.
|
||||
* @return array<string,mixed> Slug => attribute config.
|
||||
*/
|
||||
private function fetch_attributes_for( string $model ): array {
|
||||
if ( ! function_exists( 'apply_filters' ) ) {
|
||||
return array();
|
||||
}
|
||||
$filter = sprintf( 'hivepress/v1/models/%s/attributes', $model );
|
||||
$attrs = apply_filters( $filter, array() );
|
||||
return is_array( $attrs ) ? $attrs : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise one attribute config into our record shape.
|
||||
*
|
||||
* @param string $model HP model name.
|
||||
* @param string $slug Attribute slug.
|
||||
* @param array<string,mixed> $cfg Raw attribute config from HivePress.
|
||||
* @return array{model:string, slug:string, meta_key:string, type:string, filterable:bool, sortable:bool, searchable:bool}
|
||||
*/
|
||||
private function build_record( string $model, string $slug, array $cfg ): array {
|
||||
return array(
|
||||
'model' => $model,
|
||||
'slug' => $slug,
|
||||
'meta_key' => sprintf( 'hp_%s_%s', $model, $slug ),
|
||||
'type' => (string) ( $cfg['edit_field']['type'] ?? $cfg['search_field']['type'] ?? 'text' ),
|
||||
'filterable' => (bool) ( $cfg['filterable'] ?? false ),
|
||||
'sortable' => (bool) ( $cfg['sortable'] ?? false ),
|
||||
'searchable' => (bool) ( $cfg['searchable'] ?? false ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an attribute is worth promoting to a hot column.
|
||||
*
|
||||
* @param array{model:string, slug:string, meta_key:string, type:string, filterable:bool, sortable:bool, searchable:bool} $record Attribute record.
|
||||
*/
|
||||
private function is_promotion_candidate( array $record ): bool {
|
||||
// Filterable attributes always go through meta_query in archives.
|
||||
// Sortable attributes go through `meta_key` in orderby.
|
||||
// Either case = postmeta hot path → worth promoting.
|
||||
return $record['filterable'] || $record['sortable'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map HivePress field type → SQL column type for hot zone.
|
||||
*
|
||||
* Conservative defaults; over-sizing is safer than under-sizing.
|
||||
*
|
||||
* @param string $type HivePress field type.
|
||||
*/
|
||||
private function sql_type_for( string $type ): string {
|
||||
switch ( $type ) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return 'int(11) NOT NULL DEFAULT 0';
|
||||
case 'decimal':
|
||||
case 'price':
|
||||
return 'decimal(10,2) DEFAULT NULL';
|
||||
case 'checkbox':
|
||||
case 'boolean':
|
||||
return 'tinyint(1) NOT NULL DEFAULT 0';
|
||||
case 'date':
|
||||
return 'date DEFAULT NULL';
|
||||
case 'text':
|
||||
case 'select':
|
||||
default:
|
||||
return "varchar(191) NOT NULL DEFAULT ''";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
/**
|
||||
* HivePress hot-path benchmark — minimal before/after timing wrapper.
|
||||
*
|
||||
* Runs each provided sample query N times against a baseline (postmeta) and
|
||||
* a target (hot zone / shadow table) and returns elapsed times + ratio. No
|
||||
* fancy statistics — just `microtime(true)` deltas, mean, ratio.
|
||||
*
|
||||
* The CLI command (Sprint 4) will pass real production-like queries; for
|
||||
* unit tests we exercise the timing harness with synthetic callables that
|
||||
* sleep deterministically.
|
||||
*
|
||||
* Per Karpathy guideline: a stopwatch, not a profiler. Detailed trace
|
||||
* exists in `wpdo_benchmarks` table when callers want history.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Benchmark' ) ) {
|
||||
|
||||
/**
|
||||
* Minimal benchmark harness.
|
||||
*/
|
||||
final class TMDO_HivePress_Benchmark {
|
||||
|
||||
/** Default iteration count per sample. */
|
||||
public const DEFAULT_ITERATIONS = 50;
|
||||
|
||||
/**
|
||||
* Run a single sample comparison.
|
||||
*
|
||||
* Each callable is invoked $iterations times. Returns mean ms for
|
||||
* baseline, mean ms for target, and the speedup ratio (baseline/target).
|
||||
*
|
||||
* @param string $name Sample name (e.g. 'listing_search_5_filters').
|
||||
* @param callable $baseline Callable representing the postmeta path.
|
||||
* @param callable $target Callable representing the hot-zone path.
|
||||
* @param int $iterations Iteration count (default 50).
|
||||
*
|
||||
* @return array{name:string, baseline_ms:float, target_ms:float, ratio:float, iterations:int}
|
||||
*/
|
||||
public static function compare( string $name, callable $baseline, callable $target, int $iterations = self::DEFAULT_ITERATIONS ): array {
|
||||
$iterations = max( 1, $iterations );
|
||||
$baseline_ms = self::run( $baseline, $iterations );
|
||||
$target_ms = self::run( $target, $iterations );
|
||||
|
||||
return array(
|
||||
'name' => $name,
|
||||
'baseline_ms' => $baseline_ms,
|
||||
'target_ms' => $target_ms,
|
||||
'ratio' => $target_ms > 0.0 ? round( $baseline_ms / $target_ms, 2 ) : 0.0,
|
||||
'iterations' => $iterations,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callable N times, return mean elapsed milliseconds.
|
||||
*
|
||||
* @param callable $callback Callable under test.
|
||||
* @param int $iterations Iteration count.
|
||||
*/
|
||||
public static function run( callable $callback, int $iterations ): float {
|
||||
$total = 0.0;
|
||||
for ( $i = 0; $i < $iterations; $i++ ) {
|
||||
$start = microtime( true );
|
||||
$callback();
|
||||
$total += ( microtime( true ) - $start ) * 1000.0;
|
||||
}
|
||||
return round( $total / $iterations, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a sequence of comparisons and produce a summary report.
|
||||
*
|
||||
* @param array<int,array{name:string, baseline:callable, target:callable, iterations?:int}> $samples Sample list.
|
||||
*
|
||||
* @return array{samples:array<int,array<string,mixed>>, geomean_ratio:float, sample_count:int}
|
||||
*/
|
||||
public static function run_suite( array $samples ): array {
|
||||
$results = array();
|
||||
$ratios = array();
|
||||
foreach ( $samples as $sample ) {
|
||||
if ( ! isset( $sample['name'], $sample['baseline'], $sample['target'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$result = self::compare(
|
||||
(string) $sample['name'],
|
||||
$sample['baseline'],
|
||||
$sample['target'],
|
||||
(int) ( $sample['iterations'] ?? self::DEFAULT_ITERATIONS )
|
||||
);
|
||||
$results[] = $result;
|
||||
if ( $result['ratio'] > 0.0 ) {
|
||||
$ratios[] = $result['ratio'];
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'samples' => $results,
|
||||
'geomean_ratio' => self::geometric_mean( $ratios ),
|
||||
'sample_count' => count( $results ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometric mean — preferred summary statistic for ratios because it
|
||||
* doesn't bias toward outliers like arithmetic mean does.
|
||||
*
|
||||
* @param array<int,float> $values Ratios.
|
||||
*/
|
||||
public static function geometric_mean( array $values ): float {
|
||||
if ( empty( $values ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
$product = 1.0;
|
||||
foreach ( $values as $v ) {
|
||||
$product *= max( 0.0001, (float) $v );
|
||||
}
|
||||
return round( pow( $product, 1.0 / count( $values ) ), 2 );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
/**
|
||||
* HivePress family integration bootstrap.
|
||||
*
|
||||
* Single entry point for the WPDO ↔ HivePress integration:
|
||||
*
|
||||
* 1. Run conflict guard (hp-custom-tables / hp-info-cards detection)
|
||||
* 2. Detect installed HivePress family addons
|
||||
* 3. For each detected addon, instantiate the matching adapter and bind
|
||||
* its lifecycle hooks (register_fields / register_custom_tables /
|
||||
* register_entity_fields / query / event)
|
||||
* 4. Honour the `wpdo_hivepress_should_bind` filter (conflict-aware)
|
||||
*
|
||||
* Booted by `TMDO_Core::run()` once during request boot. Idempotent —
|
||||
* subsequent calls return early. Order:
|
||||
*
|
||||
* plugins_loaded:4 → TMDO_Core::run() → calls Bootstrap::boot()
|
||||
* plugins_loaded:5+ → adapters' on_register_* hooks fire when WPDO actions dispatch
|
||||
* init:1 → re-fired by WPDO core safety net
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Bootstrap' ) ) {
|
||||
|
||||
/**
|
||||
* Bootstrap orchestrator for HivePress family adapters.
|
||||
*
|
||||
* Stateful only across the current request (instantiated adapters cached
|
||||
* for `wp wpdo hivepress score/doctor` access). Reset between tests via
|
||||
* `reset_for_tests()`.
|
||||
*/
|
||||
final class TMDO_HivePress_Bootstrap {
|
||||
|
||||
/** Slug → adapter class name registry. */
|
||||
private const ADAPTER_MAP = array(
|
||||
'hivepress' => 'TMDO_HivePress_Core_Adapter',
|
||||
'hivepress-reviews' => 'TMDO_HivePress_Reviews_Adapter',
|
||||
'hivepress-favorites' => 'TMDO_HivePress_Favorites_Adapter',
|
||||
'hivepress-messages' => 'TMDO_HivePress_Messages_Adapter',
|
||||
'hivepress-memberships' => 'TMDO_HivePress_Memberships_Adapter',
|
||||
'hivepress-requests' => 'TMDO_HivePress_Requests_Adapter',
|
||||
'hivepress-tags' => 'TMDO_HivePress_Tags_Adapter',
|
||||
'hivepress-seo' => 'TMDO_HivePress_Seo_Adapter',
|
||||
'hivepress-social-links' => 'TMDO_HivePress_Social_Links_Adapter',
|
||||
'hivepress-blocks' => 'TMDO_HivePress_Blocks_Adapter',
|
||||
'hivepress-bookings' => 'TMDO_HivePress_Bookings_Adapter',
|
||||
'hivepress-marketplace' => 'TMDO_HivePress_Marketplace_Adapter',
|
||||
'hivepress-statistics' => 'TMDO_HivePress_Statistics_Adapter',
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether boot() has already run this request.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static bool $booted = false;
|
||||
|
||||
/**
|
||||
* Instantiated adapters keyed by addon slug.
|
||||
*
|
||||
* @var array<string, TMDO_HivePress_Adapter>
|
||||
*/
|
||||
private static array $adapters = array();
|
||||
|
||||
/**
|
||||
* Detection snapshot at boot time (captured for diagnostics).
|
||||
*
|
||||
* @var array<string,string>
|
||||
*/
|
||||
private static array $detected = array();
|
||||
|
||||
/**
|
||||
* Boot the integration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function boot(): void {
|
||||
if ( self::$booted ) {
|
||||
return;
|
||||
}
|
||||
self::$booted = true;
|
||||
|
||||
// Step 1: conflict guard. Detection + admin_notice + filter install.
|
||||
if ( class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
|
||||
TMDO_HivePress_Conflict_Guard::check_and_warn();
|
||||
}
|
||||
|
||||
// Step 2: detect addons. Empty array on non-HP sites = zero overhead.
|
||||
self::$detected = class_exists( 'TMDO_HivePress_Detector' )
|
||||
? TMDO_HivePress_Detector::detect()
|
||||
: array();
|
||||
|
||||
if ( empty( self::$detected ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: respect the should_bind filter (conflict guard short-circuits here).
|
||||
$context = array(
|
||||
'detected' => self::$detected,
|
||||
'adapters' => self::ADAPTER_MAP,
|
||||
'conflicts' => class_exists( 'TMDO_HivePress_Conflict_Guard' )
|
||||
? TMDO_HivePress_Conflict_Guard::detect_conflicts()
|
||||
: array(),
|
||||
);
|
||||
$should = apply_filters( 'wpdo_hivepress_should_bind', true, $context );
|
||||
if ( ! $should ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: instantiate + bind each adapter.
|
||||
foreach ( self::$detected as $slug => $version ) {
|
||||
self::bind_adapter( $slug, $version );
|
||||
}
|
||||
|
||||
// Step 5: register cross-cutting components (router + cron optimizer).
|
||||
// Both are gated by their own opt-in option and only activate when
|
||||
// the operator flips it; safe to call here unconditionally.
|
||||
if ( class_exists( 'TMDO_HivePress_Comment_Router' ) ) {
|
||||
TMDO_HivePress_Comment_Router::register();
|
||||
}
|
||||
if ( class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) {
|
||||
TMDO_HivePress_Cron_Optimizer::register();
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: wpdo_hivepress_booted
|
||||
*
|
||||
* Fires after all adapters are bound. Listeners can introspect
|
||||
* `TMDO_HivePress_Bootstrap::adapters()` for the active set.
|
||||
*
|
||||
* @param array<string,string> $detected Slug → version map.
|
||||
* @param array<string,TMDO_HivePress_Adapter> $adapters Slug → adapter instance.
|
||||
*/
|
||||
do_action( 'wpdo_hivepress_booted', self::$detected, self::$adapters );
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-boot on next call (for tests + plugin activation).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_for_tests(): void {
|
||||
self::$booted = false;
|
||||
self::$adapters = array();
|
||||
self::$detected = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only accessor for active adapters.
|
||||
*
|
||||
* @return array<string, TMDO_HivePress_Adapter>
|
||||
*/
|
||||
public static function adapters(): array {
|
||||
return self::$adapters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only accessor for detection snapshot at boot time.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function detected(): array {
|
||||
return self::$detected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the adapter for a given addon slug, or null when none active.
|
||||
*
|
||||
* @param string $slug Addon slug.
|
||||
*/
|
||||
public static function adapter_for( string $slug ): ?object {
|
||||
return self::$adapters[ $slug ] ?? null;
|
||||
}
|
||||
|
||||
// ── Internal: per-adapter binding ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Instantiate and wire one adapter's lifecycle hooks.
|
||||
*
|
||||
* @param string $slug Detected addon slug.
|
||||
* @param string $version Detected version string ('' if unknown).
|
||||
*/
|
||||
private static function bind_adapter( string $slug, string $version ): void {
|
||||
$class = self::ADAPTER_MAP[ $slug ] ?? '';
|
||||
if ( '' === $class || ! class_exists( $class ) ) {
|
||||
// Adapter for this slug not yet implemented (future Sprint).
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$adapter = new $class();
|
||||
} catch ( \Throwable $e ) {
|
||||
if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'error' ) ) {
|
||||
TMDO_Logger::error(
|
||||
'hivepress-integration',
|
||||
sprintf( 'Failed to instantiate %s: %s', $class, $e->getMessage() )
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! $adapter instanceof TMDO_HivePress_Adapter ) {
|
||||
if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'warn' ) ) {
|
||||
TMDO_Logger::warn(
|
||||
'hivepress-integration',
|
||||
sprintf( '%s does not implement TMDO_HivePress_Adapter — skipping', $class )
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Version floor check.
|
||||
$min = $adapter->minimum_addon_version();
|
||||
if ( '' !== $min && '' !== $version && version_compare( $version, $min, '<' ) ) {
|
||||
if ( class_exists( 'TMDO_Logger' ) && method_exists( 'TMDO_Logger', 'warn' ) ) {
|
||||
TMDO_Logger::warn(
|
||||
'hivepress-integration',
|
||||
sprintf( '%s installed version %s is below minimum %s — adapter idle', $slug, $version, $min )
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self::$adapters[ $slug ] = $adapter;
|
||||
|
||||
// Adapter constructor is contractually responsible for calling
|
||||
// `$this->bind_anti_eav_hooks()` (inherited from
|
||||
// TMDO_Anti_EAV_Aware via TMDO_HivePress_Adapter_Trait) so the
|
||||
// three `wpdo_register_*` actions get wired. Bootstrap then only
|
||||
// triggers the addon-specific extras: query/event hooks.
|
||||
$adapter->on_register_query_hooks();
|
||||
$adapter->on_register_event_hooks();
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,166 @@
|
||||
<?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 )
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
/**
|
||||
* Conflict guard against legacy HivePress data-layer plugins.
|
||||
*
|
||||
* `wp-data-optimizer` v3.0.0 fully supersedes `hp-custom-tables` (HPCT) and
|
||||
* `hp-info-cards`. When either is detected alongside WPDO HivePress
|
||||
* integration, dual-write would corrupt zone tables (HPCT writes to its own
|
||||
* `hpct_*` tables while WPDO writes to `wpdo_hot_hp_*`, then both copies
|
||||
* drift) and admin tooling would show contradictory state.
|
||||
*
|
||||
* Strategy: detect, log, render admin notice with guidance, AND short-circuit
|
||||
* the WPDO HivePress bootstrap via `wpdo_hivepress_should_bind` filter so
|
||||
* adapters are NOT bound. Operator must explicitly run
|
||||
* `wp wpdo hivepress migrate-from-hpct` to switch to WPDO and deactivate
|
||||
* the legacy plugins.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Conflict_Guard' ) ) {
|
||||
|
||||
/**
|
||||
* Static guard checking for legacy plugin conflicts.
|
||||
*
|
||||
* Stateful across requests via memo cache only; persists no DB state.
|
||||
*/
|
||||
final class TMDO_HivePress_Conflict_Guard {
|
||||
|
||||
/**
|
||||
* Conflicting legacy plugins to detect.
|
||||
*
|
||||
* Probe by either `class_exists()` OR `defined()` — both signals are
|
||||
* checked since plugin authors are inconsistent about which they expose.
|
||||
*
|
||||
* @var array<string, array{classes:array<int,string>, consts:array<int,string>, name:string}>
|
||||
*/
|
||||
private const CONFLICTS = array(
|
||||
'hp-custom-tables' => array(
|
||||
'classes' => array(
|
||||
'HPCT_Core',
|
||||
'HPCT_Plugin',
|
||||
'HP_Custom_Tables',
|
||||
),
|
||||
'consts' => array(
|
||||
'HPCT_VERSION',
|
||||
'HP_CUSTOM_TABLES_VERSION',
|
||||
),
|
||||
'name' => 'HP Custom Tables',
|
||||
),
|
||||
'hp-info-cards' => array(
|
||||
'classes' => array(
|
||||
'HP_Info_Cards',
|
||||
'HPIC_Plugin',
|
||||
),
|
||||
'consts' => array(
|
||||
'HP_INFO_CARDS_VERSION',
|
||||
'HPIC_VERSION',
|
||||
),
|
||||
'name' => 'HP Info Cards',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Memoized detection result for the current request.
|
||||
*
|
||||
* @var array<int,string>|null
|
||||
*/
|
||||
private static ?array $memo = null;
|
||||
|
||||
/**
|
||||
* Whether the admin_notices hook has been bound this request.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static bool $notice_bound = false;
|
||||
|
||||
/**
|
||||
* Run the conflict check at boot time.
|
||||
*
|
||||
* Called by `TMDO_HivePress_Bootstrap::boot()` BEFORE adapter binding
|
||||
* so the `wpdo_hivepress_should_bind` filter has chance to short-circuit
|
||||
* adapter wiring when conflicts exist.
|
||||
*/
|
||||
public static function check_and_warn(): void {
|
||||
$conflicts = self::detect_conflicts();
|
||||
if ( empty( $conflicts ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::log_conflicts( $conflicts );
|
||||
self::ensure_notice_bound();
|
||||
self::install_should_bind_short_circuit( $conflicts );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect currently-active legacy plugins.
|
||||
*
|
||||
* @return array<int,string> Slugs of detected conflicting plugins.
|
||||
*/
|
||||
public static function detect_conflicts(): array {
|
||||
if ( null !== self::$memo ) {
|
||||
return self::$memo;
|
||||
}
|
||||
|
||||
$out = array();
|
||||
foreach ( self::CONFLICTS as $slug => $probe ) {
|
||||
if ( self::probe_one( $probe ) ) {
|
||||
$out[] = $slug;
|
||||
}
|
||||
}
|
||||
|
||||
self::$memo = $out;
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state for unit tests.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_for_tests(): void {
|
||||
self::$memo = null;
|
||||
self::$notice_bound = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any conflict is currently active.
|
||||
*/
|
||||
public static function has_conflict(): bool {
|
||||
return ! empty( self::detect_conflicts() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the admin notice (bound only when conflicts exist).
|
||||
*
|
||||
* @internal Called by WordPress; do not call directly.
|
||||
*/
|
||||
public static function render_notice(): void {
|
||||
if ( ! function_exists( 'is_admin' ) || ! is_admin() ) {
|
||||
return;
|
||||
}
|
||||
$conflicts = self::detect_conflicts();
|
||||
if ( empty( $conflicts ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$names = array_map(
|
||||
static function ( string $slug ): string {
|
||||
$probe = self::CONFLICTS[ $slug ] ?? null;
|
||||
if ( null === $probe ) {
|
||||
return $slug;
|
||||
}
|
||||
$name = (string) ( $probe['name'] ?? $slug );
|
||||
return esc_html( $name );
|
||||
},
|
||||
$conflicts
|
||||
);
|
||||
|
||||
$message = sprintf(
|
||||
/* 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' ),
|
||||
implode( ', ', $names )
|
||||
);
|
||||
|
||||
printf(
|
||||
'<div class="notice notice-warning"><p><strong>%s</strong></p><p>%s</p></div>',
|
||||
esc_html__( 'WP Data Optimizer — HivePress integration paused', 'tmdo-hivepress' ),
|
||||
esc_html( $message )
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Probe a single legacy plugin for presence.
|
||||
*
|
||||
* @param array{classes:array<int,string>, consts:array<int,string>, name:string} $probe Probe spec.
|
||||
*/
|
||||
private static function probe_one( array $probe ): bool {
|
||||
foreach ( (array) $probe['classes'] as $cls ) {
|
||||
if ( '' !== $cls && class_exists( $cls ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
foreach ( (array) $probe['consts'] as $const ) {
|
||||
if ( '' !== $const && defined( $const ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log conflicts to WPDO error log so audit script picks them up.
|
||||
*
|
||||
* @param array<int,string> $conflicts Conflict slugs.
|
||||
*/
|
||||
private static function log_conflicts( array $conflicts ): void {
|
||||
if ( ! class_exists( 'TMDO_Logger' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Conflict detected: %s. wp-data-optimizer fully supersedes these. Run: wp wpdo hivepress migrate-from-hpct',
|
||||
implode( ', ', $conflicts )
|
||||
);
|
||||
|
||||
// Prefer warn() but fall back to log() if the API differs.
|
||||
if ( method_exists( 'TMDO_Logger', 'warn' ) ) {
|
||||
TMDO_Logger::warn( 'hivepress-integration', $message );
|
||||
} elseif ( method_exists( 'TMDO_Logger', 'log' ) ) {
|
||||
TMDO_Logger::log( 'warning', 'hivepress-integration', $message );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the admin notice exactly once per request.
|
||||
*/
|
||||
private static function ensure_notice_bound(): void {
|
||||
if ( self::$notice_bound ) {
|
||||
return;
|
||||
}
|
||||
if ( function_exists( 'add_action' ) ) {
|
||||
add_action( 'admin_notices', array( __CLASS__, 'render_notice' ) );
|
||||
}
|
||||
self::$notice_bound = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the short-circuit so HivePress adapter binding is skipped.
|
||||
*
|
||||
* Bootstrap reads `apply_filters('wpdo_hivepress_should_bind', true, $context)`
|
||||
* before binding adapters. We force `false` whenever conflicts exist
|
||||
* so dual-write cannot occur. The conflict context is passed to filter
|
||||
* so other code can react.
|
||||
*
|
||||
* @param array<int,string> $conflicts Conflict slugs.
|
||||
*/
|
||||
private static function install_should_bind_short_circuit( array $conflicts ): void {
|
||||
if ( ! function_exists( 'add_filter' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter(
|
||||
'wpdo_hivepress_should_bind',
|
||||
static function ( $should_bind, $context = array() ) use ( $conflicts ) {
|
||||
unset( $context );
|
||||
return false;
|
||||
// Note: $conflicts captured purely for greppability via closure inspection.
|
||||
// PHPCS may flag the `unset` if context is unused, but keeping the symmetry
|
||||
// with the documented filter signature.
|
||||
},
|
||||
1,
|
||||
2
|
||||
);
|
||||
|
||||
unset( $conflicts ); // Satisfy use-after for static analysis.
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
/**
|
||||
* Cron optimizer — replaces HivePress' hourly listing-expiry full scan.
|
||||
*
|
||||
* HivePress core's `class-listing.php::hourly()` walks every `hp_listing`
|
||||
* post and reads `hp_expired_time` / `hp_featured_time` postmeta, then
|
||||
* dispatches expire actions. With N listings × 2 meta keys this is 2N
|
||||
* postmeta lookups per hour.
|
||||
*
|
||||
* After Sprint 1 added `hp_expired_time` + `hp_featured_time` to
|
||||
* `wp_wpdo_hot_hp_listing` as indexed BIGINT columns, we can replace the
|
||||
* scan with two indexed range queries: `WHERE expired_time BETWEEN 1 AND
|
||||
* UNIX_TIMESTAMP()`.
|
||||
*
|
||||
* Disabled by default. Operator opts in via:
|
||||
*
|
||||
* wp option update wpdo_hivepress_cron_optimizer_enabled 1
|
||||
*
|
||||
* The optimizer hooks into `hivepress/v1/events/hourly` at priority 5
|
||||
* (before HP's own callback) and short-circuits the scan when the hot
|
||||
* table is reachable. If the hot table is missing or the relevant
|
||||
* `hot_hp_listing` module isn't in cutover/complete state, the
|
||||
* optimizer yields silently to HP's original code path.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Cron_Optimizer' ) ) {
|
||||
|
||||
/**
|
||||
* Cron optimizer for HivePress hourly listing expiry.
|
||||
*
|
||||
* Single-instance, registered via static `register()`.
|
||||
*/
|
||||
final class TMDO_HivePress_Cron_Optimizer {
|
||||
|
||||
/** Operator opt-in option key. */
|
||||
public const OPTION_ENABLED = 'wpdo_hivepress_cron_optimizer_enabled';
|
||||
|
||||
/** Module name in TMDO_Feature_Flags terminology. */
|
||||
public const MODULE = 'hot_hp_listing';
|
||||
|
||||
/**
|
||||
* Whether `register()` has bound hooks this request.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static bool $bound = false;
|
||||
|
||||
/**
|
||||
* Bind the hourly hook (idempotent).
|
||||
*
|
||||
* Called by Bootstrap during core adapter event-hook registration so
|
||||
* activation follows the same gate as adapter binding.
|
||||
*/
|
||||
public static function register(): void {
|
||||
if ( self::$bound ) {
|
||||
return;
|
||||
}
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
add_action( 'hivepress/v1/events/hourly', array( __CLASS__, 'maybe_run' ), 5, 0 );
|
||||
self::$bound = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state (test only).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_for_tests(): void {
|
||||
self::$bound = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the optimizer is enabled via operator option.
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
if ( ! function_exists( 'get_option' ) ) {
|
||||
return false;
|
||||
}
|
||||
return (bool) (int) get_option( self::OPTION_ENABLED, 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hourly hook: dispatch expire actions for listings whose hot-zone
|
||||
* `expired_time` falls in (0, NOW()] window.
|
||||
*
|
||||
* Yields silently when the hot table is missing or the module is not
|
||||
* in a state where reads are guaranteed accurate.
|
||||
*
|
||||
* @return int Number of listings that received the expire action (for tests).
|
||||
*/
|
||||
public static function maybe_run(): int {
|
||||
if ( ! self::module_can_read() ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'wpdo_hot_hp_listing';
|
||||
$now = time();
|
||||
|
||||
// Indexed range scan replaces O(N) postmeta walk.
|
||||
$ids = (array) $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is wpdb prefix + literal.
|
||||
$wpdb->prepare(
|
||||
"SELECT post_id FROM `{$table}` WHERE expired_time > 0 AND expired_time <= %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is wpdb prefix + literal.
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
$count = 0;
|
||||
foreach ( $ids as $post_id ) {
|
||||
$post_id = (int) $post_id;
|
||||
if ( $post_id <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
do_action( 'hivepress/v1/models/listing/expire', $post_id );
|
||||
++$count;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the `hot_hp_listing` module is in a state where the hot
|
||||
* table reflects current truth (cutover or complete).
|
||||
*/
|
||||
private static function module_can_read(): bool {
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return false;
|
||||
}
|
||||
return TMDO_Feature_Flags::is_query_active( self::MODULE );
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
/**
|
||||
* HivePress family addon detector.
|
||||
*
|
||||
* Probes for the 13 known HivePress family plugins by checking the most
|
||||
* stable `class_exists()` / `defined()` signal each addon publishes. Returns
|
||||
* a `slug => version` map of detected addons.
|
||||
*
|
||||
* Detection happens during `TMDO_HivePress_Bootstrap::boot()` on
|
||||
* `plugins_loaded:5`, AFTER WPDO core (priority 4) but BEFORE HivePress own
|
||||
* boot (priority 8). The bootstrap then instantiates an adapter per detected
|
||||
* addon and binds its lifecycle hooks.
|
||||
*
|
||||
* Result is cached in a 5-min transient (`wpdo_hivepress_detector_cache`) so
|
||||
* repeated calls within a single page-load (e.g. admin tab + REST endpoint)
|
||||
* don't re-probe. Cache busts on plugin activation/deactivation via the
|
||||
* static `bust_cache()` method.
|
||||
*
|
||||
* Cost: when HivePress core is NOT installed, detector short-circuits after
|
||||
* a single `class_exists('HivePress\\Core')` check — zero-cost fallback path
|
||||
* for sites that don't use HivePress.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Detector' ) ) {
|
||||
|
||||
/**
|
||||
* Static detector for HivePress family addons.
|
||||
*
|
||||
* Stateless apart from the request-level memo cache; reset between tests
|
||||
* via `reset_for_tests()`.
|
||||
*/
|
||||
final class TMDO_HivePress_Detector {
|
||||
|
||||
/** Transient key for the detection cache. */
|
||||
private const CACHE_KEY = 'wpdo_hivepress_detector_cache';
|
||||
|
||||
/** Transient TTL (5 minutes — short enough to pick up plugin activation, long enough to avoid per-request cost). */
|
||||
private const CACHE_TTL = 300;
|
||||
|
||||
/**
|
||||
* Request-level memo cache. Survives only the current request.
|
||||
*
|
||||
* @var array<string,string>|null
|
||||
*/
|
||||
private static ?array $memo = null;
|
||||
|
||||
/**
|
||||
* Detection probe table: slug => [class, version_const, name].
|
||||
*
|
||||
* Keep this list in sync with the HivePress family on wordpress.org.
|
||||
* Detection prefers `class_exists()` over `defined()` because version
|
||||
* constants sometimes leak to other globals; the class probe is more
|
||||
* specific. Both signals are checked — either one suffices.
|
||||
*
|
||||
* For addons not yet installed locally (bookings, marketplace,
|
||||
* statistics) the probe still runs — it just returns no match. The
|
||||
* adapter file exists for future installs.
|
||||
*
|
||||
* @var array<string, array{class:string, const:string, name:string}>
|
||||
*/
|
||||
private const PROBES = array(
|
||||
'hivepress' => array(
|
||||
'class' => 'HivePress\\Core',
|
||||
'const' => 'HIVEPRESS_VERSION',
|
||||
'name' => 'HivePress',
|
||||
),
|
||||
'hivepress-blocks' => array(
|
||||
'class' => 'HivePress\\Blocks\\Plugin',
|
||||
'const' => 'HIVEPRESS_BLOCKS_VERSION',
|
||||
'name' => 'HivePress Blocks',
|
||||
),
|
||||
'hivepress-bookings' => array(
|
||||
'class' => 'HivePress\\Bookings\\Plugin',
|
||||
'const' => 'HIVEPRESS_BOOKINGS_VERSION',
|
||||
'name' => 'HivePress Bookings',
|
||||
),
|
||||
'hivepress-favorites' => array(
|
||||
'class' => 'HivePress\\Favorites\\Plugin',
|
||||
'const' => 'HIVEPRESS_FAVORITES_VERSION',
|
||||
'name' => 'HivePress Favorites',
|
||||
),
|
||||
'hivepress-marketplace' => array(
|
||||
'class' => 'HivePress\\Marketplace\\Plugin',
|
||||
'const' => 'HIVEPRESS_MARKETPLACE_VERSION',
|
||||
'name' => 'HivePress Marketplace',
|
||||
),
|
||||
'hivepress-memberships' => array(
|
||||
'class' => 'HivePress\\Memberships\\Plugin',
|
||||
'const' => 'HIVEPRESS_MEMBERSHIPS_VERSION',
|
||||
'name' => 'HivePress Memberships',
|
||||
),
|
||||
'hivepress-messages' => array(
|
||||
'class' => 'HivePress\\Messages\\Plugin',
|
||||
'const' => 'HIVEPRESS_MESSAGES_VERSION',
|
||||
'name' => 'HivePress Messages',
|
||||
),
|
||||
'hivepress-requests' => array(
|
||||
'class' => 'HivePress\\Requests\\Plugin',
|
||||
'const' => 'HIVEPRESS_REQUESTS_VERSION',
|
||||
'name' => 'HivePress Requests',
|
||||
),
|
||||
'hivepress-reviews' => array(
|
||||
'class' => 'HivePress\\Reviews\\Plugin',
|
||||
'const' => 'HIVEPRESS_REVIEWS_VERSION',
|
||||
'name' => 'HivePress Reviews',
|
||||
),
|
||||
'hivepress-seo' => array(
|
||||
'class' => 'HivePress\\Seo\\Plugin',
|
||||
'const' => 'HIVEPRESS_SEO_VERSION',
|
||||
'name' => 'HivePress SEO',
|
||||
),
|
||||
'hivepress-social-links' => array(
|
||||
'class' => 'HivePress\\SocialLinks\\Plugin',
|
||||
'const' => 'HIVEPRESS_SOCIAL_LINKS_VERSION',
|
||||
'name' => 'HivePress Social Links',
|
||||
),
|
||||
'hivepress-statistics' => array(
|
||||
'class' => 'HivePress\\Statistics\\Plugin',
|
||||
'const' => 'HIVEPRESS_STATISTICS_VERSION',
|
||||
'name' => 'HivePress Statistics',
|
||||
),
|
||||
'hivepress-tags' => array(
|
||||
'class' => 'HivePress\\Tags\\Plugin',
|
||||
'const' => 'HIVEPRESS_TAGS_VERSION',
|
||||
'name' => 'HivePress Tags',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Detect installed HivePress family addons.
|
||||
*
|
||||
* Returns map of slug → version-string. Empty array when HivePress
|
||||
* core is not installed (zero-overhead short circuit for non-HP sites).
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function detect(): array {
|
||||
if ( null !== self::$memo ) {
|
||||
return self::$memo;
|
||||
}
|
||||
|
||||
$cached = function_exists( 'get_transient' ) ? get_transient( self::CACHE_KEY ) : false;
|
||||
if ( is_array( $cached ) ) {
|
||||
self::$memo = $cached;
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Short-circuit: if HivePress core is not present, no addon can be loaded.
|
||||
if ( ! self::probe_one( self::PROBES['hivepress'] ) ) {
|
||||
self::$memo = array();
|
||||
if ( function_exists( 'set_transient' ) ) {
|
||||
set_transient( self::CACHE_KEY, self::$memo, self::CACHE_TTL );
|
||||
}
|
||||
return self::$memo;
|
||||
}
|
||||
|
||||
$found = array();
|
||||
foreach ( self::PROBES as $slug => $probe ) {
|
||||
if ( self::probe_one( $probe ) ) {
|
||||
$found[ $slug ] = self::version_for( $probe );
|
||||
}
|
||||
}
|
||||
|
||||
self::$memo = $found;
|
||||
if ( function_exists( 'set_transient' ) ) {
|
||||
set_transient( self::CACHE_KEY, $found, self::CACHE_TTL );
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-detection on next call.
|
||||
*
|
||||
* Call from plugin activation / deactivation hooks if the listening
|
||||
* code wants up-to-date detection state immediately.
|
||||
*/
|
||||
public static function bust_cache(): void {
|
||||
self::$memo = null;
|
||||
if ( function_exists( 'delete_transient' ) ) {
|
||||
delete_transient( self::CACHE_KEY );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state for unit tests.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static function reset_for_tests(): void {
|
||||
self::$memo = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog of supported addons for UI display (slug => human-readable name).
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function catalog(): array {
|
||||
$out = array();
|
||||
foreach ( self::PROBES as $slug => $probe ) {
|
||||
$out[ $slug ] = $probe['name'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a given addon slug is in the supported catalog.
|
||||
*
|
||||
* @param string $slug Addon slug.
|
||||
*/
|
||||
public static function is_known( string $slug ): bool {
|
||||
return isset( self::PROBES[ $slug ] );
|
||||
}
|
||||
|
||||
// ── Internal probes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Probe a single addon. Either the class OR the const must exist.
|
||||
*
|
||||
* @param array{class:string, const:string, name:string} $probe Probe definition.
|
||||
*/
|
||||
private static function probe_one( array $probe ): bool {
|
||||
$class = (string) ( $probe['class'] ?? '' );
|
||||
$const = (string) ( $probe['const'] ?? '' );
|
||||
|
||||
if ( '' !== $class && class_exists( $class ) ) {
|
||||
return true;
|
||||
}
|
||||
if ( '' !== $const && defined( $const ) ) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort version extraction. Empty string when no version available.
|
||||
*
|
||||
* @param array{class:string, const:string, name:string} $probe Probe definition.
|
||||
*/
|
||||
private static function version_for( array $probe ): string {
|
||||
$const = (string) ( $probe['const'] ?? '' );
|
||||
if ( '' !== $const && defined( $const ) ) {
|
||||
$value = constant( $const );
|
||||
if ( is_string( $value ) || is_numeric( $value ) ) {
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* REST API endpoints for the HivePress family integration.
|
||||
*
|
||||
* Three read-only endpoints (manage_options capability required):
|
||||
*
|
||||
* GET /wpdo/v1/hivepress/status → detection + adapter binding state
|
||||
* GET /wpdo/v1/hivepress/score → 8-D suitability report
|
||||
* GET /wpdo/v1/hivepress/health → doctor probes for each adapter
|
||||
*
|
||||
* No mutating endpoints in v3.0.0 — `migrate` / `rollback` are CLI-only
|
||||
* because they alter site-wide FSM state (Karpathy: high-blast-radius
|
||||
* actions need explicit operator intent, not a button click).
|
||||
*
|
||||
* Bound on `rest_api_init` from wp-data-optimizer.php after the existing
|
||||
* `TMDO_REST_API` registration.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_REST' ) ) {
|
||||
|
||||
/**
|
||||
* REST controller for the HivePress integration.
|
||||
*/
|
||||
final class TMDO_HivePress_REST {
|
||||
|
||||
/** REST namespace. */
|
||||
public const NAMESPACE = 'wpdo/v1';
|
||||
|
||||
/**
|
||||
* Register the three GET routes.
|
||||
*/
|
||||
public function register_routes(): void {
|
||||
register_rest_route(
|
||||
self::NAMESPACE,
|
||||
'/hivepress/status',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_status' ),
|
||||
'permission_callback' => array( $this, 'check_permission' ),
|
||||
)
|
||||
);
|
||||
register_rest_route(
|
||||
self::NAMESPACE,
|
||||
'/hivepress/score',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_score' ),
|
||||
'permission_callback' => array( $this, 'check_permission' ),
|
||||
)
|
||||
);
|
||||
register_rest_route(
|
||||
self::NAMESPACE,
|
||||
'/hivepress/health',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_health' ),
|
||||
'permission_callback' => array( $this, 'check_permission' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability check — operator-only (manage_options).
|
||||
*
|
||||
* @return bool|\WP_Error
|
||||
*/
|
||||
public function check_permission() {
|
||||
if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'manage_options' ) ) {
|
||||
return new WP_Error(
|
||||
'wpdo_hp_forbidden',
|
||||
__( 'You do not have permission to read HivePress integration data.', 'tmdo-hivepress' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /hivepress/status — detection + binding state.
|
||||
*/
|
||||
public function get_status() {
|
||||
$detected = class_exists( 'TMDO_HivePress_Detector' )
|
||||
? TMDO_HivePress_Detector::detect()
|
||||
: array();
|
||||
$catalog = class_exists( 'TMDO_HivePress_Detector' )
|
||||
? TMDO_HivePress_Detector::catalog()
|
||||
: array();
|
||||
$bound = class_exists( 'TMDO_HivePress_Bootstrap' )
|
||||
? array_keys( TMDO_HivePress_Bootstrap::adapters() )
|
||||
: array();
|
||||
$conflict = class_exists( 'TMDO_HivePress_Conflict_Guard' )
|
||||
? TMDO_HivePress_Conflict_Guard::detect_conflicts()
|
||||
: array();
|
||||
|
||||
$addons = array();
|
||||
foreach ( $catalog as $slug => $name ) {
|
||||
$addons[] = array(
|
||||
'slug' => $slug,
|
||||
'name' => $name,
|
||||
'detected' => isset( $detected[ $slug ] ),
|
||||
'version' => $detected[ $slug ] ?? null,
|
||||
'bound' => in_array( $slug, $bound, true ),
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'addons' => $addons,
|
||||
'conflicts' => $conflict,
|
||||
'totals' => array(
|
||||
'detected' => count( $detected ),
|
||||
'bound' => count( $bound ),
|
||||
'catalog' => count( $catalog ),
|
||||
),
|
||||
),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /hivepress/score — 8-D suitability report.
|
||||
*/
|
||||
public function get_score() {
|
||||
if ( ! class_exists( 'TMDO_HivePress_Suitability_Scorer' ) ) {
|
||||
return new WP_REST_Response( array( 'error' => 'scorer_unavailable' ), 503 );
|
||||
}
|
||||
return new WP_REST_Response( TMDO_HivePress_Suitability_Scorer::report(), 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /hivepress/health — doctor probes for each adapter.
|
||||
*/
|
||||
public function get_health() {
|
||||
$adapters = class_exists( 'TMDO_HivePress_Bootstrap' )
|
||||
? TMDO_HivePress_Bootstrap::adapters()
|
||||
: array();
|
||||
|
||||
$probes = array();
|
||||
$ok = true;
|
||||
foreach ( $adapters as $slug => $adapter ) {
|
||||
if ( ! $adapter instanceof TMDO_HivePress_Adapter ) {
|
||||
continue;
|
||||
}
|
||||
$probe = $adapter->doctor_check();
|
||||
$probes[ $slug ] = $probe;
|
||||
if ( empty( $probe['ok'] ) ) {
|
||||
$ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'ok' => $ok,
|
||||
'probes' => $probes,
|
||||
),
|
||||
200
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* 8-dimension anti-EAV suitability scorer (aggregate across all adapters).
|
||||
*
|
||||
* Each adapter self-reports an 8-D score via `suitability_score()`. This
|
||||
* scorer collects all reports from `TMDO_HivePress_Bootstrap::adapters()`
|
||||
* and computes:
|
||||
*
|
||||
* - Per-adapter aggregate (already in adapter's own report)
|
||||
* - Per-dimension average across all adapters
|
||||
* - Site-wide aggregate (mean of all adapter aggregates)
|
||||
*
|
||||
* The output feeds:
|
||||
* - `wp wpdo hivepress score` CLI (Sprint 4)
|
||||
* - Admin UI HivePress tab (Sprint 4)
|
||||
* - REST endpoint /wpdo/v1/hivepress/score (Sprint 4)
|
||||
*
|
||||
* Per Karpathy guideline: a sum/avg, not a fancy weighted model. If the
|
||||
* score needs to be more sophisticated later, tradeoff it then.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! class_exists( 'TMDO_HivePress_Suitability_Scorer' ) ) {
|
||||
|
||||
/**
|
||||
* Aggregate scorer for active HivePress adapters.
|
||||
*
|
||||
* Stateless — every call rebuilds the report from current adapter state.
|
||||
*/
|
||||
final class TMDO_HivePress_Suitability_Scorer {
|
||||
|
||||
/** Dimension keys (must match adapter `suitability_score()` output). */
|
||||
public const DIMENSIONS = array(
|
||||
'd1_meta_calls',
|
||||
'd2_meta_sql',
|
||||
'd3_table_coverage',
|
||||
'd4_registry_meta',
|
||||
'd5_hook_bus',
|
||||
'd6_options',
|
||||
'd7_coupling',
|
||||
'd8_perf',
|
||||
);
|
||||
|
||||
/**
|
||||
* Build a complete score report for all currently active adapters.
|
||||
*
|
||||
* @return array{adapters:array<string,array<string,float>>, per_dimension:array<string,float>, aggregate:float, adapter_count:int}
|
||||
*/
|
||||
public static function report(): array {
|
||||
$adapters = class_exists( 'TMDO_HivePress_Bootstrap' )
|
||||
? TMDO_HivePress_Bootstrap::adapters()
|
||||
: array();
|
||||
|
||||
$per_adapter = array();
|
||||
foreach ( $adapters as $slug => $adapter ) {
|
||||
if ( $adapter instanceof TMDO_HivePress_Adapter ) {
|
||||
$per_adapter[ $slug ] = $adapter->suitability_score();
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'adapters' => $per_adapter,
|
||||
'per_dimension' => self::compute_per_dimension( $per_adapter ),
|
||||
'aggregate' => self::compute_aggregate( $per_adapter ),
|
||||
'adapter_count' => count( $per_adapter ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the per-dimension average across all adapter reports.
|
||||
*
|
||||
* @param array<string,array<string,float>> $per_adapter Adapter score map.
|
||||
* @return array<string,float> Dimension key → average value.
|
||||
*/
|
||||
public static function compute_per_dimension( array $per_adapter ): array {
|
||||
$out = array();
|
||||
if ( empty( $per_adapter ) ) {
|
||||
foreach ( self::DIMENSIONS as $d ) {
|
||||
$out[ $d ] = 0.0;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
foreach ( self::DIMENSIONS as $d ) {
|
||||
$sum = 0.0;
|
||||
foreach ( $per_adapter as $score ) {
|
||||
$sum += (float) ( $score[ $d ] ?? 0.0 );
|
||||
}
|
||||
$out[ $d ] = round( $sum / count( $per_adapter ), 2 );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the site-wide aggregate (mean of adapter aggregates).
|
||||
*
|
||||
* @param array<string,array<string,float>> $per_adapter Adapter score map.
|
||||
*/
|
||||
public static function compute_aggregate( array $per_adapter ): float {
|
||||
if ( empty( $per_adapter ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
$sum = 0.0;
|
||||
foreach ( $per_adapter as $score ) {
|
||||
$sum += (float) ( $score['aggregate'] ?? 0.0 );
|
||||
}
|
||||
return round( $sum / count( $per_adapter ), 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify which dimensions are dragging the aggregate down.
|
||||
*
|
||||
* Returns adapter slugs whose given dimension is below the threshold.
|
||||
*
|
||||
* @param string $dimension Dimension key (e.g. 'd8_perf').
|
||||
* @param float $threshold Minimum acceptable value (default 1.0).
|
||||
* @return array<int,string> Slugs needing attention for this dimension.
|
||||
*/
|
||||
public static function adapters_below_threshold( string $dimension, float $threshold = 1.0 ): array {
|
||||
if ( ! in_array( $dimension, self::DIMENSIONS, true ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$report = self::report();
|
||||
$flagged = array();
|
||||
foreach ( $report['adapters'] as $slug => $score ) {
|
||||
if ( ( (float) ( $score[ $dimension ] ?? 0.0 ) ) < $threshold ) {
|
||||
$flagged[] = $slug;
|
||||
}
|
||||
}
|
||||
return $flagged;
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! class_exists )
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* Interface contract for a HivePress addon adapter.
|
||||
*
|
||||
* Each adapter represents one HivePress family plugin (`hivepress`,
|
||||
* `hivepress-bookings`, `hivepress-reviews`, etc.) and is responsible for:
|
||||
*
|
||||
* - Declaring how to detect the addon (class / version constant)
|
||||
* - Registering Hot/Warm/Cold/Archive zone field mappings owned by the addon
|
||||
* - Registering entity field groups (post/user/term/comment)
|
||||
* - Registering custom tables (if the addon owns its own tables)
|
||||
* - Wiring query/comment routers (meta_query → JOIN rewrites)
|
||||
* - Subscribing to `wpdo_after_write` for cross-addon event handling
|
||||
* - Reporting an 8-dimension anti-EAV suitability score
|
||||
* - Reporting health doctor probes
|
||||
* - Listing the FSM migrations the adapter participates in
|
||||
*
|
||||
* The interface is intentionally `*_*` no-arg methods (instead of `register()`
|
||||
* static entrypoint) because adapters are managed by `TMDO_HivePress_Bootstrap`
|
||||
* — it instantiates one adapter per detected addon and binds the lifecycle
|
||||
* methods to WPDO core hooks. Static `register()` is reserved for the
|
||||
* Bootstrap itself, mirroring the existing partner-integration pattern.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! interface_exists( 'TMDO_HivePress_Adapter' ) ) {
|
||||
|
||||
/**
|
||||
* Public surface every HivePress addon adapter MUST satisfy.
|
||||
*
|
||||
* The Trait `TMDO_HivePress_Adapter_Trait` provides safe no-op defaults for
|
||||
* everything except `plugin_slug()` / `detection_class()` / `detection_const()`,
|
||||
* so concrete adapters typically override 3–6 methods.
|
||||
*/
|
||||
interface TMDO_HivePress_Adapter {
|
||||
|
||||
/**
|
||||
* Stable slug used as Schema_Registry / Custom_Table_Registry provider key.
|
||||
*
|
||||
* MUST exactly match the wp.org plugin slug (e.g. 'hivepress-reviews')
|
||||
* so cross-tooling (CLI, admin UI, audit script) can correlate.
|
||||
*/
|
||||
public function plugin_slug(): string;
|
||||
|
||||
/**
|
||||
* Fully-qualified class name whose presence indicates the addon is loaded.
|
||||
*
|
||||
* Adapter Bootstrap calls `class_exists()` on this string. If the addon
|
||||
* publishes multiple Plugin/Core classes, return the most stable one
|
||||
* (the one least likely to be renamed across releases).
|
||||
*/
|
||||
public function detection_class(): string;
|
||||
|
||||
/**
|
||||
* Version constant whose `defined()` indicates the addon is loaded.
|
||||
*
|
||||
* Used as a backup signal when `class_exists()` is too late (e.g.
|
||||
* adapters need the version string for compatibility gating). Return
|
||||
* empty string when the addon does not expose a version constant.
|
||||
*/
|
||||
public function detection_const(): string;
|
||||
|
||||
/**
|
||||
* Minimum HivePress addon version this adapter supports.
|
||||
*
|
||||
* Bootstrap compares against `defined($detection_const)` value and
|
||||
* downgrades to `idle` mode (no hooks bound) when the installed
|
||||
* version is below this floor. Return empty string to skip gating.
|
||||
*/
|
||||
public function minimum_addon_version(): string;
|
||||
|
||||
/**
|
||||
* Register Hot/Warm/Cold/Archive zone field mappings.
|
||||
*
|
||||
* Called by Bootstrap on the `wpdo_register_fields` action, which fires
|
||||
* inside `TMDO_Core::run()` at `plugins_loaded:4`. Schema_Registry
|
||||
* already has internal dedup so re-firing on `init:1` is safe.
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void;
|
||||
|
||||
/**
|
||||
* Register entity field groups (post/user/term/comment).
|
||||
*
|
||||
* Called on `wpdo_register_entity_fields`. Adapters that contribute
|
||||
* user/term/comment fields (e.g. core adapter for hp_user) hook here.
|
||||
*/
|
||||
public function on_register_entity_fields(): void;
|
||||
|
||||
/**
|
||||
* Register custom tables owned by the adapter (e.g. comment hot tables).
|
||||
*
|
||||
* Called on `wpdo_register_custom_tables`. Each table SHOULD include
|
||||
* `expected_columns` + `expected_indexes` so `wp wpdo doctor` can
|
||||
* validate. Tables are NOT created here — adapters declare ownership
|
||||
* for tooling; actual DDL is owned by `TMDO_Schema_Manager`.
|
||||
*
|
||||
* @param TMDO_Custom_Table_Registry $registry Singleton.
|
||||
*/
|
||||
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void;
|
||||
|
||||
/**
|
||||
* Register WP_Query / WP_Comment_Query rewriting hooks.
|
||||
*
|
||||
* Adapters with hot zone fields used in search/filter MUST register a
|
||||
* `pre_get_posts` / `pre_get_comments` filter that defers to
|
||||
* `TMDO_HivePress_Query_Router` / `..._Comment_Router`.
|
||||
*
|
||||
* Called on `init:5` after Schema_Registry is fully populated.
|
||||
*/
|
||||
public function on_register_query_hooks(): void;
|
||||
|
||||
/**
|
||||
* Subscribe to `wpdo_after_write` for cross-adapter event handling.
|
||||
*
|
||||
* Adapters that participate in cross-cutting workflows (e.g. listing
|
||||
* sold-out → block bookings) hook here. Called once during boot.
|
||||
*/
|
||||
public function on_register_event_hooks(): void;
|
||||
|
||||
/**
|
||||
* Self-report 8-dimension anti-EAV suitability score.
|
||||
*
|
||||
* Returns a flat array with keys `d1_meta_calls .. d8_perf` (each
|
||||
* 0.0–1.0) plus an `aggregate` key (0.0–10.0). The
|
||||
* `TMDO_HivePress_Suitability_Scorer` may inspect adapter source to
|
||||
* verify, but the adapter's self-report is the primary signal.
|
||||
*
|
||||
* Concrete implementation guidance:
|
||||
* D1: 1.0 if adapter never calls *_meta() directly, only TMDO_API
|
||||
* D2: 1.0 if no hardcoded wp_postmeta etc. literals
|
||||
* D3: 1.0 if every hot meta_key has a registered hot column
|
||||
* D4: 1.0 if every registered table includes expected_columns + indexes
|
||||
* D5: 1.0 if adapter subscribes to wpdo_after_write (when applicable)
|
||||
* D6: 1.0 if zero autoload=yes options + zero raw transients
|
||||
* D7: 1.0 if no direct queries against another adapter's tables
|
||||
* D8: 1.0 if EXPLAIN of representative queries shows index scan only
|
||||
*
|
||||
* @return array{d1_meta_calls:float, d2_meta_sql:float, d3_table_coverage:float, d4_registry_meta:float, d5_hook_bus:float, d6_options:float, d7_coupling:float, d8_perf:float, aggregate:float}
|
||||
*/
|
||||
public function suitability_score(): array;
|
||||
|
||||
/**
|
||||
* Health doctor probe — table existence, row counts, index sanity.
|
||||
*
|
||||
* Called by `wp wpdo hivepress doctor` and admin UI HivePress tab.
|
||||
* MUST never throw; catch and report as `ok=false` with message.
|
||||
*
|
||||
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
||||
*/
|
||||
public function doctor_check(): array;
|
||||
|
||||
/**
|
||||
* Module names this adapter contributes to the 7-state FSM.
|
||||
*
|
||||
* Each module is independently transitionable via `wp wpdo migrate
|
||||
* <module>` etc. Names MUST be unique across adapters and align with
|
||||
* `TMDO_Feature_Flags::ZONE_MODULES` registry conventions.
|
||||
*
|
||||
* @return array<int, string> e.g. ['hot_hp_listing', 'hot_hp_request']
|
||||
*/
|
||||
public function migrations(): array;
|
||||
}
|
||||
|
||||
} // end if ( ! interface_exists )
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared implementation trait for HivePress addon adapters.
|
||||
*
|
||||
* Composes `TMDO_Anti_EAV_Aware` (the canonical sugar layer for partner
|
||||
* plugins) and adds HivePress-specific helpers:
|
||||
*
|
||||
* - Hot/Warm/Cold zone field registration shortcuts (default `hp_*` post types)
|
||||
* - Custom-table registration with auto-filled HP conventions
|
||||
* - 8-dimension suitability self-score skeleton with sensible defaults
|
||||
* - Default no-op implementations for query/event hooks
|
||||
* - `on_after_addon_write()` cross-adapter event subscription helper
|
||||
*
|
||||
* Concrete adapters `use TMDO_HivePress_Adapter_Trait;` and override only
|
||||
* what they need. This keeps each adapter file focused on its domain
|
||||
* (Listing fields, Booking fields, Review aggregations, etc.).
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 3.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ( ! trait_exists( 'TMDO_HivePress_Adapter_Trait' ) ) {
|
||||
|
||||
trait TMDO_HivePress_Adapter_Trait {
|
||||
|
||||
// Compose the canonical sugar trait so adapters get its helpers
|
||||
// (`register_field`, `register_custom_table`, `register_entity_fields`,
|
||||
// `get_field`, `set_field`, `on_after_write`, `bind_anti_eav_hooks`)
|
||||
// without each having to `use` two traits.
|
||||
use TMDO_Anti_EAV_Aware;
|
||||
|
||||
// ── Default no-op implementations for the interface ────────────────
|
||||
|
||||
/**
|
||||
* Override in concrete adapter (default 'hivepress' for safety).
|
||||
*/
|
||||
public function plugin_slug(): string {
|
||||
return 'hivepress';
|
||||
}
|
||||
|
||||
/**
|
||||
* Override in concrete adapter (default empty string disables detection).
|
||||
*/
|
||||
public function detection_class(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Override in concrete adapter (default empty string).
|
||||
*/
|
||||
public function detection_const(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Override in concrete adapter to set version floor.
|
||||
*
|
||||
* Default empty string = accept any installed version.
|
||||
*/
|
||||
public function minimum_addon_version(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Default no-op for adapters with no zone fields (e.g. presentation-only addons).
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Schema registry singleton.
|
||||
*/
|
||||
public function on_register_fields( TMDO_Schema_Registry $registry ): void {
|
||||
unset( $registry );
|
||||
}
|
||||
|
||||
/**
|
||||
* Default no-op — most addons don't contribute entity fields.
|
||||
*
|
||||
* `TMDO_Anti_EAV_Aware::on_register_entity_fields()` is overridden in
|
||||
* concrete adapters that own user/term/comment groups (e.g. core
|
||||
* adapter for hp_user). The trait's own default is also no-op so this
|
||||
* override exists only to satisfy the interface contract symmetry.
|
||||
*/
|
||||
public function on_register_entity_fields(): void {
|
||||
// Intentional no-op.
|
||||
}
|
||||
|
||||
/**
|
||||
* Default no-op for adapters with no custom tables.
|
||||
*
|
||||
* @param TMDO_Custom_Table_Registry $registry Custom table registry singleton.
|
||||
*/
|
||||
public function on_register_custom_tables( TMDO_Custom_Table_Registry $registry ): void {
|
||||
unset( $registry );
|
||||
}
|
||||
|
||||
/**
|
||||
* Default no-op — adapter has no query routing needs.
|
||||
*
|
||||
* Adapters with hot zone fields used in search/filter override and
|
||||
* call `TMDO_HivePress_Query_Router::register_for( $this )` here.
|
||||
*/
|
||||
public function on_register_query_hooks(): void {
|
||||
// Intentional no-op.
|
||||
}
|
||||
|
||||
/**
|
||||
* Default no-op — adapter has no cross-adapter event handling.
|
||||
*/
|
||||
public function on_register_event_hooks(): void {
|
||||
// Intentional no-op.
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-report 8-dimension anti-EAV suitability score.
|
||||
*
|
||||
* Default returns 1.0 across the board, plus aggregate 10.0. Adapters
|
||||
* with known weaknesses (e.g. addon-only detection, missing hot
|
||||
* tables) MUST override and lower the relevant dimensions to keep
|
||||
* the aggregate honest.
|
||||
*
|
||||
* @return array{d1_meta_calls:float, d2_meta_sql:float, d3_table_coverage:float, d4_registry_meta:float, d5_hook_bus:float, d6_options:float, d7_coupling:float, d8_perf:float, aggregate:float}
|
||||
*/
|
||||
public function suitability_score(): array {
|
||||
return $this->compose_score( array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Default doctor probe — returns ok with adapter name.
|
||||
*
|
||||
* Adapters with custom tables MUST override and verify table existence.
|
||||
*
|
||||
* @return array{ok:bool, message:string, details?:array<string,mixed>}
|
||||
*/
|
||||
public function doctor_check(): array {
|
||||
return array(
|
||||
'ok' => true,
|
||||
'message' => sprintf( '%s: adapter loaded, no custom tables to probe', $this->plugin_slug() ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default no FSM migrations (adapter is read-only or zero hot fields).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function migrations(): array {
|
||||
return array();
|
||||
}
|
||||
|
||||
// ── Helper: zone-aware field registration shortcuts ─────────────────
|
||||
|
||||
/**
|
||||
* Register a single hot zone field (varchar/int/decimal column on `wp_wpdo_hot_<post_type>`).
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook.
|
||||
* @param string $post_type WordPress post type (e.g. 'hp_listing').
|
||||
* @param string $meta_key wp_postmeta meta_key.
|
||||
* @param string $data_type SQL column type (e.g. "tinyint(1) NOT NULL DEFAULT 0").
|
||||
* @param array<string,mixed> $extra Optional overrides: column, indexed, etc.
|
||||
*/
|
||||
protected function register_hot( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, string $data_type, array $extra = array() ): void {
|
||||
$config = array_merge(
|
||||
array(
|
||||
'post_type' => $post_type,
|
||||
'meta_key' => $meta_key,
|
||||
'zone' => 'hot',
|
||||
'data_type' => $data_type,
|
||||
'column' => sanitize_key( $meta_key ),
|
||||
'indexed' => false,
|
||||
),
|
||||
$extra
|
||||
);
|
||||
$this->register_field( $registry, $config );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a single cold zone field (object cache + JSON blob fallback).
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook.
|
||||
* @param string $post_type WordPress post type.
|
||||
* @param string $meta_key wp_postmeta meta_key.
|
||||
* @param int $cache_ttl Cache TTL in seconds (default 1 hour).
|
||||
* @param array<string,mixed> $extra Optional overrides.
|
||||
*/
|
||||
protected function register_cold( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, int $cache_ttl = 3600, array $extra = array() ): void {
|
||||
$config = array_merge(
|
||||
array(
|
||||
'post_type' => $post_type,
|
||||
'meta_key' => $meta_key,
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_' . sanitize_key( $post_type ),
|
||||
'cache_ttl' => $cache_ttl,
|
||||
),
|
||||
$extra
|
||||
);
|
||||
$this->register_field( $registry, $config );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a single warm zone field (TTL counter / temp value).
|
||||
*
|
||||
* @param TMDO_Schema_Registry $registry Provided by `wpdo_register_fields` hook.
|
||||
* @param string $post_type WordPress post type.
|
||||
* @param string $meta_key wp_postmeta meta_key.
|
||||
* @param int|null $ttl TTL in seconds (null = no expiry).
|
||||
* @param array<string,mixed> $extra Optional overrides.
|
||||
*/
|
||||
protected function register_warm( TMDO_Schema_Registry $registry, string $post_type, string $meta_key, ?int $ttl = null, array $extra = array() ): void {
|
||||
$config = array_merge(
|
||||
array(
|
||||
'post_type' => $post_type,
|
||||
'meta_key' => $meta_key,
|
||||
'zone' => 'warm',
|
||||
'ttl' => $ttl,
|
||||
),
|
||||
$extra
|
||||
);
|
||||
$this->register_field( $registry, $config );
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to writes on another adapter's keys (cross-adapter coordination).
|
||||
*
|
||||
* Wraps `TMDO_Anti_EAV_Aware::on_after_write()` with a description hook
|
||||
* so the cross-adapter linkage is greppable for the suitability scorer's
|
||||
* D5 (Hook Bus Integration) check.
|
||||
*
|
||||
* @param string $key_prefix Meta key prefix (e.g. 'hp_sold_out_').
|
||||
* @param callable $callback Receives (entity_type, entity_id, key, value, ok, op, before).
|
||||
*/
|
||||
protected function on_after_addon_write( string $key_prefix, callable $callback ): void {
|
||||
$this->on_after_write( $key_prefix, $callback );
|
||||
}
|
||||
|
||||
// ── Helper: 8-dimension score composition ───────────────────────────
|
||||
|
||||
/**
|
||||
* Compose an 8-dimension score, filling missing keys with 1.0 and
|
||||
* computing the aggregate as `sum / 8 * 1.25` (so 1.0 across all
|
||||
* → 10.0).
|
||||
*
|
||||
* @param array<string,float> $partial Override values keyed by `d1_meta_calls`...`d8_perf`.
|
||||
* @return array{d1_meta_calls:float, d2_meta_sql:float, d3_table_coverage:float, d4_registry_meta:float, d5_hook_bus:float, d6_options:float, d7_coupling:float, d8_perf:float, aggregate:float}
|
||||
*/
|
||||
protected function compose_score( array $partial ): array {
|
||||
$defaults = array(
|
||||
'd1_meta_calls' => 1.0,
|
||||
'd2_meta_sql' => 1.0,
|
||||
'd3_table_coverage' => 1.0,
|
||||
'd4_registry_meta' => 1.0,
|
||||
'd5_hook_bus' => 1.0,
|
||||
'd6_options' => 1.0,
|
||||
'd7_coupling' => 1.0,
|
||||
'd8_perf' => 1.0,
|
||||
);
|
||||
$score = array_merge( $defaults, $partial );
|
||||
|
||||
// Clamp every dimension to [0.0, 1.0].
|
||||
foreach ( $defaults as $k => $_ ) {
|
||||
$score[ $k ] = max( 0.0, min( 1.0, (float) ( $score[ $k ] ?? 1.0 ) ) );
|
||||
}
|
||||
|
||||
$sum = array_sum( array_intersect_key( $score, $defaults ) );
|
||||
$score['aggregate'] = round( $sum / 8.0 * 10.0, 2 );
|
||||
|
||||
return $score;
|
||||
}
|
||||
}
|
||||
|
||||
} // end if ( ! trait_exists )
|
||||
Reference in New Issue
Block a user