chore: initial snapshot of 2meet-data-optimizer 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,227 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Demo_Entity_Counter — production proof-of-life for entity adapters.
|
||||
*
|
||||
* Task D follow-up to PR-5: demonstrates that the entity adapter framework
|
||||
* actually works end-to-end (not just stubs). Implements a "counter" pattern
|
||||
* shared across post / user / term / comment entities — a common gamification
|
||||
* primitive (user points, listing views, comment helpful_count, term usage).
|
||||
*
|
||||
* Storage:
|
||||
* wp_wpdo_demo_entity_counters (entity_type, entity_id, counter_key, counter_value, updated_at)
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Plugin or test invokes ::set( 'user', 42, 'points', 50 )
|
||||
* - This writes to BOTH wp_usermeta (native, preserved) AND wpdo_demo table
|
||||
* - Reads come from wpdo_demo when feature flag entity_demo_counter == 'cutover',
|
||||
* otherwise fall through to native usermeta (transparent fallback)
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable Squiz.Commenting.FunctionComment.Missing,Squiz.Commenting.InlineComment.InvalidEndChar,Generic.Commenting.DocComment.MissingShort,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.PHP.YodaConditions,Generic.CodeAnalysis.EmptyStatement -- v2.0.0 partner integrations: pure registration helpers + intentional silent catches.
|
||||
|
||||
/**
|
||||
* Demo entity counter — proves 4-entity adapter framework works end-to-end.
|
||||
*/
|
||||
final class TMDO_Demo_Entity_Counter {
|
||||
|
||||
/** Feature flag module name. */
|
||||
public const MODULE = 'entity_demo_counter';
|
||||
|
||||
/** Custom table holding all counters. */
|
||||
public const TABLE = 'wpdo_demo_entity_counters';
|
||||
|
||||
/**
|
||||
* Idempotent install of the demo table.
|
||||
*
|
||||
* Called from TMDO_Installer::install_v2_tables() OR manually for demos.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function install_table(): void {
|
||||
global $wpdb;
|
||||
if ( ! function_exists( 'dbDelta' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
}
|
||||
|
||||
$charset = $wpdb->get_charset_collate();
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
entity_type varchar(20) NOT NULL DEFAULT '',
|
||||
entity_id bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||
counter_key varchar(100) NOT NULL DEFAULT '',
|
||||
counter_value bigint(20) NOT NULL DEFAULT 0,
|
||||
updated_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY ui_entity_counter (entity_type, entity_id, counter_key),
|
||||
KEY idx_lookup (entity_type, counter_key, counter_value),
|
||||
KEY idx_entity (entity_type, entity_id)
|
||||
) {$charset};";
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the demo table — idempotent.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function drop_table(): void {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a counter for any entity. Writes to BOTH native meta AND demo table
|
||||
* when feature flag is in a write-active state. Otherwise writes native
|
||||
* only (idle path — full backward compatibility).
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $counter_key Counter slug (e.g. 'points', 'view_count').
|
||||
* @param int $value New value.
|
||||
* @return bool
|
||||
*/
|
||||
public static function set( string $entity_type, int $entity_id, string $counter_key, int $value ): bool {
|
||||
if ( ! self::is_valid_entity( $entity_type ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always write the native meta first (durability anchor).
|
||||
TMDO_API::set_entity( $entity_type, $entity_id, $counter_key, $value );
|
||||
|
||||
// Conditional dual-write to demo table.
|
||||
if ( TMDO_Feature_Flags::is_write_active( self::MODULE ) ) {
|
||||
self::write_to_table( $entity_type, $entity_id, $counter_key, $value );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a counter value. Source depends on feature flag state:
|
||||
* - read_custom (cutover/cleanup/complete) → demo table
|
||||
* - otherwise → native meta (fallback)
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $counter_key Counter slug.
|
||||
* @return int
|
||||
*/
|
||||
public static function get( string $entity_type, int $entity_id, string $counter_key ): int {
|
||||
if ( ! self::is_valid_entity( $entity_type ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ( TMDO_Feature_Flags::is_read_custom( self::MODULE ) ) {
|
||||
$row = self::read_from_table( $entity_type, $entity_id, $counter_key );
|
||||
if ( null !== $row ) {
|
||||
return (int) $row;
|
||||
}
|
||||
// Fallback to native if zone row missing — graceful degradation.
|
||||
}
|
||||
|
||||
return (int) TMDO_API::get_entity( $entity_type, $entity_id, $counter_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-N entities by counter value — the killer query that postmeta CANNOT
|
||||
* do efficiently (requires full scan + filesort). Demonstrates the value of
|
||||
* the entity adapter pattern.
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param string $counter_key Counter slug.
|
||||
* @param int $limit Max rows.
|
||||
* @return array<int, array{entity_id:int, counter_value:int}>
|
||||
*/
|
||||
public static function top_n( string $entity_type, string $counter_key, int $limit = 10 ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from constant.
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT entity_id, counter_value FROM `{$table}` WHERE entity_type = %s AND counter_key = %s ORDER BY counter_value DESC LIMIT %d",
|
||||
$entity_type,
|
||||
$counter_key,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn( array $r ) => array(
|
||||
'entity_id' => (int) $r['entity_id'],
|
||||
'counter_value' => (int) $r['counter_value'],
|
||||
),
|
||||
$rows ?: array()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param string $entity_type Entity type.
|
||||
*/
|
||||
private static function is_valid_entity( string $entity_type ): bool {
|
||||
return in_array( $entity_type, array( 'post', 'user', 'term', 'comment' ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single counter value via UPSERT (1 round-trip).
|
||||
*
|
||||
* @param string $entity_type One of: post, user, term, comment.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $counter_key Counter slug.
|
||||
* @param int $value New value.
|
||||
* @return void
|
||||
*/
|
||||
private static function write_to_table( string $entity_type, int $entity_id, string $counter_key, int $value ): void {
|
||||
global $wpdb;
|
||||
TMDO_DB::upsert(
|
||||
$wpdb->prefix . self::TABLE,
|
||||
array(
|
||||
'entity_type' => $entity_type,
|
||||
'entity_id' => $entity_id,
|
||||
'counter_key' => $counter_key,
|
||||
'counter_value' => $value,
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'counter_value', 'updated_at' ),
|
||||
array( 'entity_type', 'entity_id', 'counter_key' ),
|
||||
array( '%s', '%d', '%s', '%d', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single counter value from the demo table.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param int $entity_id Entity ID.
|
||||
* @param string $counter_key Counter slug.
|
||||
* @return int|null Null when row absent.
|
||||
*/
|
||||
private static function read_from_table( string $entity_type, int $entity_id, string $counter_key ): ?int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . self::TABLE;
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from constant.
|
||||
$value = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT counter_value FROM `{$table}` WHERE entity_type = %s AND entity_id = %d AND counter_key = %s LIMIT 1",
|
||||
$entity_type,
|
||||
$entity_id,
|
||||
$counter_key
|
||||
)
|
||||
);
|
||||
return null === $value ? null : (int) $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
<?php
|
||||
/**
|
||||
* Member entity field registration for WP Data Optimizer.
|
||||
*
|
||||
* Registers four user entity groups designed for 千萬 (10M) member scale.
|
||||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_usermeta EAV.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers four user entity groups for membership, activity, profile, and SSO.
|
||||
*
|
||||
* Groups → flat tables:
|
||||
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
|
||||
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
|
||||
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
|
||||
* sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
|
||||
*
|
||||
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
|
||||
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
|
||||
*/
|
||||
final class TMDO_Member_Fields {
|
||||
|
||||
/**
|
||||
* Hook into WPDO entity field registration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all user entity groups.
|
||||
*
|
||||
* V2.7.0: Adds four legacy-key groups (core_profile, social, commerce, hp_user)
|
||||
* to absorb wp_usermeta rows that previously bypassed the entity bridge —
|
||||
* driving the wp_users:wp_usermeta ratio from 1:5.5 toward 1:2.5.
|
||||
*
|
||||
* V2.8.4: Adds admin_prefs group — the 7 default keys WP core writes for
|
||||
* EVERY new user via wp_insert_user (rich_editing, syntax_highlighting,
|
||||
* comment_shortcuts, admin_color, use_ssl, show_admin_bar_front,
|
||||
* dismissed_wp_pointers). Without this group, fresh users always show
|
||||
* ratio 1:9 regardless of any other optimization.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::register_membership_group();
|
||||
self::register_activity_group();
|
||||
self::register_profile_group();
|
||||
self::register_sso_group();
|
||||
self::register_core_profile_group();
|
||||
self::register_social_group();
|
||||
self::register_commerce_group();
|
||||
self::register_hp_user_group();
|
||||
self::register_admin_prefs_group();
|
||||
}
|
||||
|
||||
// ── Group definitions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tier level, points balance, expiry — primary search target.
|
||||
*
|
||||
* Extra composite indexes (idx_level_expires, idx_expires_level, idx_points_bal)
|
||||
* are applied by TMDO_Installer::install_member_indexes() after table creation.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_membership_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'membership',
|
||||
array(
|
||||
array(
|
||||
'key' => 'membership_level',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'bronze', 'silver', 'gold', 'platinum', 'custom' ),
|
||||
'label' => 'Membership tier level',
|
||||
),
|
||||
array(
|
||||
'key' => 'points_balance',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'default' => 0,
|
||||
'label' => 'Current points balance',
|
||||
),
|
||||
array(
|
||||
'key' => 'membership_expires_at',
|
||||
'type' => 'datetime',
|
||||
'searchable' => true,
|
||||
'label' => 'Membership expiry datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'membership_activated_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Membership activation datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'tier_source',
|
||||
'type' => 'text',
|
||||
'label' => 'Tier source: manual / wc_subscription / admin_set',
|
||||
),
|
||||
array(
|
||||
'key' => 'custom_tier_label',
|
||||
'type' => 'text',
|
||||
'label' => 'Custom tier display label (when level=custom)',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login counters and last-active timestamps — separated to avoid lock
|
||||
* contention with membership reads during high-traffic periods.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_activity_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'activity',
|
||||
array(
|
||||
array(
|
||||
'key' => 'login_count',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'default' => 0,
|
||||
'label' => 'Cumulative login count',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_active_at',
|
||||
'type' => 'datetime',
|
||||
'searchable' => true,
|
||||
'label' => 'Last activity datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_login_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Last login datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_order_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Last order datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'session_count',
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'label' => 'Total session count',
|
||||
),
|
||||
array(
|
||||
'key' => 'account_flags',
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'label' => 'Bitmask: 1=email_verified 2=phone_verified 4=kyc 8=social_signup',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display fields, specialties, avatar — written infrequently.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_profile_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'profile',
|
||||
array(
|
||||
array(
|
||||
'key' => 'specialties',
|
||||
'type' => 'json',
|
||||
'label' => 'Professional specialties (JSON array)',
|
||||
),
|
||||
array(
|
||||
'key' => 'bio_url',
|
||||
'type' => 'text',
|
||||
'label' => 'Bio or portfolio URL',
|
||||
),
|
||||
array(
|
||||
'key' => 'avatar_url',
|
||||
'type' => 'text',
|
||||
'label' => 'Avatar image URL',
|
||||
),
|
||||
array(
|
||||
'key' => 'display_name_custom',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'fulltext' => true,
|
||||
'label' => 'Custom display name (fulltext searchable)',
|
||||
),
|
||||
array(
|
||||
'key' => 'locale',
|
||||
'type' => 'text',
|
||||
'label' => 'User locale (e.g. zh_TW)',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
|
||||
*
|
||||
* Silent refresh fires every 15 min per user; at 10M users this is a
|
||||
* high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
|
||||
* load 10–50× versus a wp_usermeta EAV scan per refresh.
|
||||
*
|
||||
* last_id_token is NOT stored in plaintext (privacy + volume). Only the
|
||||
* SHA-256 hash is kept for SLO token comparison.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_sso_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'sso',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hub_global_user_id',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
|
||||
),
|
||||
array(
|
||||
'key' => 'picture_url',
|
||||
'type' => 'text',
|
||||
'label' => 'Social / SSO profile picture URL',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_id_token_hash',
|
||||
'type' => 'text',
|
||||
'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
|
||||
),
|
||||
array(
|
||||
'key' => 'refresh_token_enc',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
|
||||
),
|
||||
array(
|
||||
'key' => 'token_expires_at',
|
||||
'type' => 'datetime',
|
||||
'searchable' => true,
|
||||
'label' => 'SSO token expiry (set to past to force re-auth on next request)',
|
||||
),
|
||||
array(
|
||||
'key' => 'sso_last_login_at',
|
||||
'type' => 'datetime',
|
||||
'label' => 'Last SSO-initiated login datetime',
|
||||
),
|
||||
array(
|
||||
'key' => 'sso_login_count',
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'label' => 'SSO login count',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WP core user fields stored as multi-row EAV in wp_usermeta.
|
||||
*
|
||||
* Absorbing these here lets the Hook Bus short-circuit get_user_meta() /
|
||||
* update_user_meta() for the keys WP itself uses for display_name resolution
|
||||
* and the WP profile UI.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_core_profile_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'core_profile',
|
||||
array(
|
||||
array(
|
||||
'key' => 'nickname',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'WP nickname',
|
||||
),
|
||||
array(
|
||||
'key' => 'first_name',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'WP first name',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_name',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'WP last name',
|
||||
),
|
||||
array(
|
||||
'key' => 'description',
|
||||
'type' => 'textarea',
|
||||
'label' => 'WP user bio',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Social profile URLs (HivePress vendor-profile + WP user-contact-methods).
|
||||
*
|
||||
* 15 keys × 8 vendor users ≈ 120 EAV rows in this dataset.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_social_group(): void {
|
||||
$social_keys = array(
|
||||
'facebook',
|
||||
'twitter',
|
||||
'instagram',
|
||||
'youtube',
|
||||
'tiktok',
|
||||
'linkedin',
|
||||
'vimeo',
|
||||
'vkontakte',
|
||||
'mastodon',
|
||||
'medium',
|
||||
'wordpress',
|
||||
'odnoklassniki',
|
||||
'pinterest',
|
||||
'dribbble',
|
||||
'github',
|
||||
);
|
||||
|
||||
// Social URLs typed as `textarea` (TEXT) — VARCHAR(255) silently truncates
|
||||
// long share URLs (utm params, deep paths) under WP's default non-strict
|
||||
// SQL mode. TEXT (64KB) covers all realistic URL lengths.
|
||||
$fields = array();
|
||||
foreach ( $social_keys as $key ) {
|
||||
$fields[] = array(
|
||||
'key' => $key,
|
||||
'type' => 'textarea',
|
||||
'label' => ucfirst( $key ) . ' profile URL',
|
||||
);
|
||||
}
|
||||
|
||||
TMDO_Entity_Registry::register_group( 'user', 'social', $fields );
|
||||
}
|
||||
|
||||
/**
|
||||
* WooCommerce billing & shipping address fields.
|
||||
*
|
||||
* Billing_email is searchable for guest-checkout customer lookups.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_commerce_group(): void {
|
||||
$address_keys = array(
|
||||
'first_name',
|
||||
'last_name',
|
||||
'company',
|
||||
'address_1',
|
||||
'address_2',
|
||||
'city',
|
||||
'state',
|
||||
'postcode',
|
||||
'country',
|
||||
);
|
||||
|
||||
$fields = array();
|
||||
foreach ( $address_keys as $key ) {
|
||||
$fields[] = array(
|
||||
'key' => 'billing_' . $key,
|
||||
'type' => 'text',
|
||||
'label' => 'WC billing ' . str_replace( '_', ' ', $key ),
|
||||
);
|
||||
$fields[] = array(
|
||||
'key' => 'shipping_' . $key,
|
||||
'type' => 'text',
|
||||
'label' => 'WC shipping ' . str_replace( '_', ' ', $key ),
|
||||
);
|
||||
}
|
||||
|
||||
// Email + phone are billing-only.
|
||||
$fields[] = array(
|
||||
'key' => 'billing_email',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'WC billing email',
|
||||
);
|
||||
$fields[] = array(
|
||||
'key' => 'billing_phone',
|
||||
'type' => 'text',
|
||||
'label' => 'WC billing phone',
|
||||
);
|
||||
$fields[] = array(
|
||||
'key' => 'shipping_phone',
|
||||
'type' => 'text',
|
||||
'label' => 'WC shipping phone',
|
||||
);
|
||||
|
||||
TMDO_Entity_Registry::register_group( 'user', 'commerce', $fields );
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress per-user fields (favorites + avatar attachment).
|
||||
*
|
||||
* Hp_favorited_listings is a serialized array of post IDs in legacy storage;
|
||||
* the migration engine safe_unserialize()s it (allowed_classes=false to block
|
||||
* PHP-object injection) then the json type encodes back to a JSON array column.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_user_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'hp_user',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_favorited_listings',
|
||||
'type' => 'json',
|
||||
'label' => 'HivePress favorited listing IDs (array)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_image',
|
||||
'type' => 'text',
|
||||
'label' => 'HivePress avatar attachment ID',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WP-core admin pref defaults — written by `wp_insert_user()` for EVERY
|
||||
* new user regardless of role. Without registering these, a fresh user
|
||||
* lands at ratio 1:9 (7 admin prefs + wp_capabilities + wp_user_level).
|
||||
* Once registered, Hook Bus intercepts `update_user_meta()` calls from
|
||||
* `wp_insert_user()` and routes them to `wp_wpdo_user_admin_prefs` flat
|
||||
* table → fresh user ratio drops to 1:2.
|
||||
*
|
||||
* Values are stored as text because WP itself stores 'true'/'false'
|
||||
* strings (not booleans), 'fresh' / 'classic' (admin_color enum strings),
|
||||
* and integer-as-string for `use_ssl`. Preserving WP's textual storage
|
||||
* shape ensures downstream code (e.g. theme switchers reading
|
||||
* `admin_color`) sees the exact same value as before.
|
||||
*
|
||||
* @since 2.8.4
|
||||
* @return void
|
||||
*/
|
||||
private static function register_admin_prefs_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'admin_prefs',
|
||||
array(
|
||||
array(
|
||||
'key' => 'rich_editing',
|
||||
'type' => 'text',
|
||||
'label' => 'Visual editor enabled (true/false string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'syntax_highlighting',
|
||||
'type' => 'text',
|
||||
'label' => 'Code editor syntax highlighting (true/false string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'comment_shortcuts',
|
||||
'type' => 'text',
|
||||
'label' => 'Comment moderation keyboard shortcuts (true/false string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'admin_color',
|
||||
'type' => 'text',
|
||||
'label' => 'Admin colour scheme (fresh/classic/etc)',
|
||||
),
|
||||
array(
|
||||
'key' => 'use_ssl',
|
||||
'type' => 'text',
|
||||
'label' => 'Force SSL on admin (0/1 as string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'show_admin_bar_front',
|
||||
'type' => 'text',
|
||||
'label' => 'Show admin bar on front-end (true/false string)',
|
||||
),
|
||||
array(
|
||||
'key' => 'dismissed_wp_pointers',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Comma-separated dismissed pointer IDs',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
/**
|
||||
* Atomic points ledger manager for WP Data Optimizer.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages member points with atomic DB transactions.
|
||||
*
|
||||
* All balance mutations go through _transact(), which wraps
|
||||
* SELECT … FOR UPDATE + UPDATE membership + INSERT ledger inside a single
|
||||
* InnoDB transaction. This serialises concurrent debits and prevents the
|
||||
* classic double-spend race condition (two requests each read balance=100,
|
||||
* each deduct 60, each write balance=40).
|
||||
*
|
||||
* Table layout:
|
||||
* wp_wpdo_user_membership.points_balance — current snapshot balance
|
||||
* wp_wpdo_user_points_ledger — append-only journal
|
||||
*/
|
||||
final class TMDO_Points_Manager {
|
||||
|
||||
/**
|
||||
* Credit points to a user (positive delta).
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $delta Points to add (must be > 0).
|
||||
* @param string $reason Short reason code (≤60 chars).
|
||||
* @param int $ref_id Optional reference ID (order_id, post_id, …).
|
||||
* @param string $ref_type Optional reference type ('order', 'post', 'manual', …).
|
||||
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
|
||||
*/
|
||||
public static function credit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '' ): array {
|
||||
if ( $delta <= 0 ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'credit delta must be positive',
|
||||
);
|
||||
}
|
||||
return self::transact( $user_id, $delta, $reason, $ref_id, $ref_type, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Debit points from a user (negative delta applied internally).
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $delta Points to deduct (positive number; stored as negative).
|
||||
* @param string $reason Short reason code (≤60 chars).
|
||||
* @param int $ref_id Optional reference ID.
|
||||
* @param string $ref_type Optional reference type.
|
||||
* @param bool $allow_overdraft When true, debit proceeds even if balance < delta.
|
||||
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
|
||||
*/
|
||||
public static function debit( int $user_id, int $delta, string $reason = '', int $ref_id = 0, string $ref_type = '', bool $allow_overdraft = false ): array {
|
||||
if ( $delta <= 0 ) {
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'debit delta must be positive',
|
||||
);
|
||||
}
|
||||
return self::transact( $user_id, -$delta, $reason, $ref_id, $ref_type, $allow_overdraft );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current points balance for a user.
|
||||
*
|
||||
* Reads directly from the flat table, bypassing usermeta EAV.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @return int Balance (0 when user has no membership row).
|
||||
*/
|
||||
public static function get_balance( int $user_id ): int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_user_membership';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$balance = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT points_balance FROM `{$table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
return (int) ( $balance ?? 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve ledger entries for a user, newest-first.
|
||||
*
|
||||
* The idx_user_created covering index makes this O(log N) regardless of total rows.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $limit Max rows to return (default 20).
|
||||
* @param int $offset Row offset for pagination (default 0).
|
||||
* @return array<int, array{id:int, delta:int, balance_after:int, reason:string, ref_id:?int, ref_type:?string, created_at:string}>
|
||||
*/
|
||||
public static function get_ledger( int $user_id, int $limit = 20, int $offset = 0 ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpdo_user_points_ledger';
|
||||
|
||||
$limit = max( 1, min( 500, $limit ) );
|
||||
$offset = max( 0, $offset );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, delta, balance_after, reason, ref_id, ref_type, created_at FROM `{$table}` WHERE user_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id,
|
||||
$limit,
|
||||
$offset
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $rows ?: array();
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Core atomic transaction: SELECT … FOR UPDATE → validate → UPDATE balance → INSERT ledger.
|
||||
*
|
||||
* All balance mutations (credit and debit) route through this single method.
|
||||
* TMDO_DB::begin() must be called before SELECT … FOR UPDATE; otherwise
|
||||
* InnoDB ignores the lock hint and the serialisation guarantee is lost.
|
||||
*
|
||||
* @param int $user_id WordPress user ID.
|
||||
* @param int $delta Signed delta (positive = credit, negative = debit).
|
||||
* @param string $reason Reason code stored in ledger.
|
||||
* @param int $ref_id Reference ID (0 = none).
|
||||
* @param string $ref_type Reference type ('' = none).
|
||||
* @param bool $allow_overdraft Skip balance-floor check when true.
|
||||
* @return array{ok:bool, balance:int, ledger_id:int, error?:string}
|
||||
*/
|
||||
private static function transact( int $user_id, int $delta, string $reason, int $ref_id, string $ref_type, bool $allow_overdraft ): array {
|
||||
global $wpdb;
|
||||
$mem_table = $wpdb->prefix . 'wpdo_user_membership';
|
||||
$ledger_table = $wpdb->prefix . 'wpdo_user_points_ledger';
|
||||
|
||||
// Truncate reason to column width to avoid silent DB truncation.
|
||||
$reason = substr( $reason, 0, 60 );
|
||||
$ref_type = substr( $ref_type, 0, 30 );
|
||||
|
||||
TMDO_DB::begin();
|
||||
|
||||
try {
|
||||
// Lock the membership row for this user so concurrent writes wait.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$current_balance = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d FOR UPDATE", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
$current_balance = (int) ( $current_balance ?? 0 );
|
||||
|
||||
$new_balance = $current_balance + $delta;
|
||||
|
||||
// Reject negative-result debits unless overdraft is explicitly allowed.
|
||||
if ( ! $allow_overdraft && $new_balance < 0 ) {
|
||||
TMDO_DB::rollback();
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'insufficient_balance',
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert with relative increment — prevents concurrent first-credit race.
|
||||
// SELECT FOR UPDATE does not lock a non-existent row, so two simultaneous
|
||||
// first-credits both read balance=0. Using VALUES(points_balance) here means
|
||||
// InnoDB serialises the two INSERTs: the loser hits ON DUPLICATE KEY and
|
||||
// applies a relative +delta instead of overwriting with an absolute value.
|
||||
$upserted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO `{$mem_table}` (user_id, points_balance) VALUES (%d, %d) ON DUPLICATE KEY UPDATE points_balance = points_balance + VALUES(points_balance)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id,
|
||||
$delta
|
||||
)
|
||||
);
|
||||
|
||||
if ( false === $upserted ) {
|
||||
TMDO_DB::rollback();
|
||||
TMDO_Logger::error( 'points_manager', 'transact', "Membership upsert failed for user {$user_id}: {$wpdb->last_error}" );
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'db_error',
|
||||
);
|
||||
}
|
||||
|
||||
// Re-read actual balance so ledger and return value are correct even when
|
||||
// ON DUPLICATE KEY UPDATE resolved a concurrent race on the first upsert.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$new_balance = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT points_balance FROM `{$mem_table}` WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$user_id
|
||||
)
|
||||
);
|
||||
|
||||
// Append ledger row.
|
||||
$ledger_data = array(
|
||||
'user_id' => $user_id,
|
||||
'delta' => $delta,
|
||||
'balance_after' => $new_balance,
|
||||
'reason' => $reason,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
);
|
||||
$ledger_fmt = array( '%d', '%d', '%d', '%s', '%s' );
|
||||
|
||||
if ( $ref_id ) {
|
||||
$ledger_data['ref_id'] = $ref_id;
|
||||
$ledger_fmt[] = '%d';
|
||||
}
|
||||
if ( '' !== $ref_type ) {
|
||||
$ledger_data['ref_type'] = $ref_type;
|
||||
$ledger_fmt[] = '%s';
|
||||
}
|
||||
|
||||
$inserted = $wpdb->insert( $ledger_table, $ledger_data, $ledger_fmt );
|
||||
|
||||
if ( false === $inserted ) {
|
||||
TMDO_DB::rollback();
|
||||
TMDO_Logger::error( 'points_manager', 'transact', "Ledger insert failed for user {$user_id}: {$wpdb->last_error}" );
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'db_error',
|
||||
);
|
||||
}
|
||||
|
||||
$ledger_id = (int) $wpdb->insert_id;
|
||||
|
||||
TMDO_DB::commit();
|
||||
|
||||
// Notify subscribers — match Hook Bus signature: (type, id, key, value, result, op, before).
|
||||
do_action( 'wpdo_after_write', 'user', $user_id, 'points_balance', $new_balance, true, 'update', $current_balance );
|
||||
|
||||
return array(
|
||||
'ok' => true,
|
||||
'balance' => $new_balance,
|
||||
'ledger_id' => $ledger_id,
|
||||
);
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
TMDO_DB::rollback();
|
||||
TMDO_Logger::error( 'points_manager', 'transact', $e->getMessage() );
|
||||
return array(
|
||||
'ok' => false,
|
||||
'error' => 'exception',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
<?php
|
||||
/**
|
||||
* Post entity field registration for WP Data Optimizer (v2.9.1).
|
||||
*
|
||||
* Registers seven post entity groups designed to absorb the bulk of
|
||||
* wp_postmeta rows that previously bypassed any flat-table strategy.
|
||||
* All groups use TMDO_Entity_Registry → flat tables instead of wp_postmeta EAV.
|
||||
*
|
||||
* Groups → flat tables (post_type targets):
|
||||
* wp_core → wp_wpdo_post_wp_core (cross post_type)
|
||||
* attachment → wp_wpdo_post_attachment (attachment)
|
||||
* wc_product → wp_wpdo_post_wc_product (product)
|
||||
* hp_listing_core → wp_wpdo_post_hp_listing_core (hp_listing)
|
||||
* hp_request_core → wp_wpdo_post_hp_request_core (hp_request)
|
||||
* hp_vendor_core → wp_wpdo_post_hp_vendor_core (hp_vendor)
|
||||
* nav_menu_item → wp_wpdo_post_nav_menu_item (nav_menu_item)
|
||||
*
|
||||
* Pattern mirrors TMDO_Member_Fields::register(). Called from
|
||||
* TMDO_Core::run() before the wpdo_register_entity_fields action fires;
|
||||
* Schema_Manager materializes the seven flat tables in init:1
|
||||
* via process_pending_migrations() (idempotent via schema_hash compare).
|
||||
*
|
||||
* Keys not registered here (HivePress dynamic attrs, WPCS legacy keys,
|
||||
* etc.) pass through to wp_postmeta unchanged — same fall-back behavior
|
||||
* as user entity bridge.
|
||||
*
|
||||
* 🔒 v2.9.x frozen contract: this class must NEVER call register_group()
|
||||
* with entity_type='user'. User entity registration is owned exclusively
|
||||
* by TMDO_Member_Fields.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.9.1
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers seven post entity groups.
|
||||
*/
|
||||
final class TMDO_Post_Fields {
|
||||
|
||||
/**
|
||||
* Hook into WPDO entity field registration.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register(): void {
|
||||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_entity_fields' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all post entity groups.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::register_wp_core_group();
|
||||
self::register_attachment_group();
|
||||
self::register_wc_product_group();
|
||||
self::register_hp_listing_core_group();
|
||||
self::register_hp_request_core_group();
|
||||
self::register_hp_vendor_core_group();
|
||||
self::register_nav_menu_item_group();
|
||||
}
|
||||
|
||||
// ── Group definitions ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* WP core post meta keys present across post_types.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_wp_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'wp_core',
|
||||
array(
|
||||
array(
|
||||
'key' => '_thumbnail_id',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Featured image attachment ID',
|
||||
),
|
||||
array(
|
||||
'key' => '_wp_page_template',
|
||||
'type' => 'text',
|
||||
'label' => 'Page template slug',
|
||||
),
|
||||
array(
|
||||
'key' => '_edit_last',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Last editor user ID',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment-specific meta keys (post_type=attachment).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_attachment_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'attachment',
|
||||
array(
|
||||
array(
|
||||
'key' => '_wp_attached_file',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'Relative path of the attached file',
|
||||
),
|
||||
array(
|
||||
'key' => '_wp_attachment_metadata',
|
||||
'type' => 'json',
|
||||
'label' => 'Image dimensions / EXIF / sizes array',
|
||||
),
|
||||
array(
|
||||
'key' => '_wp_attachment_image_alt',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Alt text for accessibility',
|
||||
),
|
||||
array(
|
||||
'key' => '_wp_attachment_caption',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Attachment caption',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* WooCommerce product core meta keys (post_type=product).
|
||||
* 19 keys — covers ~94% of wp_postmeta rows for products on dev10.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_wc_product_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'wc_product',
|
||||
array(
|
||||
array(
|
||||
'key' => '_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Effective price',
|
||||
),
|
||||
array(
|
||||
'key' => '_regular_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Regular price',
|
||||
),
|
||||
array(
|
||||
'key' => '_sale_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Sale price',
|
||||
),
|
||||
array(
|
||||
'key' => '_stock',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Stock quantity',
|
||||
),
|
||||
array(
|
||||
'key' => '_stock_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'instock', 'outofstock', 'onbackorder' ),
|
||||
'label' => 'Stock status',
|
||||
),
|
||||
array(
|
||||
'key' => '_sku',
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'label' => 'Product SKU',
|
||||
),
|
||||
array(
|
||||
'key' => '_manage_stock',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Manage stock?',
|
||||
),
|
||||
array(
|
||||
'key' => '_backorders',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no', 'notify' ),
|
||||
'label' => 'Allow backorders?',
|
||||
),
|
||||
array(
|
||||
'key' => '_sold_individually',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Sold individually?',
|
||||
),
|
||||
array(
|
||||
'key' => '_virtual',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Virtual product?',
|
||||
),
|
||||
array(
|
||||
'key' => '_downloadable',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'yes', 'no' ),
|
||||
'label' => 'Downloadable?',
|
||||
),
|
||||
array(
|
||||
'key' => '_tax_class',
|
||||
'type' => 'text',
|
||||
'label' => 'Tax class slug',
|
||||
),
|
||||
array(
|
||||
'key' => '_tax_status',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'taxable', 'shipping', 'none' ),
|
||||
'label' => 'Tax status',
|
||||
),
|
||||
array(
|
||||
'key' => '_download_limit',
|
||||
'type' => 'integer',
|
||||
'label' => 'Download limit',
|
||||
),
|
||||
array(
|
||||
'key' => '_download_expiry',
|
||||
'type' => 'integer',
|
||||
'label' => 'Download expiry days',
|
||||
),
|
||||
array(
|
||||
'key' => '_product_version',
|
||||
'type' => 'text',
|
||||
'label' => 'WC version product was created on',
|
||||
),
|
||||
array(
|
||||
'key' => '_wc_average_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
array(
|
||||
'key' => '_wc_review_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Review count',
|
||||
),
|
||||
array(
|
||||
'key' => 'total_sales',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Total sales count',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress listing core meta keys (post_type=hp_listing).
|
||||
* Aligns with the existing wpdo_hot_hp_listing flat table; v2.9.5 will
|
||||
* copy-then-cutover the 230 dev10 rows to this group's table.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_listing_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_listing_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_price',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Listing price',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'publish', 'draft', 'pending', 'expired', 'private' ),
|
||||
'label' => 'Listing status',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_featured',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Featured flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Verified flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_vendor',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Vendor user ID',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_featured_time',
|
||||
'type' => 'integer',
|
||||
'label' => 'Featured-until unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_view_count',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'View counter',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Rating count',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_hourly_rate',
|
||||
'type' => 'decimal',
|
||||
'label' => 'Hourly rate',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress request core meta keys (post_type=hp_request).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_request_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_request_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_status',
|
||||
'type' => 'enum',
|
||||
'searchable' => true,
|
||||
'options' => array( 'publish', 'draft', 'pending', 'expired' ),
|
||||
'label' => 'Request status',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_user',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Request author user ID',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_expired_time',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Expiry unix ts',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_budget',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Budget amount',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_view_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'View counter',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* HivePress vendor core meta keys (post_type=hp_vendor).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_hp_vendor_core_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'hp_vendor_core',
|
||||
array(
|
||||
array(
|
||||
'key' => 'hp_user',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Vendor user ID (linked WP user)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_verified',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Verified flag (0/1)',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_hourly_rate',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Hourly rate',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating_count',
|
||||
'type' => 'integer',
|
||||
'label' => 'Rating count',
|
||||
),
|
||||
array(
|
||||
'key' => 'hp_rating',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'Average rating',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nav menu item meta keys (post_type=nav_menu_item).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function register_nav_menu_item_group(): void {
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'post',
|
||||
'nav_menu_item',
|
||||
array(
|
||||
array(
|
||||
'key' => '_menu_item_type',
|
||||
'type' => 'enum',
|
||||
'options' => array( 'post_type', 'taxonomy', 'custom', 'post_type_archive' ),
|
||||
'label' => 'Menu item linking type',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_menu_item_parent',
|
||||
'type' => 'integer',
|
||||
'label' => 'Parent menu item ID',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_object_id',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'Linked object ID',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_object',
|
||||
'type' => 'text',
|
||||
'label' => 'Linked object slug (post_type/taxonomy)',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_target',
|
||||
'type' => 'text',
|
||||
'label' => 'Link target attribute',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_classes',
|
||||
'type' => 'json',
|
||||
'label' => 'CSS classes array',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_xfn',
|
||||
'type' => 'text',
|
||||
'label' => 'XFN relationship',
|
||||
),
|
||||
array(
|
||||
'key' => '_menu_item_url',
|
||||
'type' => 'textarea',
|
||||
'label' => 'Custom URL (for type=custom)',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Term_Comment_Garbage_Filter — Block known-garbage writes to
|
||||
* wp_termmeta / wp_commentmeta at the metadata filter layer (v2.12.1 Phase 1).
|
||||
*
|
||||
* Phase 0 (v2.12.0) provided cleanup CLI to delete historical garbage. This
|
||||
* filter prevents the same garbage from accumulating again by intercepting
|
||||
* writes via WordPress metadata filters (`add_term_metadata`, etc.) and
|
||||
* silently dropping them — short-circuiting the database INSERT entirely.
|
||||
*
|
||||
* Targets (must align with TMDO_Termmeta_Cleaner / TMDO_Commentmeta_Cleaner):
|
||||
*
|
||||
* wp_termmeta + wp_commentmeta:
|
||||
* - meta_key matching `_wxr_import_*` (WordPress importer residue —
|
||||
* written once during WXR import, never read afterward)
|
||||
* - meta_key matching `_2meet_demo_*` (project-specific demo markers,
|
||||
* safe to drop and re-seed)
|
||||
*
|
||||
* wp_commentmeta only (orphan post-meta keys):
|
||||
* - 8 hardcoded keys from TMDO_Commentmeta_Cleaner::ORPHAN_POST_META_KEYS
|
||||
* — these are bugs / typos writing post-domain meta to comment table
|
||||
*
|
||||
* Read paths are NOT filtered. Existing rows in wp_*meta still resolve normally
|
||||
* via standard WP metadata API; once cleanup CLI runs, reads naturally return
|
||||
* empty. This minimizes risk of breaking any reader code that still expects
|
||||
* the keys (none should, but defense in depth).
|
||||
*
|
||||
* Init: hooks registered on `init` priority 5 from TMDO_Core (after
|
||||
* HivePress's plugins_loaded:5 boot, before main entity bridge filters at 10).
|
||||
*
|
||||
* Lessons applied from v2.11.7 — this class is added to
|
||||
* TMDO_Hook_Bus_Bridge::COEXIST_WHITELIST so `wp wpdo conflict-scan` does not
|
||||
* report a false positive when both Hook Bus and this filter run on the same
|
||||
* `add_term_metadata` / `add_comment_metadata` hook.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.1
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block known-garbage writes to wp_termmeta / wp_commentmeta.
|
||||
*/
|
||||
final class TMDO_Term_Comment_Garbage_Filter {
|
||||
|
||||
/** Option key for admin toggle. */
|
||||
public const OPT_ENABLED = 'wpdo_term_comment_garbage_filter_enabled';
|
||||
|
||||
/** Telemetry option: count of garbage writes dropped (rolling 24h cumulative). */
|
||||
public const OPT_DROPPED_COUNT = 'wpdo_term_comment_garbage_drops_24h';
|
||||
|
||||
/** Telemetry option: timestamp of the last reset of the 24h counter. */
|
||||
public const OPT_DROPPED_RESET_AT = 'wpdo_term_comment_garbage_drops_reset_at';
|
||||
|
||||
/**
|
||||
* Pattern prefixes that trigger a drop on writes to BOTH wp_termmeta and
|
||||
* wp_commentmeta. Aligned with cleanup CLI `--target` buckets.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const SHARED_DROP_PREFIXES = array(
|
||||
'_wxr_import_',
|
||||
'_2meet_demo_',
|
||||
);
|
||||
|
||||
/**
|
||||
* Exact meta_keys that are dropped only for wp_commentmeta writes
|
||||
* (post-domain keys mistakenly written to comment table — always a bug).
|
||||
*
|
||||
* Must stay in sync with TMDO_Commentmeta_Cleaner::ORPHAN_POST_META_KEYS.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const COMMENT_ONLY_ORPHAN_KEYS = array(
|
||||
'_hp_price',
|
||||
'_hp_status',
|
||||
'_hp_featured',
|
||||
'_hp_verified',
|
||||
'_hp_view_count',
|
||||
'_thumbnail_id',
|
||||
'_edit_lock',
|
||||
'_edit_last',
|
||||
);
|
||||
|
||||
/**
|
||||
* Register filters. Called from TMDO_Core::run() on init:5.
|
||||
*
|
||||
* Idempotent — safe to call multiple times.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init(): void {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Term meta writes — priority 9 (before Hook Bus at 10).
|
||||
add_filter( 'add_term_metadata', array( self::class, 'on_term_write' ), 9, 5 );
|
||||
add_filter( 'update_term_metadata', array( self::class, 'on_term_write' ), 9, 5 );
|
||||
|
||||
// Comment meta writes.
|
||||
add_filter( 'add_comment_metadata', array( self::class, 'on_comment_write' ), 9, 5 );
|
||||
add_filter( 'update_comment_metadata', array( self::class, 'on_comment_write' ), 9, 5 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the admin toggle. Defaults to enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
return (bool) get_option( self::OPT_ENABLED, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a meta_key triggers the shared drop rules
|
||||
* (applicable to both term and comment meta).
|
||||
*
|
||||
* @param mixed $meta_key Candidate meta_key.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_shared_garbage_key( $meta_key ): bool {
|
||||
if ( ! is_string( $meta_key ) ) {
|
||||
return false;
|
||||
}
|
||||
foreach ( self::SHARED_DROP_PREFIXES as $prefix ) {
|
||||
if ( str_starts_with( $meta_key, $prefix ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a meta_key triggers the comment-only orphan post-meta drop.
|
||||
*
|
||||
* @param mixed $meta_key Candidate meta_key.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_comment_orphan_key( $meta_key ): bool {
|
||||
if ( ! is_string( $meta_key ) ) {
|
||||
return false;
|
||||
}
|
||||
return in_array( $meta_key, self::COMMENT_ONLY_ORPHAN_KEYS, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: add_term_metadata / update_term_metadata.
|
||||
*
|
||||
* Returns null → continue normal flow (write to wp_termmeta).
|
||||
* Returns true → short-circuit; WP treats as success without DB write.
|
||||
*
|
||||
* @param mixed $check Filter accumulator (null at our priority).
|
||||
* @param int $object_id Term ID.
|
||||
* @param string $meta_key Meta key being written.
|
||||
* @param mixed $meta_value Value being written (unused).
|
||||
* @param mixed $extra Either $unique (add) or $prev_value (update). Unused.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_term_write( $check, $object_id, $meta_key, $meta_value, $extra ) {
|
||||
unset( $object_id, $meta_value, $extra );
|
||||
if ( self::is_shared_garbage_key( $meta_key ) ) {
|
||||
self::increment_drop_counter();
|
||||
return true;
|
||||
}
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: add_comment_metadata / update_comment_metadata.
|
||||
*
|
||||
* Returns null → continue normal flow.
|
||||
* Returns true → short-circuit (silent drop).
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Comment ID.
|
||||
* @param string $meta_key Meta key being written.
|
||||
* @param mixed $meta_value Value being written (unused).
|
||||
* @param mixed $extra Either $unique (add) or $prev_value (update). Unused.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_comment_write( $check, $object_id, $meta_key, $meta_value, $extra ) {
|
||||
unset( $object_id, $meta_value, $extra );
|
||||
if ( self::is_shared_garbage_key( $meta_key ) || self::is_comment_orphan_key( $meta_key ) ) {
|
||||
self::increment_drop_counter();
|
||||
return true;
|
||||
}
|
||||
return $check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the rolling 24h drop counter.
|
||||
*
|
||||
* Auto-resets every 24h based on a stored timestamp; this avoids
|
||||
* unbounded growth and gives the admin status panel a meaningful
|
||||
* "drops in last day" indicator.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function increment_drop_counter(): void {
|
||||
$now = time();
|
||||
$reset_at = (int) get_option( self::OPT_DROPPED_RESET_AT, 0 );
|
||||
|
||||
if ( 0 === $reset_at || ( $now - $reset_at ) >= DAY_IN_SECONDS ) {
|
||||
update_option( self::OPT_DROPPED_COUNT, 1, false );
|
||||
update_option( self::OPT_DROPPED_RESET_AT, $now, false );
|
||||
return;
|
||||
}
|
||||
|
||||
$count = (int) get_option( self::OPT_DROPPED_COUNT, 0 );
|
||||
update_option( self::OPT_DROPPED_COUNT, $count + 1, false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current 24h rolling drop counter (for admin status panel).
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function get_drop_count_24h(): int {
|
||||
$reset_at = (int) get_option( self::OPT_DROPPED_RESET_AT, 0 );
|
||||
if ( 0 === $reset_at || ( time() - $reset_at ) >= DAY_IN_SECONDS ) {
|
||||
return 0;
|
||||
}
|
||||
return (int) get_option( self::OPT_DROPPED_COUNT, 0 );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Term_Comment_Misc_Bucket — Catch-all flat storage for unregistered
|
||||
* term + comment meta keys (v2.12.4 Phase 4).
|
||||
*
|
||||
* The "missing piece" that makes wp_termmeta / wp_commentmeta completely
|
||||
* avoidable. After Phase 0–3:
|
||||
*
|
||||
* - Phase 0 (v2.12.0): cleanup CLI removes historical garbage
|
||||
* - Phase 1 (v2.12.1): garbage filter blocks new garbage writes
|
||||
* - Phase 2 (v2.12.2): entity registry routes registered HivePress fields
|
||||
* to wp_wpdo_term_hp_taxonomy / wp_wpdo_comment_hp_review
|
||||
* - Phase 3 (v2.12.3): WC term count filter routes product_count_* → wp_options
|
||||
*
|
||||
* What's left? **Unregistered keys** — anything written to term/comment meta
|
||||
* that doesn't match any pattern (e.g., `note_group` on dev10, or any future
|
||||
* plugin's custom key). Those still land in wp_termmeta / wp_commentmeta.
|
||||
*
|
||||
* This class is the catch-all. Filter priority 99 (LAST in the chain) means
|
||||
* we only handle writes where every other filter has returned null (i.e., no
|
||||
* match). Two new flat tables provide structurally identical K/V storage
|
||||
* with explicit ownership.
|
||||
*
|
||||
* Filter priority chain (term + comment metadata writes):
|
||||
*
|
||||
* priority 9: garbage filter (drop _wxr_ / _demo_ / orphan)
|
||||
* priority 9: WC term count filter (route product_count_* → wp_options)
|
||||
* priority 10: Hook Bus / entity (route registered keys → flat tables)
|
||||
* priority 99: THIS misc bucket (catch-all → wp_wpdo_*_misc)
|
||||
*
|
||||
* The chain works because filter callbacks preserve `$check` / `$pre`
|
||||
* (the accumulator) when they don't match, so we can detect "nobody handled
|
||||
* this" by checking `$check === null` at our priority.
|
||||
*
|
||||
* Storage:
|
||||
*
|
||||
* CREATE TABLE wp_wpdo_term_misc (
|
||||
* term_id BIGINT(20) UNSIGNED NOT NULL,
|
||||
* meta_key VARCHAR(191) NOT NULL,
|
||||
* meta_value LONGTEXT,
|
||||
* updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
* PRIMARY KEY (term_id, meta_key),
|
||||
* KEY meta_key (meta_key)
|
||||
* );
|
||||
* -- (and corresponding wp_wpdo_comment_misc with comment_id)
|
||||
*
|
||||
* No reverse-EAV optimization (still K/V storage), but achieves the literal
|
||||
* goal of "wp_termmeta / wp_commentmeta zero writes" — making them DROPpable
|
||||
* in v3.0.0.
|
||||
*
|
||||
* 🔒 Lessons applied (v2.11.7): added to TMDO_Hook_Bus_Bridge::COEXIST_WHITELIST.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
* @since 2.12.4
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all storage for unregistered term + comment meta keys.
|
||||
*/
|
||||
final class TMDO_Term_Comment_Misc_Bucket {
|
||||
|
||||
/** Option toggle key. */
|
||||
public const OPT_ENABLED = 'wpdo_term_comment_misc_bucket_enabled';
|
||||
|
||||
/** Filter priority — runs LAST in the chain (after Hook Bus at 10). */
|
||||
public const FILTER_PRIORITY = 99;
|
||||
|
||||
/**
|
||||
* Register the four metadata filters on `init` priority 5.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init(): void {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Term metadata.
|
||||
add_filter( 'get_term_metadata', array( self::class, 'on_term_read' ), self::FILTER_PRIORITY, 4 );
|
||||
add_filter( 'add_term_metadata', array( self::class, 'on_term_add' ), self::FILTER_PRIORITY, 5 );
|
||||
add_filter( 'update_term_metadata', array( self::class, 'on_term_update' ), self::FILTER_PRIORITY, 5 );
|
||||
add_filter( 'delete_term_metadata', array( self::class, 'on_term_delete' ), self::FILTER_PRIORITY, 5 );
|
||||
|
||||
// Comment metadata.
|
||||
add_filter( 'get_comment_metadata', array( self::class, 'on_comment_read' ), self::FILTER_PRIORITY, 4 );
|
||||
add_filter( 'add_comment_metadata', array( self::class, 'on_comment_add' ), self::FILTER_PRIORITY, 5 );
|
||||
add_filter( 'update_comment_metadata', array( self::class, 'on_comment_update' ), self::FILTER_PRIORITY, 5 );
|
||||
add_filter( 'delete_comment_metadata', array( self::class, 'on_comment_delete' ), self::FILTER_PRIORITY, 5 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the admin toggle. Defaults to enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
return (bool) get_option( self::OPT_ENABLED, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Term-side fully-qualified misc table name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function term_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'wpdo_term_misc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment-side fully-qualified misc table name.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function comment_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'wpdo_comment_misc';
|
||||
}
|
||||
|
||||
// ── Term metadata callbacks ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter callback: get_term_metadata.
|
||||
*
|
||||
* @param mixed $pre Filter accumulator.
|
||||
* @param int $object_id Term ID.
|
||||
* @param string $meta_key Meta key being read.
|
||||
* @param bool $single Whether single value was requested.
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_term_read( $pre, $object_id, $meta_key, $single ) {
|
||||
unset( $single );
|
||||
// Only handle when no earlier filter has resolved this read.
|
||||
if ( null !== $pre ) {
|
||||
return $pre;
|
||||
}
|
||||
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
|
||||
return $pre;
|
||||
}
|
||||
$value = self::read( self::term_table(), 'term_id', (int) $object_id, $meta_key );
|
||||
if ( null === $value ) {
|
||||
return $pre;
|
||||
}
|
||||
return array( $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: add_term_metadata.
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Term ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value.
|
||||
* @param bool $unique Unique flag (unused).
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_term_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
||||
unset( $unique );
|
||||
if ( null !== $check ) {
|
||||
return $check;
|
||||
}
|
||||
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
|
||||
return $check;
|
||||
}
|
||||
self::write( self::term_table(), 'term_id', (int) $object_id, $meta_key, $meta_value );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: update_term_metadata.
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Term ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value.
|
||||
* @param mixed $prev_value Previous value (unused).
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_term_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
||||
unset( $prev_value );
|
||||
if ( null !== $check ) {
|
||||
return $check;
|
||||
}
|
||||
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
|
||||
return $check;
|
||||
}
|
||||
self::write( self::term_table(), 'term_id', (int) $object_id, $meta_key, $meta_value );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: delete_term_metadata.
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Term ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value-scoped delete (unused).
|
||||
* @param bool $delete_all Delete-all flag (unused).
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_term_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
|
||||
unset( $meta_value, $delete_all );
|
||||
if ( null !== $check ) {
|
||||
return $check;
|
||||
}
|
||||
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
|
||||
return $check;
|
||||
}
|
||||
self::delete_row( self::term_table(), 'term_id', (int) $object_id, $meta_key );
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Comment metadata callbacks ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter callback: get_comment_metadata.
|
||||
*
|
||||
* @param mixed $pre Filter accumulator.
|
||||
* @param int $object_id Comment ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param bool $single Single flag (unused).
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_comment_read( $pre, $object_id, $meta_key, $single ) {
|
||||
unset( $single );
|
||||
if ( null !== $pre ) {
|
||||
return $pre;
|
||||
}
|
||||
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
|
||||
return $pre;
|
||||
}
|
||||
$value = self::read( self::comment_table(), 'comment_id', (int) $object_id, $meta_key );
|
||||
if ( null === $value ) {
|
||||
return $pre;
|
||||
}
|
||||
return array( $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: add_comment_metadata.
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Comment ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value.
|
||||
* @param bool $unique Unique flag (unused).
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_comment_add( $check, $object_id, $meta_key, $meta_value, $unique ) {
|
||||
unset( $unique );
|
||||
if ( null !== $check ) {
|
||||
return $check;
|
||||
}
|
||||
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
|
||||
return $check;
|
||||
}
|
||||
self::write( self::comment_table(), 'comment_id', (int) $object_id, $meta_key, $meta_value );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: update_comment_metadata.
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Comment ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value.
|
||||
* @param mixed $prev_value Previous value (unused).
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_comment_update( $check, $object_id, $meta_key, $meta_value, $prev_value ) {
|
||||
unset( $prev_value );
|
||||
if ( null !== $check ) {
|
||||
return $check;
|
||||
}
|
||||
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
|
||||
return $check;
|
||||
}
|
||||
self::write( self::comment_table(), 'comment_id', (int) $object_id, $meta_key, $meta_value );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter callback: delete_comment_metadata.
|
||||
*
|
||||
* @param mixed $check Filter accumulator.
|
||||
* @param int $object_id Comment ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value-scoped (unused).
|
||||
* @param bool $delete_all Delete-all flag (unused).
|
||||
* @return mixed
|
||||
*/
|
||||
public static function on_comment_delete( $check, $object_id, $meta_key, $meta_value, $delete_all ) {
|
||||
unset( $meta_value, $delete_all );
|
||||
if ( null !== $check ) {
|
||||
return $check;
|
||||
}
|
||||
if ( ! is_string( $meta_key ) || '' === $meta_key ) {
|
||||
return $check;
|
||||
}
|
||||
self::delete_row( self::comment_table(), 'comment_id', (int) $object_id, $meta_key );
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Internal storage helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read a value from a misc bucket table.
|
||||
*
|
||||
* Returns the raw stored value (string), or null when no row exists.
|
||||
* Caller must wrap in array for the get_*_metadata filter contract.
|
||||
*
|
||||
* @param string $table Fully-qualified table name.
|
||||
* @param string $id_column 'term_id' or 'comment_id'.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @return string|null
|
||||
*/
|
||||
private static function read( string $table, string $id_column, int $object_id, string $meta_key ): ?string {
|
||||
global $wpdb;
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$value = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT meta_value FROM `{$table}` WHERE `{$id_column}` = %d AND meta_key = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$object_id,
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
// phpcs:enable
|
||||
return ( null === $value ) ? null : (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write (INSERT or UPDATE) a value to a misc bucket table.
|
||||
*
|
||||
* Uses ON DUPLICATE KEY UPDATE — the (id, meta_key) primary key
|
||||
* means each (entity, meta_key) pair has a single canonical row.
|
||||
*
|
||||
* @param string $table Fully-qualified table name.
|
||||
* @param string $id_column 'term_id' or 'comment_id'.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @param mixed $meta_value Value to store. Non-scalar types are serialized.
|
||||
* @return void
|
||||
*/
|
||||
private static function write( string $table, string $id_column, int $object_id, string $meta_key, $meta_value ): void {
|
||||
global $wpdb;
|
||||
|
||||
// Match WP convention: serialize arrays/objects, scalar values stored as-is.
|
||||
$serialized = is_scalar( $meta_value ) || null === $meta_value
|
||||
? (string) $meta_value
|
||||
: maybe_serialize( $meta_value );
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"INSERT INTO `{$table}` (`{$id_column}`, meta_key, meta_value) VALUES (%d, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE meta_value = VALUES(meta_value)",
|
||||
$object_id,
|
||||
$meta_key,
|
||||
$serialized
|
||||
)
|
||||
);
|
||||
// phpcs:enable
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the row matching (object_id, meta_key) from a misc bucket table.
|
||||
*
|
||||
* @param string $table Fully-qualified table name.
|
||||
* @param string $id_column 'term_id' or 'comment_id'.
|
||||
* @param int $object_id Entity ID.
|
||||
* @param string $meta_key Meta key.
|
||||
* @return void
|
||||
*/
|
||||
private static function delete_row( string $table, string $id_column, int $object_id, string $meta_key ): void {
|
||||
global $wpdb;
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM `{$table}` WHERE `{$id_column}` = %d AND meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$object_id,
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
// phpcs:enable
|
||||
}
|
||||
|
||||
/**
|
||||
* Count rows in a misc bucket table (for admin status panel).
|
||||
*
|
||||
* @param string $entity_type 'term' or 'comment'.
|
||||
* @return int
|
||||
*/
|
||||
public static function count_rows( string $entity_type ): int {
|
||||
global $wpdb;
|
||||
$table = 'comment' === $entity_type ? self::comment_table() : self::term_table();
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$exists = (bool) $wpdb->get_var(
|
||||
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
|
||||
);
|
||||
if ( ! $exists ) {
|
||||
return 0;
|
||||
}
|
||||
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
||||
// phpcs:enable
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user