File "CustomOrdersTableController.php"

Full Path: /home/jlklyejr/public_html/wp-content/test/wp-content/plugins/woocommerce/src/Internal/DataStores/Orders/CustomOrdersTableController.php
File size: 21.44 KB
MIME-type: text/x-php
Charset: utf-8

<?php
/**
 * CustomOrdersTableController class file.
 */

namespace Automattic\WooCommerce\Internal\DataStores\Orders;

use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\PluginUtil;
use WC_Admin_Settings;

defined( 'ABSPATH' ) || exit;

/**
 * This is the main class that controls the custom orders tables feature. Its responsibilities are:
 *
 * - Displaying UI components (entries in the tools page and in settings)
 * - Providing the proper data store for orders via 'woocommerce_order_data_store' hook
 *
 * ...and in general, any functionality that doesn't imply database access.
 */
class CustomOrdersTableController {

	use AccessiblePrivateMethods;

	private const SYNC_QUERY_ARG = 'wc_hpos_sync_now';

	/**
	 * The name of the option for enabling the usage of the custom orders tables
	 */
	public const CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION = 'woocommerce_custom_orders_table_enabled';

	/**
	 * The name of the option that tells whether database transactions are to be used or not for data synchronization.
	 */
	public const USE_DB_TRANSACTIONS_OPTION = 'woocommerce_use_db_transactions_for_custom_orders_table_data_sync';

	/**
	 * The name of the option to store the transaction isolation level to use when database transactions are enabled.
	 */
	public const DB_TRANSACTIONS_ISOLATION_LEVEL_OPTION = 'woocommerce_db_transactions_isolation_level_for_custom_orders_table_data_sync';

	public const DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL = 'READ UNCOMMITTED';

	/**
	 * The data store object to use.
	 *
	 * @var OrdersTableDataStore
	 */
	private $data_store;

	/**
	 * Refunds data store object to use.
	 *
	 * @var OrdersTableRefundDataStore
	 */
	private $refund_data_store;

	/**
	 * The data synchronizer object to use.
	 *
	 * @var DataSynchronizer
	 */
	private $data_synchronizer;

	/**
	 * The data cleanup instance to use.
	 *
	 * @var LegacyDataCleanup
	 */
	private $data_cleanup;

	/**
	 * The batch processing controller to use.
	 *
	 * @var BatchProcessingController
	 */
	private $batch_processing_controller;

	/**
	 * The features controller to use.
	 *
	 * @var FeaturesController
	 */
	private $features_controller;

	/**
	 * The orders cache object to use.
	 *
	 * @var OrderCache
	 */
	private $order_cache;

	/**
	 * The orders cache controller object to use.
	 *
	 * @var OrderCacheController
	 */
	private $order_cache_controller;

	/**
	 * The plugin util object to use.
	 *
	 * @var PluginUtil
	 */
	private $plugin_util;

	/**
	 * Class constructor.
	 */
	public function __construct() {
		$this->init_hooks();
	}

	/**
	 * Initialize the hooks used by the class.
	 */
	private function init_hooks() {
		self::add_filter( 'woocommerce_order_data_store', array( $this, 'get_orders_data_store' ), 999, 1 );
		self::add_filter( 'woocommerce_order-refund_data_store', array( $this, 'get_refunds_data_store' ), 999, 1 );
		self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_hpos_tools' ), 999 );
		self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
		self::add_filter( 'pre_update_option', array( $this, 'process_pre_update_option' ), 999, 3 );
		self::add_action( 'woocommerce_after_register_post_type', array( $this, 'register_post_type_for_order_placeholders' ), 10, 0 );
		self::add_action( 'woocommerce_sections_advanced', array( $this, 'sync_now' ) );
		self::add_filter( 'removable_query_args', array( $this, 'register_removable_query_arg' ) );
		self::add_action( 'woocommerce_register_feature_definitions', array( $this, 'add_feature_definition' ) );
	}

