diff --git a/plugins/woocommerce/changelog/enhancement-41914 b/plugins/woocommerce/changelog/enhancement-41914 new file mode 100644 index 00000000000..5222e6f37a8 --- /dev/null +++ b/plugins/woocommerce/changelog/enhancement-41914 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add CLI command `wc hpos cleanup` to cleanup post and post meta data for migrated orders. diff --git a/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php b/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php index e9daad2e87e..67964323604 100644 --- a/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php +++ b/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/CLIRunner.php @@ -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 + * + * ... + * : ID or range of orders to clean up. + * + * [--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 + ) + ); + } + } diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/LegacyDataHandler.php b/plugins/woocommerce/src/Internal/DataStores/Orders/LegacyDataHandler.php new file mode 100644 index 00000000000..ab94fd6d2a2 --- /dev/null +++ b/plugins/woocommerce/src/Internal/DataStores/Orders/LegacyDataHandler.php @@ -0,0 +1,200 @@ +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; + } + + + +} diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php index 287d676e57d..6f3dc75c952 100644 --- a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/OrdersDataStoreServiceProvider.php @@ -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 ) ); } } diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/LegacyDataHandlerTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/LegacyDataHandlerTests.php new file mode 100644 index 00000000000..5d20a44088a --- /dev/null +++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/LegacyDataHandlerTests.php @@ -0,0 +1,113 @@ +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 ) ) ) ); + } + +}