feat(2meet-data-optimizer): backfill every registered user group with member-backfill --all

- member-backfill / member-audit take their groups from the registry (TMDO_Entity_Migration_Engine::backfillable_groups(), restored orphans excluded) instead of a hard-coded list that still named the never-registered `sso`; `--all` backfills every user group (R-2.1 had to use wp eval). TMDO_Post_Migration::backfillable_groups() now delegates to it.
- member-cutover checks the Mode_Manager result with is_wp_error(): a guarded or invalid transition used to crash with a fatal (WP_Error used as an array).
- Verify: CliMemberBackfillTest (--all and the orphan filter RED before; the --group case cannot fail under the non-throwing WP_CLI stub); dev36 `member-backfill --all --reset` 9 groups, 0 errors, checker user miss/diff/extra 0; `member-cutover --confirm` exits 1 with the Guard message, mode unchanged.
- PR: PR-068
This commit is contained in:
2026-09-26 23:30:28 +08:00
parent 7807eae957
commit 2022cf1d08
4 changed files with 132 additions and 34 deletions
+29 -27
View File
@@ -35,24 +35,6 @@ if ( ! class_exists( 'WP_CLI' ) ) {
*/ */
class TMDO_CLI_Member { class TMDO_CLI_Member {
/**
* Valid group names managed by this CLI.
*
* V2.7.0: Extended with core_profile/social/commerce/hp_user to absorb
* the legacy WP/WC/HP wp_usermeta keys.
*/
private const VALID_GROUPS = array(
'membership',
'activity',
'profile',
'sso',
'core_profile',
'social',
'commerce',
'hp_user',
'admin_prefs',
);
// ── member-audit ───────────────────────────────────────────────────────── // ── member-audit ─────────────────────────────────────────────────────────
/** /**
@@ -73,7 +55,7 @@ class TMDO_CLI_Member {
WP_CLI::log( '' ); WP_CLI::log( '' );
$items = array(); $items = array();
foreach ( self::VALID_GROUPS as $group ) { foreach ( TMDO_Entity_Registry::get_groups_for_type( 'user' ) as $group ) {
$table = $wpdb->prefix . 'wpdo_user_' . $group; $table = $wpdb->prefix . 'wpdo_user_' . $group;
$exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) $wpdb->prepare( 'SHOW TABLES LIKE %s', $table )
@@ -111,7 +93,7 @@ class TMDO_CLI_Member {
// ── member-backfill ─────────────────────────────────────────────────────── // ── member-backfill ───────────────────────────────────────────────────────
/** /**
* Backfill wp_usermeta rows into the specified user flat-table group. * Backfill wp_usermeta rows into one user flat-table group, or all of them.
* *
* Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based, * Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based,
* 500 rows/batch, checkpoint stored in wpdo_migration_status. * 500 rows/batch, checkpoint stored in wpdo_migration_status.
@@ -119,7 +101,10 @@ class TMDO_CLI_Member {
* ## OPTIONS * ## OPTIONS
* *
* [--group=<group>] * [--group=<group>]
* : Which group to backfill: membership, activity, profile, sso. Default: membership. * : Registered user group to backfill (e.g. membership, activity, core_profile). Default: membership.
*
* [--all]
* : Backfill every registered user group (restored orphan groups excluded).
* *
* [--batch-size=<n>] * [--batch-size=<n>]
* : Rows per batch (default 500, max 2000). * : Rows per batch (default 500, max 2000).
@@ -134,21 +119,38 @@ class TMDO_CLI_Member {
* *
* wp wpdo member-backfill --group=membership --dry-run * wp wpdo member-backfill --group=membership --dry-run
* wp wpdo member-backfill --group=membership * wp wpdo member-backfill --group=membership
* wp wpdo member-backfill --group=sso --reset * wp wpdo member-backfill --all
* *
* @param array $args Positional arguments. * @param array $args Positional arguments.
* @param array $assoc_args Named arguments. * @param array $assoc_args Named arguments.
*/ */
public function member_backfill( $args, $assoc_args ): void { public function member_backfill( $args, $assoc_args ): void {
$group = (string) ( $assoc_args['group'] ?? 'membership' );
$batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) ); $batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) );
$dry_run = isset( $assoc_args['dry-run'] ); $dry_run = isset( $assoc_args['dry-run'] );
$reset = isset( $assoc_args['reset'] ); $reset = isset( $assoc_args['reset'] );
$valid = TMDO_Entity_Migration_Engine::backfillable_groups( 'user' );
if ( ! in_array( $group, self::VALID_GROUPS, true ) ) { if ( isset( $assoc_args['all'] ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) ); if ( isset( $assoc_args['group'] ) ) {
WP_CLI::error( 'Use either --group or --all, not both.' );
}
$groups = $valid;
} else {
$groups = array( (string) ( $assoc_args['group'] ?? 'membership' ) );
if ( ! in_array( $groups[0], $valid, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid ) );
}
} }
foreach ( $groups as $group ) {
$this->backfill_group( $group, $batch_size, $dry_run, $reset );
}
}
/**
* Backfill one user group and report the result.
*/
private function backfill_group( string $group, int $batch_size, bool $dry_run, bool $reset ): void {
if ( $reset ) { if ( $reset ) {
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group ); TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group );
WP_CLI::log( "Checkpoint cleared for user/{$group}." ); WP_CLI::log( "Checkpoint cleared for user/{$group}." );
@@ -298,8 +300,8 @@ class TMDO_CLI_Member {
$result = TMDO_Mode_Manager::set( 'user', 'aeav_only' ); $result = TMDO_Mode_Manager::set( 'user', 'aeav_only' );
if ( isset( $result['error'] ) ) { if ( is_wp_error( $result ) ) {
WP_CLI::error( 'Cutover failed: ' . $result['error'] ); WP_CLI::error( 'Cutover failed: ' . $result->get_error_message() );
} }
WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' ); WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' );
@@ -386,6 +386,23 @@ final class TMDO_Entity_Migration_Engine {
); );
} }
/**
* Registered groups of an entity type that can be backfilled from native
* meta. Orphan groups restored from wpdo_registry_meta are left out: their
* values live only in the flat table, a backfill would wipe them.
*
* @return string[]
*/
public static function backfillable_groups( string $entity_type ): array {
$orphans = array();
foreach ( TMDO_Entity_Registry::get_restored_groups() as $restored ) {
if ( $entity_type === $restored['entity_type'] ) {
$orphans[] = $restored['group'];
}
}
return array_values( array_diff( TMDO_Entity_Registry::get_groups_for_type( $entity_type ), $orphans ) );
}
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// 斷點管理 // 斷點管理
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
@@ -561,13 +561,7 @@ final class TMDO_Post_Migration {
* @return string[] * @return string[]
*/ */
public static function backfillable_groups(): array { public static function backfillable_groups(): array {
$orphans = array(); return TMDO_Entity_Migration_Engine::backfillable_groups( self::ENTITY_TYPE );
foreach ( TMDO_Entity_Registry::get_restored_groups() as $restored ) {
if ( self::ENTITY_TYPE === $restored['entity_type'] ) {
$orphans[] = $restored['group'];
}
}
return array_values( array_diff( TMDO_Entity_Registry::get_groups_for_type( self::ENTITY_TYPE ), $orphans ) );
} }
/** /**
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* `wp tmdo member-backfill` takes its groups from the registry (2.2 PR-068).
*
* Before: a hard-coded VALID_GROUPS list (with the never-registered `sso`)
* rejected groups registered by add-ons, and there was no way to backfill
* every user group at once — R-2.1 had to fall back to `wp eval`.
*
* Fixture (user entity): groups cb_one (cb_a) and cb_two (cb_b).
*/
class CliMemberBackfillTest extends TestCase {
private const ONE = 'wp_itest_wpdo_user_cb_one';
private const TWO = 'wp_itest_wpdo_user_cb_two';
private const STATUS = 'wp_itest_wpdo_migration_status';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/adapters/class-tmdo-adapter-user.php',
'cli/class-tmdo-cli-member.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::ONE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::TWO . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, cb_b VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_a', 'meta_value' => 'one' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'cb_b', 'meta_value' => 'two' ) );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'cb_one', array( array( 'key' => 'cb_a', 'type' => 'text' ) ) );
WPDO_Entity_Registry::register_group( 'user', 'cb_two', array( array( 'key' => 'cb_b', 'type' => 'text' ) ) );
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::ONE, self::TWO, self::STATUS ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function flat( string $table, string $col ): ?string {
global $wpdb;
return $wpdb->get_var( "SELECT `{$col}` FROM `{$table}` WHERE user_id = 1" );
}
public function test_all_backfills_every_registered_user_group(): void {
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'all' => true ) );
$this->assertSame( 'one', $this->flat( self::ONE, 'cb_a' ) );
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
}
public function test_group_accepts_any_registered_user_group(): void {
( new TMDO_CLI_Member() )->member_backfill( array(), array( 'group' => 'cb_two' ) );
$this->assertSame( 'two', $this->flat( self::TWO, 'cb_b' ) );
$this->assertNull( $this->flat( self::ONE, 'cb_a' ), 'only the requested group' );
}
public function test_backfillable_groups_leave_out_restored_orphans(): void {
WPDO_Entity_Registry::restore_group( 'user', 'cb_orphan', array( array( 'key' => 'cb_orphan_key', 'type' => 'text' ) ) );
$this->assertSame( array( 'cb_one', 'cb_two' ), TMDO_Entity_Migration_Engine::backfillable_groups( 'user' ) );
}
}