	/**
	 * Class initialization, invoked by the DI container.
	 *
	 * @internal
	 * @param OrdersTableDataStore       $data_store The data store to use.
	 * @param DataSynchronizer           $data_synchronizer The data synchronizer to use.
	 * @param LegacyDataCleanup          $data_cleanup The legacy data cleanup instance to use.
	 * @param OrdersTableRefundDataStore $refund_data_store The refund data store to use.
	 * @param BatchProcessingController  $batch_processing_controller The batch processing controller to use.
	 * @param FeaturesController         $features_controller The features controller instance to use.
	 * @param OrderCache                 $order_cache The order cache engine to use.
	 * @param OrderCacheController       $order_cache_controller The order cache controller to use.
	 * @param PluginUtil                 $plugin_util The plugin util to use.
	 */
	final public function init(
		OrdersTableDataStore $data_store,
		DataSynchronizer $data_synchronizer,
		LegacyDataCleanup $data_cleanup,
		OrdersTableRefundDataStore $refund_data_store,
		BatchProcessingController $batch_processing_controller,
		FeaturesController $features_controller,
		OrderCache $order_cache,
		OrderCacheController $order_cache_controller,
		PluginUtil $plugin_util
	) {
		$this->data_store                  = $data_store;
		$this->data_synchronizer           = $data_synchronizer;
		$this->data_cleanup                = $data_cleanup;
		$this->batch_processing_controller = $batch_processing_controller;
		$this->refund_data_store           = $refund_data_store;
		$this->features_controller         = $features_controller;
		$this->order_cache                 = $order_cache;
		$this->order_cache_controller      = $order_cache_controller;
		$this->plugin_util                 = $plugin_util;
	}

