Add CLI tool to remove order data from legacy tables (#42848)

* Introduce `LegacyDataHandler` for handling legacy orders in the HPOS datastore

* Add methods to count and obtain orders subject to cleanup

* First pass at metadata cleanup for orders

* Add unit tests

* Implement `wc hpos cleanup` CLI tool

* Make PHPCS happy

* Add changelog

* Change error to warning

* Improve tests

* Fix unit tests

* Allow cleaning up of placeholders with meta

* Add support for `--force` flag

* Update plugins/woocommerce/changelog/enhancement-41914

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* Update plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* Update plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>

* Exclude auto-draft

---------

Co-authored-by: Barry Hughes <3594411+barryhughes@users.noreply.github.com>
This commit is contained in:
Jorge A. Torres 2023-12-19 09:32:55 +00:00 committed by GitHub
parent bd6a8d365a
commit 1965dfb63e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 418 additions and 0 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add CLI command `wc hpos cleanup` to cleanup post and post meta data for migrated orders.

View File

@ -4,6 +4,7 @@ namespace Automattic\WooCommerce\DataBase\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\LegacyDataHandler;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use WP_CLI;
@ -63,6 +64,7 @@ class CLIRunner {
WP_CLI::add_command( 'wc cot verify_cot_data', array( $this, 'verify_cot_data' ) );
WP_CLI::add_command( 'wc cot enable', array( $this, 'enable' ) );
WP_CLI::add_command( 'wc cot disable', array( $this, 'disable' ) );
WP_CLI::add_command( 'wc hpos cleanup', array( $this, 'cleanup_post_data' ) );
}
/**
@ -862,4 +864,99 @@ ORDER BY $meta_table.order_id ASC, $meta_table.meta_key ASC;
}
}
/**
* When HPOS is enabled, this command lets you remove redundant data from the postmeta table for migrated orders.
*
* ## OPTIONS
*
* <all|id|range>...
* : ID or range of orders to clean up.
*
* [--batch-size=<batch-size>]
* : Number of orders to process per batch. Applies only to cleaning up of 'all' orders.
* ---
* default: 500
* ---
*
* [--force]
* : When true, post meta will be cleaned up even if the post appears to have been updated more recently than the order.
* ---
* default: false
* ---
*
* ## EXAMPLES
*
* # Cleanup post data for order 314.
* $ wp wc hpos cleanup 314
*
* # Cleanup postmeta for orders with IDs betweeen 10 and 100 and order 314.
* $ wp wc hpos cleanup 10-100 314
*
* # Cleanup postmeta for all orders.
* wp wc hpos cleanup all
*
* # Cleanup postmeta for all orders with a batch size of 200 (instead of the default 500).
* wp wc hpos cleanup all --batch-size=200
*
* @param array $args Positional arguments passed to the command.
* @param array $assoc_args Associative arguments (options) passed to the command.
* @return void
*/
public function cleanup_post_data( array $args = array(), array $assoc_args = array() ) {
if ( ! $this->synchronizer->custom_orders_table_is_authoritative() || $this->synchronizer->data_sync_is_enabled() ) {
WP_CLI::error( __( 'Cleanup can only be performed when HPOS is active and compatibility mode is disabled.', 'woocommerce' ) );
}
$handler = wc_get_container()->get( LegacyDataHandler::class );
$all_orders = 'all' === $args[0];
$force = (bool) ( $assoc_args['force'] ?? false );
$q_order_ids = $all_orders ? array() : $args;
$q_limit = $all_orders ? absint( $assoc_args['batch-size'] ?? 500 ) : 0; // Limit per batch.
$order_count = $handler->count_orders_for_cleanup( $q_order_ids );
if ( ! $order_count ) {
WP_CLI::warning( __( 'No orders to cleanup.', 'woocommerce' ) );
return;
}
$progress = WP_CLI\Utils\make_progress_bar( __( 'HPOS cleanup', 'woocommerce' ), $order_count );
$count = 0;
// translators: %d is the number of orders to clean up.
WP_CLI::log( sprintf( _n( 'Starting cleanup for %d order...', 'Starting cleanup for %d orders...', $order_count, 'woocommerce' ), $order_count ) );
do {
$order_ids = $handler->get_orders_for_cleanup( $q_order_ids, $q_limit );
foreach ( $order_ids as $order_id ) {
try {
$handler->cleanup_post_data( $order_id, $force );
$count++;
// translators: %d is an order ID.
WP_CLI::debug( sprintf( __( 'Cleanup completed for order %d.', 'woocommerce' ), $order_id ) );
} catch ( \Exception $e ) {
// translators: %1$d is an order ID, %2$s is an error message.
WP_CLI::warning( sprintf( __( 'An error occurred while cleaning up order %1$d: %2$s', 'woocommerce' ), $order_id, $e->getMessage() ) );
}
$progress->tick();
}
if ( ! $all_orders ) {
break;
}
} while ( $order_ids );
$progress->finish();
WP_CLI::success(
sprintf(
// translators: %d is the number of orders that were cleaned up.
_n( 'Cleanup completed for %d order.', 'Cleanup completed for %d orders.', $count, 'woocommerce' ),
$count
)
);
}
}

