Automattic\WooCommerce\Internal\Products

ProductsOrderingMoveService{} │ final │ WC 1.0

Repositions a single product within the catalog's menu_order sequence.

Хуков нет.

Использование

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

Методы

  1. public init( ProductsOrderingReindexService $reindex_service )
  2. public move( int $previous_id, int $product_id, int $next_id )
  3. private apply( object $map, array $reindexed )
  4. private compose_anchor_positions( int $previous_id, int $product_id, int $next_id )
  5. private compose_move_map( int $previous_id, int $product_id, int $next_id, array $anchor_positions )
  6. private compose_range_ids( object $map, array $reindexed )
  7. private has_moved( array $anchor_positions, int $previous_id, int $product_id, int $next_id )

Код ProductsOrderingMoveService{} WC 11.2.2

final class ProductsOrderingMoveService {

	/**
	 * Reindex service.
	 *
	 * @var ProductsOrderingReindexService
	 */
	private ProductsOrderingReindexService $reindex_service;

	/**
	 * Initialize the service with dependencies.
	 *
	 * @internal
	 * @param ProductsOrderingReindexService $reindex_service Reindex service.
	 */
	final public function init( ProductsOrderingReindexService $reindex_service ): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found
		$this->reindex_service = $reindex_service;
	}

	/**
	 * Moves a product to the position between $previous_id and $next_id, triggering a full reindex first if positions are
	 * uninitialized or colliding. Indexed positions start at 1; menu_order = 0 is the sentinel for an unindexed product.
	 *
	 * Designed for an HVM with 500K+ products catalog operating in a clustered environment. To satisfy this setup:
	 * - if on-the-spot re-indexing is triggered: see design notes in \Automattic\WooCommerce\Internal\Products\ProductsOrderingReindexService::reindex_products
	 * - otherwise, the move takes 5 SQLs for any catalog size (4 of them PK-driven, hence nearly instant; operating on the targeted range of positions)
	 *
	 * @since 11.2.0
	 *
	 * @param int $previous_id ID of the product immediately before the target position, or 0 when moving to the start.
	 * @param int $product_id  ID of the product being repositioned.
	 * @param int $next_id     ID of the product immediately after the target position, or 0 when moving to the end.
	 * @return object{ moved:array<int,int>, reindexed:array<int,int> }
	 */
	public function move( int $previous_id, int $product_id, int $next_id ): object {
		$result = array(
			'moved'     => array(),
			'reindexed' => array(),
		);

		$anchor_positions = $this->compose_anchor_positions( $previous_id, $product_id, $next_id );
		if ( ! $this->has_moved( $anchor_positions, $previous_id, $product_id, $next_id ) ) {
			return (object) $result;
		}

		// Re-indexing is required when: a position collision is detected or moving between groups one of which is unindexed.
		$map              = $this->compose_move_map( $previous_id, $product_id, $next_id, $anchor_positions );
		$needs_reindexing = $map->old_position === $map->new_position || 0 === $map->new_position || 0 === $map->old_position;
		$needs_reindexing = $needs_reindexing || count( array_unique( $anchor_positions ) ) !== count( $anchor_positions );
		if ( $needs_reindexing ) {
			$result['reindexed'] = $this->reindex_service->reindex_products();

			$anchor_positions = $this->compose_anchor_positions( $previous_id, $product_id, $next_id );
			$map              = $this->compose_move_map( $previous_id, $product_id, $next_id, $anchor_positions );
			if ( ! $this->has_moved( $anchor_positions, $previous_id, $product_id, $next_id ) ) {
				return (object) $result;
			}
		}

		$result['moved'] = $this->apply( $map, $result['reindexed'] );
		// Deduct the move related modification from re-indexing to de-duplicate change events in outside workflow.
		foreach ( $result['moved'] as $id => $position ) {
			unset( $result['reindexed'][ $id ] );
		}

		return (object) $result;
	}

	/**
	 * Applies the move to the database and returns updated positions of all affected products.
	 *
	 * @phpstan-param object{ product_id:int, old_position:int, new_position:int, range_from:int, range_to:int, range_delta:int } $map Map with the move route.
	 * @param object         $map       Map with the move route.
	 * @param array<int,int> $reindexed Reindexed positions map.
	 * @return array<int,int>
	 */
	private function apply( object $map, array $reindexed ): array {
		global $wpdb;

		$range_ids          = array_merge( array( $map->product_id ), $this->compose_range_ids( $map, $reindexed ) );
		$range_placeholders = implode( ', ', array_fill( 0, count( $range_ids ), '%d' ) );

		// Shift the affected range (including the moved product) then pin it to the exact target position; update by PK is nearly instant.
		$updated_count  = 0;
		$updated_count += (int) $wpdb->query(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
			$wpdb->prepare(
				"UPDATE {$wpdb->posts} SET menu_order = menu_order + %d WHERE ID IN ( {$range_placeholders} )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$map->range_delta,
				...$range_ids
			)
		);
		$updated_count += (int) $wpdb->update( $wpdb->posts, array( 'menu_order' => $map->new_position ), array( 'ID' => $map->product_id ) );
		if ( $updated_count > 0 ) {
			// Performance note: fires `clean_post_cache` action per product for extensions compatibility (WooCommerce v11.2).
			// Targeted wp_cache_delete_multiple + wp_cache_set_posts_last_changed is fast, but insufficient for extensibility surface.
			array_walk( $range_ids, 'clean_post_cache' );
		}

		// Fetch updated positions for cache invalidation, hooks, and response; fetch by PK is nearly instant.
		$updated_positions = array_column(
			$wpdb->get_results(
				$wpdb->prepare(
					"SELECT ID, menu_order FROM {$wpdb->posts} WHERE ID IN ( {$range_placeholders} ) ORDER BY menu_order ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
					$range_ids
				)
			),
			'menu_order',
			'ID'
		);

		return array_map( 'intval', $updated_positions );
	}

	/**
	 * Determines whether the product's position needs updating.
	 *
	 * @param array<int,int> $anchor_positions Map of product ID → current menu_order, ordered by menu_order ASC.
	 * @param int            $previous_id      ID of the product before the target position, or 0 for start.
	 * @param int            $product_id       ID of the product being repositioned.
	 * @param int            $next_id          ID of the product after the target position, or 0 for end.
	 *
	 * @return bool
	 */
	private function has_moved( array $anchor_positions, int $previous_id, int $product_id, int $next_id ): bool {
		// Compare DB ordering (array keys sorted by menu_order) against the requested ordering (filtering out 0 pseudo-IDs).
		$has_moved = array_keys( $anchor_positions ) !== array_values( array_filter( array( $previous_id, $product_id, $next_id ) ) );
		if ( $has_moved ) {
			return true;
		}

		// Unindexed (position 0) or colliding (duplicate positions) anchors always need work.
		$needs_reindexing = in_array( 0, $anchor_positions, true ) || count( array_unique( $anchor_positions ) ) !== count( $anchor_positions );
		if ( $needs_reindexing ) {
			return true;
		}

		return false;
	}

	/**
	 * Computes the target position and the range of products that must shift to accommodate the move.
	 *
	 * @param int            $previous_id      ID of the product immediately before the target position, or 0 when moving to the start.
	 * @param int            $product_id       ID of the product being repositioned.
	 * @param int            $next_id          ID of the product immediately after the target position, or 0 when moving to the end.
	 * @param array<int,int> $anchor_positions Map of product ID → current menu_order for the three anchor products.
	 *
	 * @return object{ product_id:int, old_position:int, new_position:int, range_from:int, range_to:int, range_delta:int }
	 */
	private function compose_move_map( int $previous_id, int $product_id, int $next_id, array $anchor_positions ): object {
		$previous_position = (int) ( $anchor_positions[ $previous_id ] ?? 0 );
		$product_position  = (int) ( $anchor_positions[ $product_id ] ?? 0 );
		$next_position     = (int) ( $anchor_positions[ $next_id ] ?? 0 );

		if ( $previous_position > $product_position ) {
			// Moving forward: products between current and new position shift down.
			$range_from   = $product_position + 1;
			$range_to     = $previous_position;
			$range_delta  = -1;
			$new_position = $previous_position;
		} else {
			// Moving backward: products between new and current position shift up.
			$range_from   = $next_position;
			$range_to     = $product_position - 1;
			$range_delta  = +1;
			$new_position = $next_position;
		}

		return (object) array(
			'product_id'   => $product_id,
			'old_position' => $product_position,
			'new_position' => $new_position,
			'range_from'   => $range_from,
			'range_to'     => $range_to,
			'range_delta'  => $range_delta,
		);
	}

	/**
	 * Fetches the current menu_order for the anchor products.
	 *
	 * @param int $previous_id ID of the product immediately before the target position, or 0 when moving to the start.
	 * @param int $product_id  ID of the product being repositioned.
	 * @param int $next_id     ID of the product immediately after the target position, or 0 when moving to the end.
	 * @return array<int,int>
	 */
	private function compose_anchor_positions( int $previous_id, int $product_id, int $next_id ): array {
		global $wpdb;

		return array_column(
			$wpdb->get_results(
				$wpdb->prepare(
					"SELECT ID, menu_order FROM {$wpdb->posts} WHERE ID IN (%d, %d, %d) ORDER BY menu_order ASC",
					$previous_id,
					$product_id,
					$next_id
				)
			),
			'menu_order',
			'ID'
		);
	}

	/**
	 * Returns IDs of products whose position falls within the move range.
	 *
	 * @phpstan-param object{ range_from:int, range_to:int } $map Map with the move route.
	 * @param object         $map       Map with the move route.
	 * @param array<int,int> $reindexed Reindexed positions map, keyed by product ID.
	 * @return int[]
	 */
	private function compose_range_ids( object $map, array $reindexed ): array {
		global $wpdb;

		// Performance note: when a prior reindex is available, derive range IDs from it — avoids a DB round-trip.
		$expected_count = $map->range_to - $map->range_from + 1;
		$range_ids      = array_keys( array_filter( $reindexed, static fn( $position ) => $position >= $map->range_from && $position <= $map->range_to ) );
		if ( count( $range_ids ) !== $expected_count ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$range_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' AND menu_order BETWEEN %d AND %d",
					$map->range_from,
					$map->range_to
				)
			);
		}

		return array_map( 'intval', $range_ids );
	}
}