	/**
	 * Is the custom orders table usage enabled via settings?
	 * This can be true only if the feature is enabled and a table regeneration has been completed.
	 *
	 * @return bool True if the custom orders table usage is enabled
	 */
	public function custom_orders_table_usage_is_enabled(): bool {
		return get_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) === 'yes';
	}

	/**
	 * Gets the instance of the orders data store to use.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order_data_store hook).
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 */
	private function get_orders_data_store( $default_data_store ) {
		return $this->get_data_store_instance( $default_data_store, 'order' );
	}

	/**
	 * Gets the instance of the refunds data store to use.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the woocommerce_order-refund_data_store hook).
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 */
	private function get_refunds_data_store( $default_data_store ) {
		return $this->get_data_store_instance( $default_data_store, 'order_refund' );
	}

	/**
	 * Gets the instance of a given data store.
	 *
	 * @param \WC_Object_Data_Store_Interface|string $default_data_store The default data store (as received via the appropriate hooks).
	 * @param string                                 $type               The type of the data store to get.
	 *
	 * @return \WC_Object_Data_Store_Interface|string The actual data store to use.
	 */
	private function get_data_store_instance( $default_data_store, string $type ) {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			switch ( $type ) {
				case 'order_refund':
					return $this->refund_data_store;
				default:
					return $this->data_store;
			}
		} else {
			return $default_data_store;
		}
	}

	/**
	 * Add an entry to Status - Tools to create or regenerate the custom orders table,
	 * and also an entry to delete the table as appropriate.
	 *
	 * @param array $tools_array The array of tools to add the tool to.
	 * @return array The updated array of tools-
	 */
	private function add_hpos_tools( array $tools_array ): array {
		if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
			return $tools_array;
		}

		// Cleanup tool.
		$tools_array = array_merge( $tools_array, $this->data_cleanup->get_tools_entries() );

		// Delete HPOS tables tool.
		if ( $this->custom_orders_table_usage_is_enabled() || $this->data_synchronizer->data_sync_is_enabled() ) {
			$disabled = true;
			$message  = __( 'This will delete the custom orders tables. The tables can be deleted only if the "High-Performance order storage" is not authoritative and sync is disabled (via Settings > Advanced > Features).', 'woocommerce' );
		} else {
			$disabled = false;
			$message  = __( 'This will delete the custom orders tables. To create them again enable the "High-Performance order storage" feature (via Settings > Advanced > Features).', 'woocommerce' );
		}

		$tools_array['delete_custom_orders_table'] = array(
			'name'             => __( 'Delete the custom orders tables', 'woocommerce' ),
			'desc'             => sprintf(
				'<strong class="red">%1$s</strong> %2$s',
				__( 'Note:', 'woocommerce' ),
				$message
			),
			'requires_refresh' => true,
			'callback'         => function () {
				$this->features_controller->change_feature_enable( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, false );
				$this->delete_custom_orders_tables();
				return __( 'Custom orders tables have been deleted.', 'woocommerce' );
			},
			'button'           => __( 'Delete', 'woocommerce' ),
			'disabled'         => $disabled,
		);

		return $tools_array;
	}

	/**
	 * Delete the custom orders tables and any related options and data in response to the user pressing the tool button.
	 *
	 * @throws \Exception Can't delete the tables.
	 */
	private function delete_custom_orders_tables() {
		if ( $this->custom_orders_table_usage_is_enabled() ) {
			throw new \Exception( "Can't delete the custom orders tables: they are currently in use (via Settings > Advanced > Features)." );
		}

		delete_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION );
		$this->data_synchronizer->delete_database_tables();
	}

	/**
	 * Handler for the individual setting updated hook.
	 *
	 * @param string $option Setting name.
	 * @param mixed  $old_value Old value of the setting.
	 * @param mixed  $value New value of the setting.
	 */
	private function process_updated_option( $option, $old_value, $value ) {
		if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && 'no' === $value ) {
			$this->data_synchronizer->cleanup_synchronization_state();
		}
	}

	/**
	 * Handler for the setting pre-update hook.
	 * We use it to verify that authoritative orders table switch doesn't happen while sync is pending.
	 *
	 * @param mixed  $value New value of the setting.
	 * @param string $option Setting name.
	 * @param mixed  $old_value Old value of the setting.
	 *
	 * @throws \Exception Attempt to change the authoritative orders table while orders sync is pending.
	 */
	private function process_pre_update_option( $value, $option, $old_value ) {
		if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION === $option && $value !== $old_value ) {
			$this->order_cache->flush();
			return $value;
		}

		if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option ) {
			return $value;
		}

		$this->order_cache->flush();
		if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
			$this->data_synchronizer->create_database_tables();
		}

		$tables_created = get_option( DataSynchronizer::ORDERS_TABLE_CREATED ) === 'yes';
		if ( ! $tables_created ) {
			return 'no';
		}

		$sync_is_pending = 0 !== $this->data_synchronizer->get_current_orders_pending_sync_count();
		if ( $sync_is_pending && ! $this->changing_data_source_with_sync_pending_is_allowed() ) {
			throw new \Exception( "The authoritative table for orders storage can't be changed while there are orders out of sync" );
		}

		return $value;
	}

	/**
	 * Callback to trigger a sync immediately by clicking a button on the Features screen.
	 *
	 * @return void
	 */
	private function sync_now() {
		$section = filter_input( INPUT_GET, 'section' );
		if ( 'features' !== $section ) {
			return;
		}

		if ( ! filter_input( INPUT_GET, self::SYNC_QUERY_ARG, FILTER_VALIDATE_BOOLEAN ) ) {
			return;
		}

		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ?? '' ) ), 'hpos-sync-now' ) ) {
			WC_Admin_Settings::add_error( esc_html__( 'Unable to start synchronization. The link you followed may have expired.', 'woocommerce' ) );
			return;
		}

		$this->data_cleanup->toggle_flag( false );
		$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
	}

	/**
	 * Tell WP Admin to remove the sync query arg from the URL.
	 *
	 * @param array $query_args The query args that are removable.
	 *
	 * @return array
	 */
	private function register_removable_query_arg( $query_args ) {
		$query_args[] = self::SYNC_QUERY_ARG;

		return $query_args;
	}

	/**
	 * Handler for the woocommerce_after_register_post_type post,
	 * registers the post type for placeholder orders.
	 *
	 * @return void
	 */
	private function register_post_type_for_order_placeholders(): void {
		wc_register_order_type(
			DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE,
			array(
				'public'                           => false,
				'exclude_from_search'              => true,
				'publicly_queryable'               => false,
				'show_ui'                          => false,
				'show_in_menu'                     => false,
				'show_in_nav_menus'                => false,
				'show_in_admin_bar'                => false,
				'show_in_rest'                     => false,
				'rewrite'                          => false,
				'query_var'                        => false,
				'can_export'                       => false,
				'supports'                         => array(),
				'capabilities'                     => array(),
				'exclude_from_order_count'         => true,
				'exclude_from_order_views'         => true,
				'exclude_from_order_reports'       => true,
				'exclude_from_order_sales_reports' => true,
			)
		);
	}

	/**
	 * Add the definition for the HPOS feature.
	 *
	 * @param FeaturesController $features_controller The instance of FeaturesController.
	 *
	 * @return void
	 */
	private function add_feature_definition( $features_controller ) {
		$definition = array(
			'option_key'          => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
			'is_experimental'     => false,
			'enabled_by_default'  => false,
			'order'               => 50,
			'setting'             => $this->get_hpos_setting_for_feature(),
			'additional_settings' => array(
				$this->get_hpos_setting_for_sync(),
			),
		);

		$features_controller->add_feature_definition(
			'custom_order_tables',
			__( 'High-Performance order storage', 'woocommerce' ),
			$definition
		);
	}

	/**
	 * Returns the HPOS setting for rendering HPOS vs Post setting block in Features section of the settings page.
	 *
	 * @return array Feature setting object.
	 */
	private function get_hpos_setting_for_feature() {
		if ( 'yes' === get_transient( 'wc_installing' ) ) {
			return array();
		}

		$get_value = function() {
			return $this->custom_orders_table_usage_is_enabled() ? 'yes' : 'no';
		};

		/**
		 * ⚠️The FeaturesController instance must only be accessed from within the callback functions. Otherwise it
		 * gets called while it's still being instantiated and creates and endless loop.
		 */

		$get_desc = function() {
			$plugin_compatibility = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );

			return $this->plugin_util->generate_incompatible_plugin_feature_warning( 'custom_order_tables', $plugin_compatibility );
		};

		$get_disabled = function() {
			$plugin_compatibility = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
			$sync_complete        = 0 === $this->get_orders_pending_sync_count();
			$disabled             = array();
			// Changing something here? might also want to look at `enable|disable` functions in CLIRunner.
			if ( count( array_merge( $plugin_compatibility['uncertain'], $plugin_compatibility['incompatible'] ) ) > 0 ) {
				$disabled = array( 'yes' );
			}
			if ( ! $sync_complete && ! $this->changing_data_source_with_sync_pending_is_allowed() ) {
				$disabled = array( 'yes', 'no' );
			}

			return $disabled;
		};

		return array(
			'id'          => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
			'title'       => __( 'Order data storage', 'woocommerce' ),
			'type'        => 'radio',
			'options'     => array(
				'no'  => __( 'WordPress posts storage (legacy)', 'woocommerce' ),
				'yes' => __( 'High-performance order storage (recommended)', 'woocommerce' ),
			),
			'value'       => $get_value,
			'disabled'    => $get_disabled,
			'desc'        => $get_desc,
			'desc_at_end' => true,
			'row_class'   => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
		);
	}

	/**
	 * Returns the setting for rendering sync enabling setting block in Features section of the settings page.
	 *
	 * @return array Feature setting object.
	 */
	private function get_hpos_setting_for_sync() {
		if ( 'yes' === get_transient( 'wc_installing' ) ) {
			return array();
		}

		$get_value = function() {
			return get_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
		};

		$get_sync_message = function() {
			$orders_pending_sync_count = $this->get_orders_pending_sync_count();
			$sync_in_progress          = $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) );
			$sync_enabled              = $this->data_synchronizer->data_sync_is_enabled();
			$sync_is_pending           = $orders_pending_sync_count > 0;
			$sync_message              = array();

			$is_dangerous = $sync_is_pending && $this->changing_data_source_with_sync_pending_is_allowed();

			if ( $is_dangerous ) {
				$sync_message[] = wp_kses_data(
					sprintf(
					// translators: %d: number of pending orders.
						_n(
							"There's %d order pending sync. <b>Switching data storage while sync is incomplete is dangerous and can lead to order data corruption or loss!</b>",
							'There are %d orders pending sync. <b>Switching data storage while sync is incomplete is dangerous and can lead to order data corruption or loss!</b>',
							$orders_pending_sync_count,
							'woocommerce'
						),
						$orders_pending_sync_count,
					)
				);
			}

			if ( ! $sync_enabled && $this->data_synchronizer->background_sync_is_enabled() ) {
				$sync_message[] = __( 'Background sync is enabled.', 'woocommerce' );
			}

			if ( $sync_in_progress && $sync_is_pending ) {
				$sync_message[] = sprintf(
					// translators: %d: number of pending orders.
					__( 'Currently syncing orders... %d pending', 'woocommerce' ),
					$orders_pending_sync_count
				);
			} elseif ( $sync_is_pending ) {
				$sync_now_url = wp_nonce_url(
					add_query_arg(
						array(
							self::SYNC_QUERY_ARG => true,
						),
						wc_get_container()->get( FeaturesController::class )->get_features_page_url()
					),
					'hpos-sync-now'
				);

				if ( ! $is_dangerous ) {
					$sync_message[] = wp_kses_data(
						sprintf(
						// translators: %d: number of pending orders.
							_n(
								"There's %d order pending sync. You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.",
								'There are %d orders pending sync. You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.',
								$orders_pending_sync_count,
								'woocommerce'
							),
							$orders_pending_sync_count
						)
					);
				}

				$sync_message[] = sprintf(
					'<a href="%1$s" class="button button-link">%2$s</a>',
					esc_url( $sync_now_url ),
					sprintf(
						// translators: %d: number of pending orders.
						_n(
							'Sync %s pending order',
							'Sync %s pending orders',
							$orders_pending_sync_count,
							'woocommerce'
						),
						number_format_i18n( $orders_pending_sync_count )
					)
				);
			}

			return implode( '<br />', $sync_message );
		};

		$get_description_is_error = function() {
			$sync_is_pending = $this->get_orders_pending_sync_count() > 0;

			return $sync_is_pending && $this->changing_data_source_with_sync_pending_is_allowed();
		};

		return array(
			'id'                   => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
			'title'                => '',
			'type'                 => 'checkbox',
			'desc'                 => __( 'Enable compatibility mode (synchronizes orders to the posts table).', 'woocommerce' ),
			'value'                => $get_value,
			'desc_tip'             => $get_sync_message,
			'description_is_error' => $get_description_is_error,
			'row_class'            => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
		);
	}

	/**
	 * Returns a value indicating if changing the authoritative data source for orders while there are orders pending synchronization is allowed.
	 *
	 * @return bool
	 */
	private function changing_data_source_with_sync_pending_is_allowed(): bool {
		/**
		 * Filter to allow changing where order data is stored, even when there are orders pending synchronization.
		 *
		 * DANGER! This filter is intended for usage when doing manual and automated testing in development environments only,
		 * it should NEVER be used in production environments. Order data corruption or loss can happen!
		 *
		 * @param bool $allow True to allow changing order storage when there are orders pending synchronization, false to disallow.
		 * @returns bool
		 *
		 * @since 8.3.0
		 */
		return apply_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending', false );
	}

	/**
	 * Returns the count of orders pending synchronization.
	 *
	 * @return int
	 */
	private function get_orders_pending_sync_count(): int {
		return $this->data_synchronizer->get_sync_status()['current_pending_count'];
	}
}