View File

@ -0,0 +1,200 @@
<?php
/**
* LegacyDataHandler class file.
*/
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
defined( 'ABSPATH' ) || exit;
/**
* This class provides functionality to clean up post data from the posts table when HPOS is authoritative.
*/
class LegacyDataHandler {
/**
* Instance of the HPOS datastore.
*
* @var OrdersTableDataStore
*/
private OrdersTableDataStore $data_store;
/**
* Instance of the DataSynchronizer class.
*
* @var DataSynchronizer
*/
private DataSynchronizer $data_synchronizer;
/**
* Class initialization, invoked by the DI container.
*
* @param OrdersTableDataStore $data_store HPOS datastore instance to use.
* @param DataSynchronizer $data_synchronizer DataSynchronizer instance to use.
*
* @internal
*/
final public function init( OrdersTableDataStore $data_store, DataSynchronizer $data_synchronizer ) {
$this->data_store = $data_store;
$this->data_synchronizer = $data_synchronizer;
}
/**
* Returns the total number of orders for which legacy post data can be removed.
*
* @param array $order_ids If provided, total is computed only among IDs in this array, which can be either individual IDs or ranges like "100-200".
* @return int Number of orders.
*/
public function count_orders_for_cleanup( $order_ids = array() ) : int {
global $wpdb;
return (int) $wpdb->get_var( $this->build_sql_query_for_cleanup( $order_ids, 'count' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
}
/**
* Returns a set of orders for which legacy post data can be removed.
*
* @param array $order_ids If provided, result is a subset of the order IDs in this array, which can contain either individual order IDs or ranges like "100-200".
* @param int $limit Limit the number of results.
* @return array[int] Order IDs.
*/
public function get_orders_for_cleanup( $order_ids = array(), int $limit = 0 ): array {
global $wpdb;
return array_map(
'absint',
$wpdb->get_col( $this->build_sql_query_for_cleanup( $order_ids, 'ids', $limit ) ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepared in build_sql_query_for_cleanup().
);
}
/**
* Builds a SQL statement to either count or obtain IDs for orders in need of cleanup.
*
* @param array $order_ids If provided, the query will only include orders in this set of order IDs or ID ranges (like "10-100").
* @param string $result Use 'count' to build a query that returns a count. Otherwise, the query will return order IDs.
* @param integer $limit If provided, the query will be limited to this number of results. Does not apply when $result is 'count'.
* @return string SQL query.
*/
private function build_sql_query_for_cleanup( array $order_ids = array(), string $result = 'ids', int $limit = 0 ): string {
global $wpdb;
$sql_where = '';
if ( $order_ids ) {
// Expand ranges in $order_ids as needed to build the WHERE clause.
$where_ids = array();
$where_ranges = array();
foreach ( $order_ids as &$arg ) {
if ( is_numeric( $arg ) ) {
$where_ids[] = absint( $arg );
} elseif ( preg_match( '/^(\d+)-(\d+)$/', $arg, $matches ) ) {
$where_ranges[] = $wpdb->prepare( "({$wpdb->posts}.ID >= %d AND {$wpdb->posts}.ID <= %d)", absint( $matches[1] ), absint( $matches[2] ) );
}
}
if ( $where_ids ) {
$where_ranges[] = "{$wpdb->posts}.ID IN (" . implode( ',', $where_ids ) . ')';
}
if ( ! $where_ranges ) {
$sql_where .= '1=0';
} else {
$sql_where .= '(' . implode( ' OR ', $where_ranges ) . ')';
}
}
$sql_where .= $sql_where ? ' AND ' : '';
// Post type handling.
$sql_where .= '(';
$sql_where .= "{$wpdb->posts}.post_type IN ('" . implode( "', '", esc_sql( wc_get_order_types( 'cot-migration' ) ) ) . "')";
$sql_where .= $wpdb->prepare(
" OR (post_type = %s AND EXISTS(SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = {$wpdb->posts}.ID))",
$this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE
);
$sql_where .= ')';
// Exclude 'auto-draft' since those go away on their own.
$sql_where .= $wpdb->prepare( " AND {$wpdb->posts}.post_status != %s", 'auto-draft' );
if ( 'count' === $result ) {
$sql_fields = 'COUNT(*)';
$sql_limit = '';
} else {
$sql_fields = 'ID';
$sql_limit = $limit > 0 ? $wpdb->prepare( 'LIMIT %d', $limit ) : '';
}
return "SELECT {$sql_fields} FROM {$wpdb->posts} WHERE {$sql_where} {$sql_limit}";
}
/**
* Performs a cleanup of post data for a given order and also converts the post to the placeholder type in the backup table.
*
* @param int $order_id Order ID.
* @param bool $skip_checks Whether to skip the checks that happen before the order is cleaned up.
* @return void
* @throws \Exception When an error occurs.
*/
public function cleanup_post_data( int $order_id, bool $skip_checks = false ): void {
global $wpdb;
$order = wc_get_order( $order_id );
if ( ! $order ) {
// translators: %d is an order ID.
throw new \Exception( sprintf( __( '%d is not a valid order ID.', 'woocommerce' ), $order_id ) );
}
if ( ! $skip_checks && ! $this->is_order_newer_than_post( $order ) ) {
throw new \Exception( sprintf( __( 'Data in posts table appears to be more recent than in HPOS tables.', 'woocommerce' ) ) );
}
$meta_ids = $wpdb->get_col( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d", $order->get_id() ) );
foreach ( $meta_ids as $meta_id ) {
delete_metadata_by_mid( 'post', $meta_id );
}
// wp_update_post() changes the post modified date, so we do this manually.
// Also, we suspect using wp_update_post() could lead to integrations mistakenly updating the entity.
$wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->posts} SET post_type = %s, post_status = %s WHERE ID = %d",
$this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE,
'draft',
$order->get_id()
)
);
clean_post_cache( $order->get_id() );
}
/**
* Checks whether an HPOS-backed order is newer than the corresponding post.
*
* @param int|\WC_Order $order An HPOS order.
* @return bool TRUE if the order is up to date with the corresponding post.
* @throws \Exception When the order is not an HPOS order.
*/
private function is_order_newer_than_post( $order ): bool {
$order = is_a( $order, 'WC_Order' ) ? $order : wc_get_order( absint( $order ) );
if ( ! is_a( $order->get_data_store()->get_current_class_name(), OrdersTableDataStore::class, true ) ) {
throw new \Exception( __( 'Order is not an HPOS order.', 'woocommerce' ) );
}
$post = get_post( $order->get_id() );
if ( ! $post || $this->data_synchronizer::PLACEHOLDER_ORDER_POST_TYPE === $post->post_type ) {
return true;
}
$order_modified_gmt = $order->get_date_modified() ?? $order->get_date_created();
$order_modified_gmt = $order_modified_gmt ? $order_modified_gmt->getTimestamp() : 0;
$post_modified_gmt = $post->post_modified_gmt ?? $post->post_date_gmt;
$post_modified_gmt = ( $post_modified_gmt && '0000-00-00 00:00:00' !== $post_modified_gmt ) ? wc_string_to_timestamp( $post_modified_gmt ) : 0;
return $order_modified_gmt >= $post_modified_gmt;
}
}

View File

@ -16,6 +16,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableRefundDataStore
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\LegacyDataHandler;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStoreMeta;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
@ -42,6 +43,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
OrdersTableRefundDataStore::class,
OrderCache::class,
OrderCacheController::class,
LegacyDataHandler::class,
);
/**
@ -79,5 +81,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
if ( Constants::is_defined( 'WP_CLI' ) && WP_CLI ) {
$this->share( CLIRunner::class )->addArguments( array( CustomOrdersTableController::class, DataSynchronizer::class, PostsToOrdersMigrationController::class ) );
}
$this->share( LegacyDataHandler::class )->addArguments( array( OrdersTableDataStore::class, DataSynchronizer::class ) );
}
}

View File

@ -0,0 +1,113 @@
<?php
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
use Automattic\WooCommerce\Internal\DataStores\Orders\LegacyDataHandler;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
/**
* Class OrdersTableQueryTests.
*/
class LegacyDataHandlerTests extends WC_Unit_Test_Case {
use HPOSToggleTrait;
/**
* @var LegacyDataHandler
*/
private $sut;
/**
* Initializes system under test.
*/
public function setUp(): void {
parent::setUp();
add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
$this->setup_cot();
$this->sut = wc_get_container()->get( LegacyDataHandler::class );
}
/**
* Destroys system under test.
*/
public function tearDown(): void {
parent::tearDown();
$this->clean_up_cot_setup();
remove_all_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending' );
}
/**
* Tests the cleanup of legacy data.
*/
public function test_post_data_cleanup() {
$this->enable_cot_sync();
$orders = array(
OrderHelper::create_order(),
OrderHelper::create_order(),
);
$this->disable_cot_sync();
// Confirm orders have been synced up (i.e. are not placeholders) and contain metadata.
foreach ( $orders as $order ) {
$this->assertEquals( 'shop_order', get_post_type( $order->get_id() ) );
$this->assertNotEmpty( get_post_meta( $order->get_id() ) );
}
// Check that counts are working ok.
$this->assertEquals( 1, $this->sut->count_orders_for_cleanup( array( $orders[0]->get_id() ) ) );
$this->assertEquals( 2, $this->sut->count_orders_for_cleanup() );
// Cleanup one of the orders.
$this->sut->cleanup_post_data( $orders[0]->get_id() );
// Confirm metadata has been removed and post type has been reset to placeholder.
$this->assertEmpty( get_post_meta( $orders[0]->get_id() ) );
$this->assertEquals( 'shop_order_placehold', get_post_type( $orders[0]->get_id() ) );
// Check counts.
$this->assertEquals( 0, $this->sut->count_orders_for_cleanup( array( $orders[0]->get_id() ) ) );
$this->assertEquals( 1, $this->sut->count_orders_for_cleanup() );
// Confirm that we now have 1 unsynced order (due to the removal of the backup data).
$this->assertEquals( 1, wc_get_container()->get( DataSynchronizer::class )->get_current_orders_pending_sync_count() );
}
/**
* Tests that cleanup for a non-existent order throws an exception.
*/
public function test_post_data_cleanup_non_existent() {
$this->expectException( \Exception::class );
$this->sut->cleanup_post_data( 0 );
}
/**
* Tests `get_orders_for_cleanup()` with various arguments, including ranges of orders and individual order IDs.
*/
public function test_get_orders_for_cleanup() {
// Create a few orders.
$this->enable_cot_sync();
$order_ids = array();
for ( $i = 0; $i < 10; $i++ ) {
$order_id = OrderHelper::create_order()->get_id();
$order_ids[] = $order_id;
}
$this->disable_cot_sync();
$this->assertCount( 0, $this->sut->get_orders_for_cleanup( array( max( $order_ids ) + 1 ) ) );
$this->assertCount( 10, $this->sut->get_orders_for_cleanup() );
$this->assertCount( 10, $this->sut->get_orders_for_cleanup( $order_ids ) );
$interval = min( $order_ids ) . '-' . max( $order_ids );
$this->assertCount( 10, $this->sut->get_orders_for_cleanup( array( $interval ) ) );
$this->assertCount( 0, $this->sut->get_orders_for_cleanup( array( '300-2' ) ) );
$slice = array_slice( $order_ids, 5 );
$interval = min( $slice ) . '-' . max( $slice );
$this->assertCount( 5, $this->sut->get_orders_for_cleanup( $slice ) );
$this->assertCount( 5, $this->sut->get_orders_for_cleanup( array( $interval ) ) );
$this->assertCount( 7, $this->sut->get_orders_for_cleanup( array( $order_ids[0], $order_ids[1], $interval ) ) );
$this->assertCount( 10, $this->sut->get_orders_for_cleanup( array( $interval, '0-' . min( $slice ) ) ) );
}
}