Automattic\WooCommerce\Internal\Admin

OrderTaxLookupMigrator{} │ WC 11.2.0└─ BatchProcessorInterface, RegisterHooksInterface

Rebuilds the wc_order_tax_lookup rows of orders recorded before the table held one row per tax order item, by re-syncing each order through the Taxes data store.

Rows written before then carry the zero default of the order_item_id column, and the Taxes report keeps matching those on their tax rate id alone, the way it did before the column existed. So reporting stays as it was while this runs, and an order the processor cannot rebuild keeps reporting the way it did.

Additionally, this class manages the "Rebuild analytics tax data" tool.

No Hooks.

Usage

$OrderTaxLookupMigrator = new OrderTaxLookupMigrator();
// use class methods

Methods

  1. public dequeue()
  2. public enqueue()
  3. public get_default_batch_size()
  4. public get_description()
  5. public get_name()
  6. public get_next_batch_to_process( int $size )
  7. public get_total_pending_count()
  8. public handle_woocommerce_debug_tools( array $tools )
  9. public process_batch( array $batch )
  10. public register()
  11. private get_cursor()
  12. private order_has_stats_row( int $order_id )

Changelog

Since 11.2.0 Introduced.

OrderTaxLookupMigrator{} code WC 11.2.1

class OrderTaxLookupMigrator implements BatchProcessorInterface, RegisterHooksInterface {

	/**
	 * Option holding the highest order id the processor has been through.
	 *
	 * The cursor is what bounds progress, so it outlives the run. An order the processor could not
	 * rebuild keeps its rows at zero; without the cursor every later batch would pick that order up
	 * again and the processor would never reach the end of the table. Such an order is recorded as
	 * a failed analytics import instead, which is retried from Analytics settings. That is also why
	 * the option is left behind once the pass is done: clearing it would put those orders back in
	 * front of the next pass. Delete it by hand to run the rebuild over the whole table again.
	 *
	 * @var string
	 */
	const CURSOR_OPTION = 'woocommerce_order_tax_lookup_migration_last_order_id';

	/**
	 * How far `get_total_pending_count()` counts before it reports "this many or more".
	 *
	 * Nothing indexes the tax order item column, so counting every order left to rebuild reads the
	 * lookup table end to end, and Status > Tools runs that count on every render. The tool only
	 * has to say whether there is work left and roughly how much of it, so stop counting once
	 * there is enough to report.
	 *
	 * @var int
	 */
	const PENDING_COUNT_LIMIT = 1000;

	/**
	 * Register this class instance to the appropriate hooks.
	 *
	 * @return void
	 */
	public function register() {
		add_filter( 'woocommerce_debug_tools', array( $this, 'handle_woocommerce_debug_tools' ), 999, 1 );
	}

	/**
	 * Get a user-friendly name for this processor.
	 *
	 * @return string Name of the processor.
	 */
	public function get_name(): string {
		return 'Order tax lookup tax order item migrator';
	}

	/**
	 * Get a user-friendly description for this processor.
	 *
	 * @return string Description of what this processor does.
	 */
	public function get_description(): string {
		return 'Rebuilds wc_order_tax_lookup rows recorded before the table held one row per tax order item, so that Analytics tax reports account for every tax line an order carries.';
	}

