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:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
@@ -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 1050× 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 03:
*
* - 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
}
}