Files
2meet-data-optimizer/docs/ENTITY_ADAPTER_COOKBOOK.md
T
wpdev b63ab46f54
Tests / Integration Tests (push) Successful in 1m11s
Tests / Unit Tests (push) Failing after 11m56s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Failing after 12m7s
Tests / PHPStan (push) Failing after 14m37s
Tests / PHPCS (push) Failing after 14m46s
Tests / PHP Lint (push) Failing after 14m57s
docs: 移植 readme.txt / CONTEXT.md / docs(backport A v3.4.6)
- readme.txt(WP 外掛目錄格式,隨 ZIP 發佈):Stable tag 對齊 1.0.0,
  changelog 補 1.0.0 條目
- CONTEXT.md(領域詞彙表):Status 區塊改寫為 v1.0.0 實況;
  HPCT_INTERCEPTORS 與 HivePress Adapter 兩節標註「已搬到 AddOn,核心無此常數」
- docs/:ENTITY_ADAPTER_COOKBOOK、2 篇 ADR、INTEGRATION_PATTERN_DECISION、
  anti-eav-lint.yml.template
  - cookbook 修掉兩個死連結(ANTI_EAV_PLAYBOOK 在來源外掛就不存在)
  - INTEGRATION_PATTERN_DECISION 加 v1.0.0 後記:結論已被 AddOn 拆分取代
  - template 改 wpdev/2meet-data-optimizer + ref v1.0.0 + wp tmdo lint
- README.md 文件索引補上以上 7 個檔案

前綴改寫刻意只動類別/函式/slug(WPDO_→TMDO_、wp-data-optimizer→2meet-...),
wpdo_ option/cron/hook/表名與 wpdo/v1 REST namespace 一律保留 —— 這是資料層
零遷移的前提。
2026-07-31 10:06:03 +08:00

22 KiB

Entity Adapter Cookbook

How partner plugins integrate with 2meet-data-optimizer v1.0.0+ to gain anti-EAV benefits without modifying their existing data model.

This is the practical guide. For the canonical vocabulary see CONTEXT.md; for the two standing architectural decisions see docs/adr-001-post-entity-source-of-truth.md and docs/adr-002-dual-write-naming-collision.md.


TL;DR Decision Tree (read this first — v2.1.2 reordered)

The most common mistake is picking Tier 1-3 when Tier 5 was the right answer. Ask these questions in order:

