<?php namespace Automattic\WooCommerce\Internal\ProductAttributesLookup; use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; use WP_CLI; /** * Command line tools to handle the regeneration of the product aatributes lookup table. */ class CLIRunner { use AccessiblePrivateMethods; /** * The instance of DataRegenerator to use. * * @var DataRegenerator */ private DataRegenerator $data_regenerator; /** * The instance of DataRegenerator to use. * * @var LookupDataStore */ private LookupDataStore $lookup_data_store; /** * Creates a new instance of the class. */ public function __construct() { self::mark_method_as_accessible( 'init' ); } // phpcs:disable WooCommerce.Functions.InternalInjectionMethod /** * Class initialization, invoked by the DI container. * * This method is normally defined as public, we define it as private here * (and "publicize" it in the constructor) to prevent WP_CLI from * creating a dummy command for it. * * @param DataRegenerator $data_regenerator The instance of DataRegenerator to use. * @param LookupDataStore $lookup_data_store The instance of DataRegenerator to use. */ private function init( DataRegenerator $data_regenerator, LookupDataStore $lookup_data_store ) { $this->data_regenerator = $data_regenerator; $this->lookup_data_store = $lookup_data_store; } // phpcs:enable WooCommerce.Functions.InternalInjectionMethod // phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed /** * Enable the usage of the product attributes lookup table. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function enable( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'enable_core', $args, $assoc_args ); } /** * Core method for the "enable" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ private function enable_core( array $args, array $assoc_args ) { $table_name = $this->lookup_data_store->get_lookup_table_name(); if ( 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' ) ) { $this->warning( "The usage of the of the %W{$table_name}%n table is already enabled." ); return; } if ( ! array_key_exists( 'force', $assoc_args ) ) { $must_confirm = true; if ( $this->lookup_data_store->regeneration_is_in_progress() ) { $this->warning( "The regeneration of the %W{$table_name}%n table is currently in process." ); } elseif ( $this->lookup_data_store->regeneration_was_aborted() ) { $this->warning( "The regeneration of the %W{$table_name}%n table was aborted." ); } elseif ( 0 === $this->get_lookup_table_info()['total_rows'] ) { $this->warning( "The %W{$table_name}%n table is empty." ); } else { $must_confirm = false; } if ( $must_confirm ) { WP_CLI::confirm( 'Are you sure that you want to enable the table usage?' ); } } update_option( 'woocommerce_attribute_lookup_enabled', 'yes' ); $table_name = $this->lookup_data_store->get_lookup_table_name(); $this->success( "The usage of the %W{$table_name}%n table for product attribute lookup has been enabled." ); } /** * Disable the usage of the product attributes lookup table. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function disable( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'disable_core', $args, $assoc_args ); } /** * Core method for the "disable" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ private function disable_core( array $args, array $assoc_args ) { if ( 'yes' !== get_option( 'woocommerce_attribute_lookup_enabled' ) ) { $table_name = $this->lookup_data_store->get_lookup_table_name(); $this->warning( "The usage of the of the %W{$table_name}%n table is already disabled." ); return; } update_option( 'woocommerce_attribute_lookup_enabled', 'no' ); $table_name = $this->lookup_data_store->get_lookup_table_name(); $this->success( "The usage of the %W{$table_name}%n table for product attribute lookup has been disabled." ); } /** * Regenerate the product attributes lookup table data for one single product. * * ## OPTIONS * * <product-id> * : The id of the product for which the data will be regenerated. * * [--disable-db-optimization] * : Don't use optimized database access even if products are stored as custom post types. * * ## EXAMPLES * * wp wc palt regenerate_for_product 34 --disable-db-optimization * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function regenerate_for_product( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'regenerate_for_product_core', $args, $assoc_args ); } /** * Core method for the "regenerate_for_product" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ private function regenerate_for_product_core( array $args = array(), array $assoc_args = array() ) { $product_id = current( $args ); $this->data_regenerator->check_can_do_lookup_table_regeneration( $product_id ); $use_db_optimization = ! array_key_exists( 'disable-db-optimization', $assoc_args ); $this->check_can_use_db_optimization( $use_db_optimization ); $start_time = microtime( true ); $this->lookup_data_store->create_data_for_product( $product_id, $use_db_optimization ); if ( $this->lookup_data_store->get_last_create_operation_failed() ) { $this->error( "Lookup data regeneration failed.\nSee the WooCommerce logs (source is %9palt-updates%n) for details." ); } else { $total_time = microtime( true ) - $start_time; WP_CLI::success( sprintf( 'Attributes lookup data for product %d regenerated in %f seconds.', $product_id, $total_time ) ); } } /** * If database access optimization is requested but can't be used, show a warning. * * @param bool $use_db_optimization True if database access optimization is requested. */ private function check_can_use_db_optimization( bool $use_db_optimization ) { if ( $use_db_optimization && ! $this->lookup_data_store->can_use_optimized_db_access() ) { $this->warning( "Optimized database access can't be used (products aren't stored as custom post types)." ); } } /** * Obtain information about the product attributes lookup table. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function info( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'info_core', $args, $assoc_args ); } /** * Core method for the "info" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ private function info_core( array $args, array $assoc_args ) { global $wpdb; $enabled = 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' ); $table_name = $this->lookup_data_store->get_lookup_table_name(); $info = $this->get_lookup_table_info(); $this->log( "Table name: %W{$table_name}%n" ); $this->log( 'Table usage is ' . ( $enabled ? '%Genabled%n' : '%Ydisabled%n' ) ); $this->log( "The table contains %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." ); if ( $info['total_rows'] > 0 ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $highest_product_id_in_table = $wpdb->get_var( 'select max(product_or_parent_id) from ' . $table_name ); $this->log( "The highest product id in the table is %B{$highest_product_id_in_table}%n." ); } if ( $this->lookup_data_store->regeneration_is_in_progress() ) { $max_product_id_to_process = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process', '???' ); WP_CLI::log( '' ); $this->warning( 'Full regeneration of the table is currently %Gin progress.%n' ); if ( ! $this->data_regenerator->has_scheduled_action_for_regeneration_step() ) { $this->log( 'However, there are %9NO%n actions scheduled to run the regeneration steps (a %9wp cli palt regenerate%n command was aborted?).' ); } $this->log( "The last product id that will be processed is %Y{$max_product_id_to_process}%n." ); $this->log( "\nRun %9wp cli palt abort_regeneration%n to abort the regeneration process," ); $this->log( "then you'll be able to run %9wp cli palt resume_regeneration%n to resume the regeneration process," ); } elseif ( $this->lookup_data_store->regeneration_was_aborted() ) { $max_product_id_to_process = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process', '???' ); WP_CLI::log( '' ); $this->warning( "Full regeneration of the table has been %Raborted.%n\nThe last product id that will be processed is %Y{$max_product_id_to_process}%n." ); $this->log( "\nRun %9wp cli palt resume_regeneration%n to resume the regeneration process." ); } } /** * Abort the background regeneration of the product attributes lookup table that is happening in the background. * * [--cleanup] * : Also cleanup temporary data (so regeneration can't be resumed, but it can be restarted). * * ## EXAMPLES * * wp wc palt abort_regeneration --cleanup * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function abort_regeneration( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'abort_regeneration_core', $args, $assoc_args ); } /** * Core method for the "abort_regeneration" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ private function abort_regeneration_core( array $args, array $assoc_args ) { $this->data_regenerator->abort_regeneration( false ); $table_name = $this->lookup_data_store->get_lookup_table_name(); $this->success( "The regeneration of the data in the %W{$table_name}%n table has been aborted." ); if ( array_key_exists( 'cleanup', $assoc_args ) ) { $this->cleanup_regeneration_progress( array(), array() ); } } /** * Resume the background regeneration of the product attributes lookup table after it has been aborted. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function resume_regeneration( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'resume_regeneration_core', $args, $assoc_args ); } /** * Core method for the "resume_regeneration" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ private function resume_regeneration_core( array $args, array $assoc_args ) { $this->data_regenerator->resume_regeneration( false ); $table_name = $this->lookup_data_store->get_lookup_table_name(); $this->success( "The regeneration of the data in the %W{$table_name}%n table has been resumed." ); } /** * Delete the temporary data used during the regeneration of the product attributes lookup table. This data is normally deleted automatically after the regeneration process finishes. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function cleanup_regeneration_progress( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'cleanup_regeneration_progress_core', $args, $assoc_args ); } /** * Core method for the "cleanup_regeneration_progress" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ private function cleanup_regeneration_progress_core( array $args, array $assoc_args ) { $this->data_regenerator->finalize_regeneration( false ); $table_name = $this->lookup_data_store->get_lookup_table_name(); $this->success( "The temporary data used for regeneration of the data in the %W{$table_name}%n table has been deleted." ); } /** * Initiate the background regeneration of the product attributes lookup table. The regeneration will happen in the background, using scheduled actions. * * ## OPTIONS * * [--force] * : Don't prompt for confirmation if the product attributes lookup table isn't empty. * * ## EXAMPLES * * wp wc palt initiate_regeneration --force * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function initiate_regeneration( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'initiate_regeneration_core', $args, $assoc_args ); } /** * Core method for the "initiate_regeneration" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ private function initiate_regeneration_core( array $args, array $assoc_args ) { $this->data_regenerator->check_can_do_lookup_table_regeneration(); $info = $this->get_lookup_table_info(); if ( $info['total_rows'] > 0 && ! array_key_exists( 'force', $assoc_args ) ) { $table_name = $this->lookup_data_store->get_lookup_table_name(); $this->warning( "The %W{$table_name}%n table contains %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." ); WP_CLI::confirm( 'Initiating the regeneration will first delete the data. Are you sure?' ); } $this->data_regenerator->initiate_regeneration(); $table_name = $this->lookup_data_store->get_lookup_table_name(); $this->log( "%GSuccess:%n The regeneration of the data in the %W{$table_name}%n table has been initiated." ); } /** * Regenerate the product attributes lookup table immediately, without using scheduled tasks. * * ## OPTIONS * * [--force] * : Don't prompt for confirmation if the product attributes lookup table isn't empty. * * [--from-scratch] * : Start table regeneration from scratch even if a regeneration is already in progress. * * [--disable-db-optimization] * : Don't use optimized database access even if products are stored as custom post types. * * [--batch-size=<size>] * : How many products to process in each iteration of the loop. * --- * default: 10 * --- * * ## EXAMPLES * * wp wc palt regenerate --force --from-scratch --batch-size=20 * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. */ public function regenerate( array $args = array(), array $assoc_args = array() ) { return $this->invoke( 'regenerate_core', $args, $assoc_args ); } /** * Core method for the "regenerate" command. * * @param array $args Positional arguments passed to the command. * @param array $assoc_args Associative arguments (options) passed to the command. * @throws \Exception Invalid batch size argument. */ private function regenerate_core( array $args = array(), array $assoc_args = array() ) { global $wpdb; $table_name = $this->lookup_data_store->get_lookup_table_name(); $batch_size = $assoc_args['batch-size'] ?? DataRegenerator::PRODUCTS_PER_GENERATION_STEP; if ( ! is_numeric( $batch_size ) || $batch_size < 1 ) { throw new \Exception( 'batch_size must be a number bigger than 0' ); } $was_enabled = 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' ); // phpcs:ignore Generic.Commenting.Todo.TaskFound // TODO: adjust for non-CPT datastores (this is only used for the progress bar, though). $products_count = wp_count_posts( 'product' ); $products_count = intval( $products_count->publish ) + intval( $products_count->pending ) + intval( $products_count->draft ); if ( ! $this->lookup_data_store->regeneration_is_in_progress() || array_key_exists( 'from-scratch', $assoc_args ) ) { $info = $this->get_lookup_table_info(); if ( $info['total_rows'] > 0 && ! array_key_exists( 'force', $assoc_args ) ) { $this->warning( "The %W{$table_name}%n table contains %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." ); WP_CLI::confirm( 'Triggering the regeneration will first delete the data. Are you sure?' ); } $this->data_regenerator->finalize_regeneration( false ); $last_product_id = $this->data_regenerator->initiate_regeneration( false ); if ( 0 === $last_product_id ) { $this->data_regenerator->finalize_regeneration( $was_enabled ); WP_CLI::log( 'No products exist in the database, the table is left empty.' ); return; } $processed_count = 0; } else { $last_product_id = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process' ); if ( false === $last_product_id ) { WP_CLI::error( 'Regeneration seems to be already in progress, but the woocommerce_attribute_lookup_last_product_id_to_process option isn\'t there. Try %9wp cli palt cleanup_regeneration_progress%n first." );' ); return 1; } $processed_count = get_option( 'woocommerce_attribute_lookup_processed_count', 0 ); $this->log( "Resuming regeneration, %C{$processed_count}%n products have been processed already" ); $this->lookup_data_store->set_regeneration_in_progress_flag(); } $this->data_regenerator->cancel_regeneration_scheduled_action(); $use_db_optimization = ! array_key_exists( 'disable-db-optimization', $assoc_args ); $this->check_can_use_db_optimization( $use_db_optimization ); $progress = WP_CLI\Utils\make_progress_bar( '', $products_count ); $this->log( "Regenerating %W{$table_name}%n..." ); $progress->tick( $processed_count ); $regeneration_step_failed = false; while ( $this->data_regenerator->do_regeneration_step( $batch_size, $use_db_optimization ) ) { $progress->tick( $batch_size ); $regeneration_step_failed = $regeneration_step_failed || $this->data_regenerator->get_last_regeneration_step_failed(); } $this->data_regenerator->finalize_regeneration( $was_enabled ); $time = $progress->formatTime( $progress->elapsed() ); $progress->finish(); if ( $regeneration_step_failed ) { $this->warning( "Lookup data regeneration failed for at least one product.\nSee the WooCommerce logs (source is %9palt-updates%n) for details.\n" ); $this->log( "Table %W{$table_name}%n regenerated in {$time}." ); } else { $this->log( "%GSuccess:%n Table %W{$table_name}%n regenerated in {$time}." ); } $info = $this->get_lookup_table_info(); $this->log( "The table contains now %C{$info['total_rows']}%n rows corresponding to %G{$info['products_count']}%n products." ); } // phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed /** * Get information about the product attributes lookup table. * * @return array Array containing the 'total_rows' and 'products_count' keys. */ private function get_lookup_table_info(): array { global $wpdb; $table_name = $this->lookup_data_store->get_lookup_table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $info = $wpdb->get_row( 'select count(1), count(distinct(product_or_parent_id)) from ' . $table_name, ARRAY_N ); return array( 'total_rows' => absint( $info[0] ), 'products_count' => absint( $info[1] ), ); } /** * Invoke a method from the class, and if an exception is thrown, show it using WP_CLI::error. * * @param string $method_name Name of the method to invoke. * @param array $args Positional arguments to pass to the method. * @param array $assoc_args Associative arguments to pass to the method. * @return mixed Result from the method, or 1 if an exception is thrown. */ private function invoke( string $method_name, array $args, array $assoc_args ) { try { return call_user_func( array( $this, $method_name ), $args, $assoc_args ); } catch ( \Exception $e ) { WP_CLI::error( $e->getMessage() ); return 1; } } /** * Show a log message using the WP_CLI text colorization feature. * * @param string $text Text to show. */ private function log( string $text ) { WP_CLI::log( WP_CLI::colorize( $text ) ); } /** * Show a warning message using the WP_CLI text colorization feature. * * @param string $text Text to show. */ private function warning( string $text ) { WP_CLI::warning( WP_CLI::colorize( $text ) ); } /** * Show a success message using the WP_CLI text colorization feature. * * @param string $text Text to show. */ private function success( string $text ) { WP_CLI::success( WP_CLI::colorize( $text ) ); } /** * Show an error message using the WP_CLI text colorization feature. * * @param string $text Text to show. */ private function error( string $text ) { WP_CLI::error( WP_CLI::colorize( $text ) ); } }