	/**
	 * Get the number of orders left to go through that still hold rows in the shape that predates
	 * the tax order item column, up to PENDING_COUNT_LIMIT.
	 *
	 * Counts from the cursor, the same place `get_next_batch_to_process()` reads from, so the
	 * number the tool shows is the number the rebuild will actually get through. Counting the whole
	 * table instead would leave the tool offering a run over orders every pass steps past.
	 *
	 * @return int Number of orders pending processing, at most PENDING_COUNT_LIMIT.
	 */
	public function get_total_pending_count(): int {
		global $wpdb;

		// While the lookup is not keyed by tax order item there is nothing the rebuild can change,
		// so no order counts as pending. See get_next_batch_to_process().
		if ( ! TaxesDataStore::lookup_is_keyed_by_order_item() ) {
			return 0;
		}

		$table_name = TaxesDataStore::get_db_table_name();

		return (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is not user input.
				"SELECT COUNT(*) FROM ( SELECT DISTINCT order_id FROM {$table_name} WHERE order_id > %d AND order_item_id = 0 LIMIT %d ) AS pending",
				$this->get_cursor(),
				self::PENDING_COUNT_LIMIT
			)
		);
	}

	/**
	 * Returns the ids of the next orders to rebuild.
	 *
	 * @param int $size Maximum size of the batch to be returned.
	 *
	 * @throws Exception On a database error, so that an empty batch is never mistaken for the end
	 *                   of the table.
	 *
	 * @return array Batch of order ids, containing $size or less items.
	 */
	public function get_next_batch_to_process( int $size ): array {
		global $wpdb;

		// On a table the re-key in `WC_Install::create_tables()` never reached, the sync would
		// write every row back at zero and the pass would park the cursor at the end of the table
		// with nothing rebuilt. Hand out nothing instead: an empty batch retires the processor
		// with the cursor where it stands, so the rebuild is still on offer once the re-key has
		// landed.
		if ( ! TaxesDataStore::lookup_is_keyed_by_order_item() ) {
			return array();
		}

		$table_name = TaxesDataStore::get_db_table_name();

		$order_ids = $wpdb->get_col(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is not user input.
				"SELECT DISTINCT order_id FROM {$table_name} WHERE order_id > %d AND order_item_id = 0 ORDER BY order_id ASC LIMIT %d",
				$this->get_cursor(),
				$size
			)
		);

		if ( $wpdb->last_error ) {
			// An empty batch reads as "nothing left to do" and retires the processor, which would
			// leave the rest of the table behind. Report the failure instead, which fails the
			// scheduled action and leaves the controller's watchdog to schedule another attempt.
			// The controller only counts failures its process_batch() call throws, so a database
			// that stays broken is retried rather than retired.
			throw new Exception( $wpdb->last_error ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}

		return array_map( 'absint', $order_ids );
	}

	/**
	 * Re-sync the orders in the batch, which writes one row per tax order item and, once they are
	 * all written, drops whatever the order no longer carries.
	 *
	 * @param array $batch Batch of order ids, as returned by 'get_next_batch_to_process'.
	 *
	 * @return void
	 */
	public function process_batch( array $batch ): void {
		global $wpdb;

		if ( empty( $batch ) ) {
			return;
		}

		foreach ( $batch as $order_id ) {
			$order_id = (int) $order_id;
			$synced   = TaxesDataStore::sync_order_taxes( $order_id );

			// The reports only see lookup rows they can join to a `wc_order_stats` row, so an order
			// without one is gone as far as Analytics is concerned and its rows can go. A failed
			// `wc_get_order()` is not the same thing: it also fails while the plugin that registers
			// the order's type is deactivated, and that order still has its stats row.
			if ( -1 === $synced && ! $this->order_has_stats_row( $order_id ) ) {
				$wpdb->delete( TaxesDataStore::get_db_table_name(), array( 'order_id' => $order_id ), array( '%d' ) );
				continue;
			}

			// A write that did not land leaves the order holding the rows it came in with, which
			// report the way they did before. The cursor steps past it either way, so record it as
			// a failed analytics import: that is the list Analytics settings offers a retry over,
			// and the retry re-imports the order, which is the same work this pass could not do.
			if ( false === $synced ) {
				wc_get_logger()->error(
					"Could not rebuild the analytics tax lookup rows of order {$order_id}. The order keeps the rows it had and reports the way it did before. It is recorded as a failed analytics import, so it can be retried from Analytics settings.",
					array( 'source' => 'wc-order-tax-lookup-migration' )
				);

				OrdersScheduler::record_failed_order_import( $order_id );
			}
		}

		// Step past every order in the batch, including any that could not be rebuilt, which are
		// left to the failed import retry. See CURSOR_OPTION.
		update_option( self::CURSOR_OPTION, max( array_map( 'absint', $batch ) ), false );

		ReportsCache::invalidate();
	}

	/**
	 * Default (preferred) batch size to pass to 'get_next_batch_to_process'.
	 *
	 * A batch is a `wc_get_order()` and a handful of writes per order, so it is sized like the
	 * analytics order importer rather than like a single-query migration.
	 *
	 * @return int Default batch size.
	 */
	public function get_default_batch_size(): int {
		return 100;
	}

	/**
	 * Add the tool to start or stop the background rebuild.
	 *
	 * @param array $tools Old tools array.
	 * @return array Updated tools array.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function handle_woocommerce_debug_tools( array $tools ): array {
		// A failed re-key would otherwise go unseen here: with no order counting as pending, the
		// tool would say there is nothing to rebuild. Say what is actually missing instead.
		if ( ! TaxesDataStore::lookup_is_keyed_by_order_item() ) {
			$tools['rebuild_analytics_tax_data'] = array(
				'name'     => __( 'Rebuild analytics tax data', 'woocommerce' ),
				'button'   => __( 'Rebuild', 'woocommerce' ),
				'disabled' => true,
				'desc'     => __( 'This will rebuild the Analytics tax data of orders recorded before WooCommerce kept a record of every tax line. The database change the rebuild needs is missing on this store. Run "Verify base database tables" to apply it, then come back here.', 'woocommerce' ),
			);

			return $tools;
		}

		$batch_processor = wc_get_container()->get( BatchProcessingController::class );
		$pending_count   = $this->get_total_pending_count();

		// The count stops at PENDING_COUNT_LIMIT, so say "or more" rather than a number the store
		// has already gone past.
		$pending_label = $pending_count < self::PENDING_COUNT_LIMIT
			? number_format_i18n( $pending_count )
			/* translators: %s: number of orders, where there are at least that many. */
			: sprintf( __( '%s+', 'woocommerce' ), number_format_i18n( self::PENDING_COUNT_LIMIT ) );

		if ( 0 === $pending_count ) {
			$tools['rebuild_analytics_tax_data'] = array(
				'name'     => __( 'Rebuild analytics tax data', 'woocommerce' ),
				'button'   => __( 'Rebuild', 'woocommerce' ),
				'disabled' => true,
				'desc'     => __( 'This will rebuild the Analytics tax data of orders recorded before WooCommerce kept a record of every tax line. There are currently no orders to rebuild.', 'woocommerce' ),
			);
		} elseif ( $batch_processor->is_enqueued( self::class ) ) {
			$tools['stop_rebuild_analytics_tax_data'] = array(
				'name'             => __( 'Stop rebuilding analytics tax data', 'woocommerce' ),
				'button'           => __( 'Stop rebuilding', 'woocommerce' ),
				'requires_refresh' => true,
				'desc'             => sprintf(
					/* translators: %s: number of orders still to rebuild. */
					_n(
						'This will stop the background process that rebuilds the Analytics tax data of orders recorded before WooCommerce kept a record of every tax line. There is currently %s order left to rebuild.',
						'This will stop the background process that rebuilds the Analytics tax data of orders recorded before WooCommerce kept a record of every tax line. There are currently %s orders left to rebuild.',
						$pending_count,
						'woocommerce'
					),
					$pending_label
				),
				'callback'         => array( $this, 'dequeue' ),
			);
		} else {
			$tools['rebuild_analytics_tax_data'] = array(
				'name'             => __( 'Rebuild analytics tax data', 'woocommerce' ),
				'button'           => __( 'Rebuild', 'woocommerce' ),
				'requires_refresh' => true,
				'desc'             => sprintf(
					/* translators: %s: number of orders to rebuild. */
					_n(
						'This will rebuild the Analytics tax data of orders recorded before WooCommerce kept a record of every tax line. The rebuild happens over time in the background (via Action Scheduler). There is currently %s order to rebuild.',
						'This will rebuild the Analytics tax data of orders recorded before WooCommerce kept a record of every tax line. The rebuild happens over time in the background (via Action Scheduler). There are currently %s orders to rebuild.',
						$pending_count,
						'woocommerce'
					),
					$pending_label
				),
				'callback'         => array( $this, 'enqueue' ),
			);
		}

		return $tools;
	}

	/**
	 * Start the background rebuild.
	 *
	 * @return string Informative string to show after the tool is triggered in UI.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function enqueue(): string {
		$batch_processor = wc_get_container()->get( BatchProcessingController::class );

		if ( $batch_processor->is_enqueued( self::class ) ) {
			return __( 'Background process for rebuilding analytics tax data already started, nothing done.', 'woocommerce' );
		}

		$batch_processor->enqueue_processor( self::class );

		return __( 'Background process for rebuilding analytics tax data started.', 'woocommerce' );
	}

	/**
	 * Stop the background rebuild.
	 *
	 * @return string Informative string to show after the tool is triggered in UI.
	 *
	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
	 */
	public function dequeue(): string {
		$batch_processor = wc_get_container()->get( BatchProcessingController::class );

		if ( ! $batch_processor->is_enqueued( self::class ) ) {
			return __( 'Background process for rebuilding analytics tax data not started, nothing done.', 'woocommerce' );
		}

		$batch_processor->remove_processor( self::class );

		return __( 'Background process for rebuilding analytics tax data stopped.', 'woocommerce' );
	}

	/**
	 * Whether the order has a row in the order stats table, which is the table every analytics
	 * report reads orders through.
	 *
	 * @param int $order_id Order id.
	 * @return bool
	 */
	private function order_has_stats_row( int $order_id ): bool {
		global $wpdb;

		$table_name = OrderStatsDataStore::get_db_table_name();

		return (bool) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is not user input.
				"SELECT 1 FROM {$table_name} WHERE order_id = %d",
				$order_id
			)
		);
	}

	/**
	 * Highest order id the processor has been through.
	 *
	 * @return int
	 */
	private function get_cursor(): int {
		return (int) get_option( self::CURSOR_OPTION, 0 );
	}
}