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
Методы
- public init( ProductsOrderingReindexService $reindex_service )
- public move( int $previous_id, int $product_id, int $next_id )
- private apply( object $map, array $reindexed )
- private compose_anchor_positions( int $previous_id, int $product_id, int $next_id )
- private compose_move_map( int $previous_id, int $product_id, int $next_id, array $anchor_positions )
- private compose_range_ids( object $map, array $reindexed )
- private has_moved( array $anchor_positions, int $previous_id, int $product_id, int $next_id )
Код ProductsOrderingMoveService{} 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 );
}
}