Q1. Does the partner plugin have its OWN custom tables / lookup tables
    that already provide anti-EAV? (HPOS, wc_product_meta_lookup,
    BuddyPress activity tables, EDD payments, etc.)

    ├── YES → 🟢 TIER 5 (integrate, don't duplicate). STOP HERE.
    │         Register awareness only. Do not migrate.
    │         See: TMDO_WooCommerce reference.
    │
    └── NO → continue to Q2.

Q2. Is this a brand-new plugin you control end-to-end?

    ├── YES → 🟢 TIER 4 (greenfield, anti-EAV from day 1).
    │         Use `wp tmdo register-stub <slug>` for boilerplate.
    │         See: 2meet-inquiries reference.
    │
    └── NO (existing plugin with postmeta) → continue to Q3.

Q3. Is the relevant postmeta key heavily queried (filter / sort / search)?
    Benchmark: > 10k rows OR > 3-condition meta_query OR sort by meta_value.

    ├── YES → 🟢 TIER 1-3 (migrate to a Zone).
    │         Tier 1 (5 min) for read-only optimization.
    │         Tier 2 (30 min) for full dual-write.
    │         Tier 3 (1-2 days) for new entity type.
    │
    └── NO → 🟢 LEAVE AS POSTMETA. Premature optimization.
              Re-evaluate when scale crosses Q3 thresholds.

Why Tier 5 is FIRST: at v2.1.2 audit time, every mature plugin we surveyed (WC / BuddyPress potential / EDD potential / GravityForms) has its own anti-EAV. Defaulting to Tier 1-3 risks the catastrophic "two sources of truth" failure mode. See docs/INTEGRATION_PATTERN_DECISION.md for the principle.

How to tell if a partner plugin already has anti-EAV (Tier 5 candidate)

Check these signals in order — any ONE is sufficient for Tier 5:

Signal Where to look Examples
Lookup tables with name pattern *_lookup / *_meta_lookup / *_index SHOW TABLES LIKE 'wp_{prefix}_%lookup%' wp_wc_product_meta_lookup, wp_wc_customer_lookup
HPOS-style migration toggle (custom table replaces postmeta) Plugin's settings → "High Performance" or "Custom Tables" feature WooCommerce HPOS, EDD 3.0 payments
Dedicated columns instead of meta in main entity table DESC wp_{plugin}_entities shows price, status etc. as columns BuddyPress activity table, MemberPress subscriptions
Plugin's own search/filter API that bypasses meta_query wc_get_products(), bp_activity_get(), edd_get_payments() WC, BuddyPress, EDD all have native APIs
*_stats / *_aggregate tables for analytics queries wp_wc_order_stats, wp_*_lookup WC analytics, MonsterInsights
db_version option that hits dbDelta migration on plugin update wp option get {plugin}_db_version returns a non-trivial version Indicates the plugin has its own schema migration story

If you see 2+ signals → definitely Tier 5. If you see 0 signals but the plugin has heavy postmeta usage → Tier 1-3. If you see 0 signals and postmeta is light → leave it (Q3 = NO).

Quick command-line audit:

# List custom tables for a plugin
wp db query "SHOW TABLES LIKE 'wp_{prefix}_%'"

# Count postmeta keys the plugin owns (low = likely Tier 5; high = candidate Tier 1-3)
wp db query "SELECT COUNT(DISTINCT meta_key) FROM wp_postmeta WHERE meta_key LIKE '\\_{prefix}_%'"

# If both numbers are non-trivial → Tier 5 is correct (plugin uses both, but
# its tables are the truth and postmeta is legacy/secondary).

Three integration tiers

Pick the one that matches your plugin's data model:

Tier 1 — TMDO_API facade(最少改動,5 分鐘)

If your plugin reads/writes *_meta() directly today, swap to the facade. This gives you future-proofing for free — the day the field migrates to a zone or entity adapter, your plugin needs zero changes.

Before:

$token = get_post_meta( $vendor_id, 'tmeetic_ical_token', true );
update_post_meta( $vendor_id, 'tmeetic_ical_token', $new_token );

After:

$token = class_exists( 'TMDO_API' )
    ? TMDO_API::get_field( $vendor_id, 'tmeetic_ical_token' )
    : get_post_meta( $vendor_id, 'tmeetic_ical_token', true );

if ( class_exists( 'TMDO_API' ) ) {
    TMDO_API::set_field( $vendor_id, 'tmeetic_ical_token', $new_token );
} else {
    update_post_meta( $vendor_id, 'tmeetic_ical_token', $new_token );
}

Real example: 2meet-courses/includes/class-2meetic-ical.php (Wave 2 改造).

Cross-entity:

TMDO_API::get_entity( 'user',    $user_id, 'points' );
TMDO_API::get_entity( 'term',    $term_id, 'usage_count' );
TMDO_API::get_entity( 'comment', $comment_id, 'helpful_count' );
TMDO_API::set_entity( 'user', $uid, 'points', 50 );

Helper:

TMDO_API::is_field_registered( 'post', 'hp_price' );  // bool
TMDO_API::trace_storage( 'post', 'hp_price', 'hp_listing' );  // 'zone_hot' | 'postmeta' | ...

Tier 2 — Schema Registry 註冊(中等改動,30 分鐘)

If your plugin owns specific meta_keys that benefit from Hot zone (search/filter), Cold zone (display/JSON), or Warm zone (TTL counter) treatment.

Implementation: create one integration class.

// my-plugin/includes/class-tmdo-myplugin.php
final class TMDO_MyPlugin {
    public static function register(): void {
        // Detect partner plugin (2meet-data-optimizer) — no-op when absent.
        if ( ! class_exists( 'TMDO_Schema_Registry' ) ) {
            return;
        }
        add_action( 'wpdo_register_fields', array( __CLASS__, 'register_fields' ) );
        add_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_tables' ) );
    }

    public static function register_fields( TMDO_Schema_Registry $registry ): void {
        $registry->register_many( 'my-plugin', array(
            // Hot zone (search/filter): flat 1NF column with index.
            array(
                'post_type' => 'my_post_type',
                'meta_key'  => 'my_price',
                'zone'      => 'hot',
                'data_type' => 'decimal(10,2) NOT NULL DEFAULT 0',
                'column'    => 'my_price',
                'indexed'   => true,
            ),
            // Cold zone (description / JSON / display).
            array(
                'post_type'   => 'my_post_type',
                'meta_key'    => 'my_description',
                'zone'        => 'cold',
                'cache_group' => 'wpdo_cold_my_post_type',
                'cache_ttl'   => HOUR_IN_SECONDS,
            ),
        ) );
    }

    public static function register_tables( TMDO_Custom_Table_Registry $registry ): void {
        $registry->register( 'my-plugin', array(
            'table_name'      => 'my_custom_table',
            'primary_key'     => 'id',
            'post_type_link'  => 'my_post_type',
            'doctor_callback' => array( __CLASS__, 'doctor_my_table' ),
        ) );
    }

    public static function doctor_my_table(): array {
        global $wpdb;
        $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->prefix}my_custom_table`" );
        return array( 'ok' => true, 'message' => "rows: {$count}" );
    }
}

// In your plugin's bootstrap:
add_action( 'plugins_loaded', array( 'TMDO_MyPlugin', 'register' ), 5 );

Real examples:

  • 2meet-data-optimizer/includes/integrations/class-tmdo-infocards.php — 9 fields + 3 tables
  • 2meet-data-optimizer/includes/integrations/class-tmdo-bookings.php — 7 tables only

Tier 3 — Custom Entity Adapter(大改動,1-2 天)

If you need cross-entity behaviour (e.g. counter that works on user / term / comment uniformly), use the demo entity counter pattern.

Real example: 2meet-data-optimizer/includes/integrations/class-tmdo-demo-entity-counter.php.

Pattern:

final class My_Counter {
    public const MODULE = 'entity_my_counter';
    public const TABLE  = 'wpdo_my_counters';

    public static function install_table(): void {
        global $wpdb;
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( "CREATE TABLE {$wpdb->prefix}" . self::TABLE . " (
          id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
          entity_type varchar(20) NOT NULL,
          entity_id   bigint(20) unsigned NOT NULL,
          counter_key varchar(100) NOT NULL,
          counter_value bigint(20) NOT NULL DEFAULT 0,
          updated_at datetime NOT NULL,
          PRIMARY KEY (id),
          UNIQUE KEY ui_entity_counter (entity_type, entity_id, counter_key),
          KEY idx_lookup (entity_type, counter_key, counter_value)
        ) {$wpdb->get_charset_collate()};" );
    }

    public static function set( string $entity_type, int $entity_id, string $key, int $value ): void {
        // Always write native (durability anchor).
        TMDO_API::set_entity( $entity_type, $entity_id, $key, $value );

        // Conditional dual-write to zone table.
        if ( TMDO_Feature_Flags::is_write_active( self::MODULE ) ) {
            TMDO_DB::upsert(
                $GLOBALS['wpdb']->prefix . self::TABLE,
                array(
                    'entity_type'   => $entity_type,
                    'entity_id'     => $entity_id,
                    'counter_key'   => $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' )
            );
        }
    }

    public static function get( string $entity_type, int $entity_id, string $key ): int {
        if ( TMDO_Feature_Flags::is_read_custom( self::MODULE ) ) {
            // Read from zone table; fallback to native if row missing.
            $val = $GLOBALS['wpdb']->get_var( $GLOBALS['wpdb']->prepare(
                "SELECT counter_value FROM `{$GLOBALS['wpdb']->prefix}" . self::TABLE . "`
                 WHERE entity_type = %s AND entity_id = %d AND counter_key = %s",
                $entity_type, $entity_id, $key
            ) );
            if ( null !== $val ) {
                return (int) $val;
            }
        }
        return (int) TMDO_API::get_entity( $entity_type, $entity_id, $key );
    }

    public static function top_n( string $entity_type, string $key, int $n = 10 ): array {
        // Killer query that postmeta cannot do efficiently.
        return $GLOBALS['wpdb']->get_results( $GLOBALS['wpdb']->prepare(
            "SELECT entity_id, counter_value FROM `{$GLOBALS['wpdb']->prefix}" . self::TABLE . "`
             WHERE entity_type = %s AND counter_key = %s
             ORDER BY counter_value DESC LIMIT %d",
            $entity_type, $key, $n
        ), ARRAY_A );
    }
}

FSM lifecycle reference

For Tier 3 entity adapters, drive the 7+1 state machine via CLI:

# 1. Install schema (one-time)
wp tmdo doctor   # verify base tables

# 2. Initial state: idle (do not register feature flag)
wp tmdo mode-audit | grep entity_my_counter   # should show 'idle'

# 3. Begin dual_write — both native + zone get writes
wp tmdo mode-set entity_my_counter dual_write

# 4. Run backfill (if you have existing data)
# (custom script or wp tmdo migrate)

# 5. Verify with shadow_read for 7 days
wp tmdo mode-set entity_my_counter verify
wp tmdo shadow-enable entity_my_counter

# Check for diffs
wp eval 'echo (int) $GLOBALS["wpdb"]->get_var("SELECT COUNT(*) FROM {$GLOBALS[\"wpdb\"]->prefix}wpdo_shadow_diffs WHERE entity_type=\"user\"");'

# 6. Cutover — reads switch to zone
wp tmdo shadow-disable entity_my_counter
wp tmdo mode-set entity_my_counter cutover

# 7. After 7 more days, cleanup native rows
wp tmdo mode-set entity_my_counter cleanup

# 8. Final state — no fallback, zone is source of truth
wp tmdo mode-set entity_my_counter complete

# Rollback at any time
wp tmdo mode-set entity_my_counter idle

Anti-EAV lint exemptions

Some patterns require direct SQL by design (one-off migration scans, cron token expiry checks). Mark them with phpcs:ignore so wp tmdo lint --strict accepts them:

// phpcs:ignore WPDO.AntiEAV.PostmetaScan -- Cron sweeps postmeta to find expiring IG tokens.
$ids = $wpdb->get_col( $wpdb->prepare(
    "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'tmeetic_ig_token_expiry' AND CAST(meta_value AS UNSIGNED) < %d",
    time() + 31 * DAY_IN_SECONDS
) );

Available rules:

  • WPDO.AntiEAV.PostmetaScan — cross-postmeta scan in migration / cron
  • WPDO.AntiEAV.UsermetaScan — same, usermeta
  • WPDO.AntiEAV.PostmetaFallback — fallback path during graceful degradation

The exemption stays line-local. The lint cannot be silenced for an entire file.


Verification checklist (before merge)

# 1. Lint passes strict
wp tmdo lint --plugin=$(pwd) --strict

# 2. Conflict scan clean
wp tmdo conflict-scan

# 3. Doctor check (your custom tables registered + healthy)
wp tmdo doctor

# 4. Tests pass (if your plugin has them)
./vendor/bin/phpunit

Real-world results (dev10, 2026-04-25)

Production benchmarks measured with 50 hp_listing posts:

Path Latency (n=100) Speedup
Native get_post_meta() 205ms 1.0x baseline
TMDO_Listing_Stats::get_view_count() (Zone B Warm + fallback) 81ms 2.51x
TMDO_Demo_Entity_Counter::top_n() (Zone via 1 LEFT JOIN) 0.19ms / call postmeta cannot do efficiently

The top_n example is the killer use case — sorting 1000s of users by point count via postmeta requires a full meta_value scan + filesort. The zone table serves it from a covering index in sub-millisecond.


Tier 4: Greenfield plugin — 2meet-inquiries (v0.5.0)

The cleanest case: a plugin written from scratch to be anti-EAV from day 1. Use this as a template for all new 2meet-* plugins.

Why this is the gold standard

Pattern What 2meet-inquiries does What bookings/courses/infocards had to retrofit
Large structured config wp_2mqi_forms.config_json LONGTEXT (custom table) Originally postmeta _eh_inquiry_config (anti-EAV violation)
Hot-zone meta on hp_vendor wpdo_register_fields_tmqi_default_form_id (bigint, indexed) Some retrofitted via Schema_Registry Hot Wave 1; others still WP_Query-driven
Sensitive PII customer_email_enc BLOB + customer_email_hash CHAR(64) for indexed lookup Originally separate plugins each rolled own AES wrapper
Audit-trail rows Dedicated wp_2mqi_responses table (1 row per submission) Older plugins used wp_postmeta rows-per-field → EAV blow-up
Analytics events Dedicated wp_2mqi_analytics (event_type, stage_index, session_id) N/A — most plugins didn't have analytics, would've gone to postmeta if they did
Webhook config wp_options per vendor (autoload=no, low cardinality) Same

Anatomy: 4 custom tables, 0 plugin-owned postmeta keys

wp_2mqi_forms       — form definitions (config_json + counters + slug)
wp_2mqi_responses   — submitted inquiries (encrypted PII + payload_json)
wp_2mqi_drafts      — in-progress submissions (token + 14-day expire)
wp_2mqi_analytics   — funnel events (view, stage_*, submit)

Plus 2 hp_vendor fields registered to Schema_Registry Hot zone:

$registry->register_many( '2meet-inquiries', array(
    array(
        'post_type' => 'hp_vendor',
        'meta_key'  => '_tmqi_default_form_id',
        'zone'      => 'hot',
        'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
        'column'    => '_tmqi_default_form_id',
        'indexed'   => true,
    ),
    array(
        'post_type' => 'hp_vendor',
        'meta_key'  => '_tmqi_inquiries_enabled',
        'zone'      => 'hot',
        'data_type' => 'tinyint(1) NOT NULL DEFAULT 0',
        'column'    => '_tmqi_inquiries_enabled',
        'indexed'   => true,
    ),
) );
final class TMQI_Plugin {
    use TMDO_Anti_EAV_Aware;  // ← strict contract; fails to load without it

    public function run(): void {
        // Register tables on the canonical action.
        add_action( 'wpdo_register_fields',        array( __CLASS__, 'register_wpdo_fields' ) );
        add_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_custom_tables' ) );
        // ...
    }

    public static function register_wpdo_fields(): void { /* hot-zone fields */ }
    public static function register_custom_tables( $registry = null ): void {
        TMQI_WPDO_Integration::register_tables( $registry );
    }
}

Ground rules followed

No update_post_meta() calls anywhere — even for hp_vendor metas, we call TMDO_API::set_field() No direct SELECT FROM wp_postmetawpdo lint --strict exits 0 All large JSON in custom tablesconfig_json and payload_json columns, never postmeta Sensitive data encrypted — reuse TMEETIC_Crypto::encrypt() (don't roll your own) Indexed search on encrypted columns — store SHA-256 hash alongside ciphertext Single do_action( 'tmqi/submitted' ) — downstream notifiers, analytics, webhook all hook here

Verification (run on dev10 right now)

# Custom tables registered
wp eval 'echo count(TMDO_Custom_Table_Registry::instance()->for_provider("2meet-inquiries"));'
# → 4

# Strict lint passes
wp tmdo lint --plugin=$(wp plugin path 2meet-inquiries) --strict
# → Success: Anti-EAV lint passed

# Conflict scan
wp tmdo conflict-scan
# → 0 conflicts detected

Takeaway for Wave 2/3 retrofits

When refactoring an existing plugin to be anti-EAV, the question is not "how do we shoehorn this into postmeta less?" — it's "what does the data look like if we redesign it like 2meet-inquiries from day 1?" Then plot a migration path.

For most plugins the answer is: replace one big postmeta key with one custom table row, and register a small number of hot-zone hp_vendor fields for search.


Tier 5: Integrating with a plugin that has its OWN anti-EAV — TMDO_WooCommerce (v2.1.0)

The hardest case: WC core already has anti-EAV (HPOS for orders, wp_wc_product_meta_lookup for products). WPDO's job is to integrate, not duplicate.

Why this is different from Tiers 1-4

Aspect Tier 1-4 (we own the data) Tier 5 (WC owns it)
Custom tables We define + create WC defines + creates
Hot-zone fields Migrated from postmeta to our Hot zone Already in WC's lookup tables; we just register awareness
Doctor probes We control existence + schema We probe but don't fix
Schema drift Our migration tooling WC's update_db_*() handles
Conflict None (single owner) Risk: shadow lookup tables

Anti-pattern: DON'T duplicate WC's lookup tables

// WRONG — creates a parallel system that drifts from WC's truth
$schema->register( 'woocommerce', array(
    'post_type' => 'product',
    'meta_key'  => '_price',
    'zone'      => 'hot',
    // ... migrate _price into our wpdo_hot_product table
) );
// Now `_price` lives in BOTH wp_wc_product_meta_lookup AND our hot zone.
// Updates touch one but not the other. Catastrophe.

Right pattern: Register awareness, defer to WC's anti-EAV

// In TMDO_WooCommerce::register_custom_tables():
foreach ( WC_CORE_TABLES as $name => $meta ) {
    $registry->register( 'woocommerce', array(
        'table_name'     => $name,        // wp_wc_product_meta_lookup
        'description'    => $meta['description'],
        'doctor_callback' => array( __CLASS__, 'doctor_check' ),
    ) );
}

// In TMDO_WooCommerce::register_schema_fields():
// Register postmeta keys WC STILL uses (the ones not yet migrated to lookups).
// When `_price` is also in wc_product_meta_lookup, registering doesn't migrate
// to OUR hot zone — it's just a hint to TMDO_API consumers about "this is hot".
$schema->register_many( 'woocommerce', array(
    array( 'post_type' => 'product', 'meta_key' => '_price', 'zone' => 'hot', ... ),
    // ...
) );

Decision tree for new partner integrations

Does the partner plugin store data in postmeta?
├── NO → already on custom tables → Tier 4 (register tables + done)
└── YES → does the partner have its own lookup/cache table?
    ├── NO → Tier 1-3 (we manage migration)
    └── YES → Tier 5: register awareness only, never duplicate

Tier 5 checklist for TMDO_WooCommerce

  • Register all 20 wp_wc_* tables to Custom_Table_Registry
  • Add doctor_callback that probes existence + row count (not schema diff)
  • Register hot-zone postmeta fields (legacy path only) — WC's lookup is the truth
  • Detect HPOS state via OrderUtil::custom_orders_table_usage_is_enabled()
  • When HPOS off → register order postmeta hot fields (_order_total, etc.)
  • When HPOS on → DON'T register order postmeta (would be stale)
  • Customer usermeta hot fields registered unconditionally (WC always uses usermeta for these)
  • Subscription product fields conditional on WC_Subscriptions OR wc-linepay-subscription
  • Own custom table (wpdo_wc_commissions) for vendor marketplace tracking
  • Admin notice recommends HPOS when legacy order count > threshold
  • Admin dashboard surfaces commission stats + table health

What this DOESN'T do (and shouldn't)

  • Migrate _price into our hot zone (WC already has wc_product_meta_lookup)
  • Mirror wc_orders into our archive zone (WC handles its own archive)
  • Intercept update_post_meta for product meta (interferes with WC's lookup sync)
  • Create products / orders / customers (WC's domain)

When to revisit

  • WC drops a lookup table (unlikely but possible) → migrate that field path to Tier 3
  • HPOS becomes default-on → audit our order postmeta registrations and remove
  • New WC subextension introduces meta keys we should register → add to register_subscription_fields()