Commit 993300b0c54 for woocommerce
commit 993300b0c54846c0b65c9e2524b8469c904ef4e9
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date: Tue Aug 18 11:21:38 2026 +0200
[Performance] Fix ordering products performance (N-query pattern) (take 2) (#66603)
Re-implements product position re-indexing and changing product positions:
- Batched re-indexing: compatible with HyperDB and supported DB versions
- Move implementation operates on the targeted range of positions (a constant 5 SQLs to move a product across a catalog of any size; 4 SQLs are PK-driven and nearly instant)
diff --git a/plugins/woocommerce/changelog/performance-products-reordering-ajax b/plugins/woocommerce/changelog/performance-products-reordering-ajax
new file mode 100644
index 00000000000..3ae1fecaec7
--- /dev/null
+++ b/plugins/woocommerce/changelog/performance-products-reordering-ajax
@@ -0,0 +1,4 @@
+Significance: minor
+Type: performance
+
+Fixed ordering products performance (N-query pattern).
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 6c1aba7e10c..ca96f1f5514 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -16,6 +16,7 @@ use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomMetaBox;
+use Automattic\WooCommerce\Internal\Products\ProductsOrderingMoveService;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -2337,8 +2338,6 @@ class WC_AJAX {
/**
* Ajax request handling for product ordering.
*
- * Based on Simple Page Ordering by 10up (https://wordpress.org/plugins/simple-page-ordering/).
- *
* @return void
*/
public static function product_ordering() {
@@ -2350,54 +2349,78 @@ class WC_AJAX {
wp_die( -1 );
}
- $sorting_id = absint( $_POST['id'] );
- $previd = absint( isset( $_POST['previd'] ) ? $_POST['previd'] : 0 );
- $nextid = absint( isset( $_POST['nextid'] ) ? $_POST['nextid'] : 0 );
- $menu_orders = wp_list_pluck( $wpdb->get_results( "SELECT ID, menu_order FROM {$wpdb->posts} WHERE post_type = 'product' ORDER BY menu_order ASC, post_title ASC" ), 'menu_order', 'ID' );
- $index = 0;
+ $previous_id = absint( $_POST['previd'] ?? 0 );
+ $product_id = absint( $_POST['id'] );
+ $next_id = absint( $_POST['nextid'] ?? 0 );
- foreach ( $menu_orders as $id => $menu_order ) {
- $id = absint( $id );
+ $use_legacy_algorithm = has_action( 'woocommerce_after_single_product_ordering' ) || has_action( 'woocommerce_after_product_ordering' );
+ if ( $use_legacy_algorithm ) {
+ // Based on Simple Page Ordering by 10up (https://wordpress.org/plugins/simple-page-ordering/).
+ $menu_orders = wp_list_pluck( $wpdb->get_results( "SELECT ID, menu_order FROM {$wpdb->posts} WHERE post_type = 'product' ORDER BY menu_order ASC, post_title ASC" ), 'menu_order', 'ID' );
+ $index = 0;
- if ( $sorting_id === $id ) {
- continue;
- }
- if ( $nextid === $id ) {
+ foreach ( $menu_orders as $id => $menu_order ) {
+ $id = absint( $id );
+
+ if ( $product_id === $id ) {
+ continue;
+ }
+ if ( $next_id === $id ) {
+ ++$index;
+ }
++$index;
+ $menu_orders[ $id ] = $index;
+
+ if ( $wpdb->update( $wpdb->posts, array( 'menu_order' => $index ), array( 'ID' => $id ) ) ) {
+ // We only need to clean the cache if the menu order was actually modified.
+ clean_post_cache( $id );
+ }
+
+ /**
+ * When a single product has gotten its ordering updated.
+ *
+ * @param int $id The product ID.
+ * @param int $index The new sort position.
+ *
+ * @since 3.1.0
+ */
+ do_action( 'woocommerce_after_single_product_ordering', $id, $index );
}
- ++$index;
- $menu_orders[ $id ] = $index;
- if ( $wpdb->update( $wpdb->posts, array( 'menu_order' => $index ), array( 'ID' => $id ) ) ) {
+ if ( isset( $menu_orders[ $previous_id ] ) ) {
+ $menu_orders[ $product_id ] = $menu_orders[ $previous_id ] + 1;
+ } elseif ( isset( $menu_orders[ $next_id ] ) ) {
+ $menu_orders[ $product_id ] = $menu_orders[ $next_id ] - 1;
+ } else {
+ $menu_orders[ $product_id ] = 0;
+ }
+
+ if ( $wpdb->update( $wpdb->posts, array( 'menu_order' => $menu_orders[ $product_id ] ), array( 'ID' => $product_id ) ) ) {
// We only need to clean the cache if the menu order was actually modified.
- clean_post_cache( $id );
+ clean_post_cache( $product_id );
}
+ WC_Post_Data::delete_product_query_transients();
+
/**
- * When a single product has gotten it's ordering updated.
- * $id The product ID
- * $index The new menu order
- */
- do_action( 'woocommerce_after_single_product_ordering', $id, $index );
- }
+ * When products ordering update completed.
+ *
+ * @param int $product_id The product ID that was repositioned.
+ * @param array<int,int> $all_positions All product sort positions (product ID → actual menu_order value).
+ *
+ * @since 3.1.0
+ */
+ do_action( 'woocommerce_after_product_ordering', $product_id, $menu_orders );
+ wp_send_json( $menu_orders );
- if ( isset( $menu_orders[ $previd ] ) ) {
- $menu_orders[ $sorting_id ] = $menu_orders[ $previd ] + 1;
- } elseif ( isset( $menu_orders[ $nextid ] ) ) {
- $menu_orders[ $sorting_id ] = $menu_orders[ $nextid ] - 1;
} else {
- $menu_orders[ $sorting_id ] = 0;
- }
-
- if ( $wpdb->update( $wpdb->posts, array( 'menu_order' => $menu_orders[ $sorting_id ] ), array( 'ID' => $sorting_id ) ) ) {
- // We only need to clean the cache if the menu order was actually modified.
- clean_post_cache( $sorting_id );
+ $modifications = wc_get_container()->get( ProductsOrderingMoveService::class )->move( $previous_id, $product_id, $next_id );
+ if ( ! empty( $modifications->moved ) || ! empty( $modifications->reindexed ) ) {
+ WC_Post_Data::delete_product_query_transients();
+ unset( $modifications->reindexed );
+ }
+ wp_send_json( $modifications->moved );
}
-
- WC_Post_Data::delete_product_query_transients();
-
- do_action( 'woocommerce_after_product_ordering', $sorting_id, $menu_orders );
- wp_send_json( $menu_orders );
}
/**
diff --git a/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php b/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php
new file mode 100644
index 00000000000..20831d97a2b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Products/ProductsOrderingMoveService.php
@@ -0,0 +1,254 @@
+<?php declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Products;
+
+/**
+ * Repositions a single product within the catalog's menu_order sequence.
+ */
+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 ) {
+ /**
+ * Whether to fire the clean_post_cache action per product after reordering or apply targeted cache invalidation.
+ * Default strategy is clean_post_cache is suboptimal, but applied for backward compatibility reasons.
+ *
+ * @since 11.2.0
+ *
+ * @param bool $clean_post_cache Whether to fire clean_post_cache per product.
+ * @returns bool
+ */
+ $clean_post_cache = (bool) apply_filters( 'woocommerce_single_product_ordering_clean_post_cache', true );
+ if ( $clean_post_cache ) {
+ // Performance note: fires clean_post_cache action per product for cache plugins compatibility (WooCommerce v11.2).
+ array_walk( $range_ids, 'clean_post_cache' );
+ } else {
+ // Performance note: clear only the posts cache — menu_order lives in wp_posts, not in meta or term caches.
+ wp_cache_delete_multiple( $range_ids, 'posts' );
+ wp_cache_set_posts_last_changed();
+ }
+ }
+
+ // 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 );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php b/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php
new file mode 100644
index 00000000000..cdbbedf2e24
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Products/ProductsOrderingReindexService.php
@@ -0,0 +1,71 @@
+<?php declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Products;
+
+/**
+ * Assigns sequential menu_order values to all products, enabling deterministic drag-and-drop ordering.
+ */
+final class ProductsOrderingReindexService {
+ /**
+ * Designed for an HVM with 500K+ products catalog operating in a clustered environment. To satisfy this setup:
+ * - The batch size is set to 250. Increasing this value may negatively impact catalog browsing performance.
+ * - Cache invalidation targets the posts cache only, since menu_order lives in wp_posts and is not stored in meta or term caches.
+ * - Resources allocation for 500K products catalog: product-position map - 20 MB RAM, 2000 SQLs for full reindexing ± 2 seconds.
+ *
+ * @since 11.2.0
+ *
+ * @param int $batch_size Number of products included in each batch for re-indexing.
+ * @return array<int,int>
+ */
+ public function reindex_products( int $batch_size = 250 ): array {
+ global $wpdb;
+
+ // Performance note: prefetch product ids; enables deterministic behaviour and faster queries below.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $product_ids = array_map( 'intval', $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' ORDER BY menu_order ASC, post_title ASC, ID ASC" ) );
+ /**
+ * Whether to fire the clean_post_cache action per product after reordering or apply targeted cache invalidation.
+ * Default strategy is clean_post_cache is suboptimal, but applied for backward compatibility reasons.
+ *
+ * @since 11.2.0
+ *
+ * @param bool $clean_post_cache Whether to fire clean_post_cache per product.
+ * @returns bool
+ */
+ $clean_post_cache = (bool) apply_filters( 'woocommerce_single_product_ordering_clean_post_cache', true );
+
+ $result = array();
+ $current_position = 1;
+ for ( $offset = 0, $total = count( $product_ids ), $batch_size = max( 1, $batch_size ); $offset < $total; $offset += $batch_size ) {
+ $batch_ids = array_slice( $product_ids, $offset, $batch_size );
+ $batch_positions = array();
+ $batch_branches = array();
+ foreach ( $batch_ids as $id ) {
+ $batch_positions[ $id ] = $current_position;
+ $batch_branches[] = sprintf( 'WHEN %d THEN %d', $id, $current_position++ );
+ }
+
+ $batch_branches = implode( ' ', $batch_branches );
+ $in_values = implode( ', ', $batch_ids );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $updated = (int) $wpdb->query( "UPDATE {$wpdb->posts} SET menu_order = CASE ID {$batch_branches} END WHERE ID IN ( {$in_values} )" );
+ if ( $updated > 0 ) {
+ if ( $clean_post_cache ) {
+ // Performance note: fires clean_post_cache action per product for cache plugins compatibility (WooCommerce v11.2).
+ array_walk( $batch_ids, 'clean_post_cache' );
+ } else {
+ // Performance note: clear only the posts cache — menu_order lives in wp_posts, not in meta or term caches.
+ wp_cache_delete_multiple( $batch_ids, 'posts' );
+ wp_cache_set_posts_last_changed();
+ }
+
+ // Update the result entries only if update is confirmed.
+ foreach ( $batch_positions as $id => $position ) {
+ $result[ $id ] = $position;
+ }
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 11ef63149e4..104978276a8 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -692,6 +692,172 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
);
}
+ /**
+ * Data provider for test_product_ordering.
+ *
+ * Columns: sorting_idx, previd_idx (-1 = none), nextid_idx (-1 = none), expected menu_orders [P1..P5].
+ */
+ public function product_ordering_provider(): array {
+ return array(
+ 'last to first' => array( 4, -1, 0, array( 2, 3, 4, 5, 1 ) ),
+ 'first to last' => array( 0, 4, -1, array( 5, 1, 2, 3, 4 ) ),
+ 'middle one position left' => array( 2, 0, 1, array( 1, 3, 2, 4, 5 ) ),
+ 'middle one position right' => array( 2, 3, 4, array( 1, 2, 4, 3, 5 ) ),
+ 'middle to first' => array( 2, -1, 0, array( 2, 3, 1, 4, 5 ) ),
+ 'middle to last' => array( 2, 4, -1, array( 1, 2, 5, 3, 4 ) ),
+ 'drop in place' => array( 2, 1, 3, array( 1, 2, 3, 4, 5 ) ),
+ );
+ }
+
+ /**
+ * @testdox 'product_ordering' (legacy algorithm) moves a product to the correct position and shifts the affected range.
+ * @dataProvider product_ordering_provider
+ *
+ * @param int $sorting_idx Index (0-based) of the product being dragged.
+ * @param int $previd_idx Index of the product immediately before the drop target, or -1 if dropped at the top.
+ * @param int $nextid_idx Index of the product immediately after the drop target, or -1 if dropped at the bottom.
+ * @param int[] $expected_orders Expected menu_order values indexed by original product position [P1..P5].
+ */
+ public function test_product_ordering_using_legacy_algorithm( int $sorting_idx, int $previd_idx, int $nextid_idx, array $expected_orders ): void {
+ global $wpdb;
+
+ $this->_setRole( 'administrator' );
+
+ // Attach a listener to force the legacy branching path.
+ $legacy_hook = function () {};
+ add_action( 'woocommerce_after_single_product_ordering', $legacy_hook );
+
+ $products = array();
+ for ( $i = 1; $i <= 5; ++$i ) {
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+ $products[ $product_id ] = $product;
+ wp_update_post(
+ array(
+ 'ID' => $product_id,
+ 'menu_order' => $i,
+ )
+ );
+ }
+ $product_ids = array_keys( $products );
+
+ $_POST['security'] = wp_create_nonce( 'product-ordering' );
+ $_POST['id'] = $product_ids[ $sorting_idx ];
+ $_POST['previd'] = $previd_idx >= 0 ? $product_ids[ $previd_idx ] : 0;
+ $_POST['nextid'] = $nextid_idx >= 0 ? $product_ids[ $nextid_idx ] : 0;
+
+ $this->do_ajax( 'woocommerce_product_ordering' );
+
+ unset( $_POST['security'], $_POST['id'], $_POST['previd'], $_POST['nextid'] );
+ remove_action( 'woocommerce_after_single_product_ordering', $legacy_hook );
+
+ foreach ( $product_ids as $idx => $product_id ) {
+ $actual = (int) $wpdb->get_var( $wpdb->prepare( "SELECT menu_order FROM {$wpdb->posts} WHERE ID = %d", $product_id ) );
+ $this->assertSame( $expected_orders[ $idx ], $actual, "Product at index {$idx} has wrong menu_order." );
+ $products[ $product_id ]->delete( true );
+ }
+ }
+
+ /**
+ * @testdox 'product_ordering' (range algorithm) moves a product to the correct position and shifts the affected range.
+ * @dataProvider product_ordering_provider
+ *
+ * @param int $sorting_idx Index (0-based) of the product being dragged.
+ * @param int $previd_idx Index of the product immediately before the drop target, or -1 if dropped at the top.
+ * @param int $nextid_idx Index of the product immediately after the drop target, or -1 if dropped at the bottom.
+ * @param int[] $expected_orders Expected menu_order values indexed by original product position [P1..P5].
+ */
+ public function test_product_ordering_using_range_algorithm( int $sorting_idx, int $previd_idx, int $nextid_idx, array $expected_orders ): void {
+ global $wpdb;
+
+ $this->_setRole( 'administrator' );
+
+ $products = array();
+ for ( $i = 1; $i <= 5; ++$i ) {
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+ $products[ $product_id ] = $product;
+ wp_update_post(
+ array(
+ 'ID' => $product_id,
+ 'menu_order' => $i,
+ )
+ );
+ }
+ $product_ids = array_keys( $products );
+
+ $_POST['security'] = wp_create_nonce( 'product-ordering' );
+ $_POST['id'] = $product_ids[ $sorting_idx ];
+ $_POST['previd'] = $previd_idx >= 0 ? $product_ids[ $previd_idx ] : 0;
+ $_POST['nextid'] = $nextid_idx >= 0 ? $product_ids[ $nextid_idx ] : 0;
+
+ $this->do_ajax( 'woocommerce_product_ordering' );
+
+ unset( $_POST['security'], $_POST['id'], $_POST['previd'], $_POST['nextid'] );
+ foreach ( $product_ids as $idx => $product_id ) {
+ $actual = (int) $wpdb->get_var( $wpdb->prepare( "SELECT menu_order FROM {$wpdb->posts} WHERE ID = %d", $product_id ) );
+ $this->assertSame( $expected_orders[ $idx ], $actual, "Product at index {$idx} has wrong menu_order." );
+ $products[ $product_id ]->delete( true );
+ }
+ }
+
+ /**
+ * @testdox 'product_ordering' fires 'woocommerce_after_product_ordering' with the moved product ID and full positions map.
+ */
+ public function test_product_ordering_fires_after_product_ordering_action(): void {
+ $this->_setRole( 'administrator' );
+
+ $products = array();
+ for ( $i = 1; $i <= 2; ++$i ) {
+ $product = WC_Helper_Product::create_simple_product();
+ $product_id = $product->get_id();
+ $products[ $product_id ] = $product;
+ wp_update_post(
+ array(
+ 'ID' => $product_id,
+ 'menu_order' => $i,
+ )
+ );
+ }
+ $product_ids = array_keys( $products );
+
+ $hook_fired = false;
+ $captured = array();
+ $hook = function ( $sorting_id, $all_positions ) use ( &$hook_fired, &$captured ) {
+ $hook_fired = true;
+ $captured = array(
+ 'sorting_id' => $sorting_id,
+ 'all_positions' => $all_positions,
+ );
+ };
+ add_action( 'woocommerce_after_product_ordering', $hook, 10, 2 );
+
+ // Move the last one to the front.
+ $_POST['security'] = wp_create_nonce( 'product-ordering' );
+ $_POST['id'] = $product_ids[1];
+ $_POST['previd'] = 0;
+ $_POST['nextid'] = $product_ids[0];
+
+ $this->do_ajax( 'woocommerce_product_ordering' );
+
+ unset( $_POST['security'], $_POST['id'], $_POST['previd'], $_POST['nextid'] );
+ remove_action( 'woocommerce_after_product_ordering', $hook, 10 );
+
+ $this->assertTrue( $hook_fired, 'woocommerce_after_product_ordering was not fired.' );
+ $this->assertSame( $product_ids[1], $captured['sorting_id'] );
+ $this->assertSame(
+ array(
+ $product_ids[0] => 2,
+ $product_ids[1] => 1,
+ ),
+ $captured['all_positions']
+ );
+
+ foreach ( $product_ids as $product_id ) {
+ $products[ $product_id ]->delete( true );
+ }
+ }
+
/**
* @testdox Refunding a 0% taxed line item via the AJAX handler preserves the 0-rate tax line on the refund order.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingMoveServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingMoveServiceTest.php
new file mode 100644
index 00000000000..0cfe68c985c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingMoveServiceTest.php
@@ -0,0 +1,386 @@
+<?php declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Products;
+
+use Automattic\WooCommerce\Internal\Products\ProductsOrderingMoveService;
+use Automattic\WooCommerce\Internal\Products\ProductsOrderingReindexService;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductsOrderingMoveService class.
+ */
+final class ProductsOrderingMoveServiceTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var ProductsOrderingMoveService
+ */
+ private ProductsOrderingMoveService $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new ProductsOrderingMoveService();
+ $this->sut->init( new ProductsOrderingReindexService() );
+ }
+
+ /**
+ * @testdox move() repositions a product within a fully indexed catalog and shifts the affected range.
+ * @dataProvider move_provider
+ *
+ * @param int $sorting_idx Index (0-based) of the product being moved.
+ * @param int $previd_idx Index of the product immediately before the drop target, or -1 if dropped at the top.
+ * @param int $nextid_idx Index of the product immediately after the drop target, or -1 if dropped at the bottom.
+ * @param int[] $expected_orders Expected menu_order values indexed by original product position [P1..P5].
+ */
+ public function test_move( int $sorting_idx, int $previd_idx, int $nextid_idx, array $expected_orders ): void {
+ global $wpdb;
+
+ $products = array();
+ $product_ids = array();
+ for ( $i = 1; $i <= 5; ++$i ) {
+ $product = new \WC_Product_Simple();
+ $product->set_menu_order( $i );
+ $product->save();
+
+ $product_id = $product->get_id();
+ $product_ids[] = $product_id;
+ $products[ $product_id ] = $product;
+ }
+
+ $result = $this->sut->move(
+ $previd_idx >= 0 ? $product_ids[ $previd_idx ] : 0,
+ $product_ids[ $sorting_idx ],
+ $nextid_idx >= 0 ? $product_ids[ $nextid_idx ] : 0
+ );
+
+ $this->assertEmpty( $result->reindexed, 'No reindex should occur when all products are already sequentially indexed.' );
+ foreach ( $product_ids as $index => $product_id ) {
+ $actual = (int) $wpdb->get_var( $wpdb->prepare( "SELECT menu_order FROM {$wpdb->posts} WHERE ID = %d", $product_id ) );
+ $this->assertSame( $expected_orders[ $index ], $actual, "Product at index {$index} has wrong menu_order." );
+ $products[ $product_id ]->delete( true );
+ }
+ }
+
+ /**
+ * Data provider for test_move.
+ *
+ * Columns: sorting_idx, previd_idx (-1 = none), nextid_idx (-1 = none), expected menu_orders [P1..P5].
+ *
+ * @return array
+ */
+ public function move_provider(): array {
+ return array(
+ 'last to first' => array( 4, -1, 0, array( 2, 3, 4, 5, 1 ) ),
+ 'first to last' => array( 0, 4, -1, array( 5, 1, 2, 3, 4 ) ),
+ 'middle one position left' => array( 2, 0, 1, array( 1, 3, 2, 4, 5 ) ),
+ 'middle one position right' => array( 2, 3, 4, array( 1, 2, 4, 3, 5 ) ),
+ 'middle to first' => array( 2, -1, 0, array( 2, 3, 1, 4, 5 ) ),
+ 'middle to last' => array( 2, 4, -1, array( 1, 2, 5, 3, 4 ) ),
+ 'drop in place' => array( 2, 1, 3, array( 1, 2, 3, 4, 5 ) ),
+ );
+ }
+
+ /**
+ * @testdox move() triggers a full reindex and then applies the move when no products have been indexed yet.
+ */
+ public function test_move_with_no_prior_indexing(): void {
+ global $wpdb;
+
+ $alpha_id = $this->create_product( 'Alpha', 0 );
+ $beta_id = $this->create_product( 'Beta', 0 );
+ $gamma_id = $this->create_product( 'Gamma', 0 );
+ $products = array( $alpha_id, $beta_id, $gamma_id );
+
+ $result = $this->sut->move( $alpha_id, $gamma_id, $beta_id );
+
+ $this->assertSame( array( $alpha_id => 1 ), $result->reindexed );
+ $this->assertSame(
+ array(
+ $gamma_id => 2,
+ $beta_id => 3,
+ ),
+ $result->moved
+ );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
+ $db_positions = $wpdb->get_results( $wpdb->prepare( "SELECT ID, menu_order FROM {$wpdb->posts} WHERE ID IN (%d, %d, %d) ORDER BY menu_order ASC", $alpha_id, $gamma_id, $beta_id ) );
+ $this->assertEquals(
+ array(
+ $alpha_id => 1,
+ $gamma_id => 2,
+ $beta_id => 3,
+ ),
+ array_column( $db_positions, 'menu_order', 'ID' )
+ );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() triggers a full reindex when the target position is adjacent to an unindexed product.
+ */
+ public function test_move_from_indexed_into_unindexed_group(): void {
+ $delta_id = $this->create_product( 'Delta', 0 );
+ $gamma_id = $this->create_product( 'Gamma', 0 );
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+ $beta_id = $this->create_product( 'Beta', 2 );
+ $products = array( $delta_id, $gamma_id, $alpha_id, $beta_id );
+
+ $result = $this->sut->move( $delta_id, $beta_id, $gamma_id );
+
+ $this->assertSame( array( $delta_id => 1 ), $result->reindexed );
+ $this->assertSame(
+ array(
+ $beta_id => 2,
+ $gamma_id => 3,
+ $alpha_id => 4,
+ ),
+ $result->moved
+ );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() triggers a reindex when two products share the same position (collision) and then applies the move.
+ */
+ public function test_move_triggers_reindex_on_position_collision(): void {
+ $delta_id = $this->create_product( 'Delta', 1 );
+ $beta_id = $this->create_product( 'Beta', 2 );
+ $gamma_id = $this->create_product( 'Gamma', 2 );
+ $products = array( $delta_id, $beta_id, $gamma_id );
+
+ $result = $this->sut->move( $delta_id, $gamma_id, $beta_id );
+
+ $this->assertSame( array( $delta_id => 1 ), $result->reindexed );
+ $this->assertSame(
+ array(
+ $gamma_id => 2,
+ $beta_id => 3,
+ ),
+ $result->moved
+ );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() resolves a collision even when colliding anchors already match the requested order.
+ */
+ public function test_move_resolves_collision_matching_requested_order(): void {
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+ $beta_id = $this->create_product( 'Beta', 2 );
+ $gamma_id = $this->create_product( 'Gamma', 2 );
+ $products = array( $alpha_id, $beta_id, $gamma_id );
+
+ $result = $this->sut->move( $alpha_id, $beta_id, $gamma_id );
+
+ // Reindex resolves the collision; product is already in place post-reindex, so no move needed.
+ $this->assertSame(
+ array(
+ $alpha_id => 1,
+ $beta_id => 2,
+ $gamma_id => 3,
+ ),
+ $result->reindexed
+ );
+ $this->assertSame( array(), $result->moved );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() triggers a reindex but skips the move when the product is already in the correct position after reindexing.
+ */
+ public function test_move_skips_apply_when_in_place_after_reindex(): void {
+ $gamma_id = $this->create_product( 'Gamma', 0 );
+ $beta_id = $this->create_product( 'Beta', 0 );
+ $alpha_id = $this->create_product( 'Alpha', 0 );
+ $products = array( $gamma_id, $beta_id, $alpha_id );
+
+ $result = $this->sut->move( $alpha_id, $beta_id, $gamma_id );
+
+ $this->assertSame(
+ array(
+ $alpha_id => 1,
+ $beta_id => 2,
+ $gamma_id => 3,
+ ),
+ $result->reindexed
+ );
+ $this->assertSame( array(), $result->moved );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() does nothing when moving an unindexed product to the first position and it already sorts first.
+ */
+ public function test_move_unindexed_to_first_position(): void {
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+ $beta_id = $this->create_product( 'Beta', 2 );
+ $gamma_id = $this->create_product( 'Gamma', 0 );
+ $products = array( $alpha_id, $beta_id, $gamma_id );
+
+ $result = $this->sut->move( 0, $gamma_id, $alpha_id );
+
+ $this->assertSame( array(), $result->reindexed );
+ $this->assertSame( array(), $result->moved );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() is a no-op when both anchors are zero.
+ */
+ public function test_move_with_both_anchors_zero(): void {
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+
+ $result = $this->sut->move( 0, $alpha_id, 0 );
+
+ $this->assertSame( array(), $result->reindexed );
+ $this->assertSame( array(), $result->moved );
+
+ wc_get_product( $alpha_id )->delete( true );
+ }
+
+ /**
+ * @testdox move() triggers a full reindex when moving an unindexed product to the last position.
+ */
+ public function test_move_unindexed_to_last_position(): void {
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+ $beta_id = $this->create_product( 'Beta', 2 );
+ $gamma_id = $this->create_product( 'Gamma', 0 );
+ $products = array( $alpha_id, $beta_id, $gamma_id );
+
+ $result = $this->sut->move( $beta_id, $gamma_id, 0 );
+
+ $this->assertSame( array(), $result->reindexed );
+ $this->assertSame(
+ array(
+ $alpha_id => 1,
+ $beta_id => 2,
+ $gamma_id => 3,
+ ),
+ $result->moved
+ );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() triggers a full reindex when moving an unindexed product into an already indexed group.
+ */
+ public function test_move_from_unindexed_into_indexed_group(): void {
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+ $beta_id = $this->create_product( 'Beta', 2 );
+ $gamma_id = $this->create_product( 'Gamma', 0 );
+ $products = array( $alpha_id, $beta_id, $gamma_id );
+
+ $result = $this->sut->move( $alpha_id, $gamma_id, $beta_id );
+
+ $this->assertSame( array( $beta_id => 3 ), $result->reindexed );
+ $this->assertSame(
+ array(
+ $alpha_id => 1,
+ $gamma_id => 2,
+ ),
+ $result->moved
+ );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() does not reindex and leaves unindexed products untouched when the move stays within the indexed group.
+ */
+ public function test_move_within_indexed_group_with_unindexed_present(): void {
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+ $beta_id = $this->create_product( 'Beta', 2 );
+ $gamma_id = $this->create_product( 'Gamma', 3 );
+ $delta_id = $this->create_product( 'Delta', 0 );
+ $epsilon_id = $this->create_product( 'Epsilon', 0 );
+ $products = array( $alpha_id, $beta_id, $gamma_id, $delta_id, $epsilon_id );
+
+ $result = $this->sut->move( $alpha_id, $gamma_id, $beta_id );
+
+ $this->assertSame( array(), $result->reindexed );
+ $this->assertSame(
+ array(
+ $gamma_id => 2,
+ $beta_id => 3,
+ ),
+ $result->moved
+ );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() correctly shifts all products in the range when non-anchor duplicates exist (no reindex).
+ */
+ public function test_move_with_non_anchor_duplicate_in_range(): void {
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+ $beta_id = $this->create_product( 'Beta', 2 );
+ $charlie_id = $this->create_product( 'Charlie', 2 );
+ $delta_id = $this->create_product( 'Delta', 3 );
+ $echo_id = $this->create_product( 'Echo', 4 );
+ $products = array( $alpha_id, $beta_id, $charlie_id, $delta_id, $echo_id );
+
+ $result = $this->sut->move( $alpha_id, $echo_id, $beta_id );
+
+ $this->assertSame( array(), $result->reindexed );
+ $this->assertSame(
+ array(
+ $echo_id => 2,
+ $beta_id => 3,
+ $charlie_id => 3,
+ $delta_id => 4,
+ ),
+ $result->moved
+ );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @testdox move() triggers a reindex when previous and next anchors share the same non-zero position.
+ */
+ public function test_move_triggers_reindex_on_anchor_collision(): void {
+ $alpha_id = $this->create_product( 'Alpha', 1 );
+ $beta_id = $this->create_product( 'Beta', 5 );
+ $gamma_id = $this->create_product( 'Gamma', 5 );
+ $products = array( $alpha_id, $beta_id, $gamma_id );
+
+ $result = $this->sut->move( $beta_id, $alpha_id, $gamma_id );
+
+ $this->assertSame( array( $gamma_id => 3 ), $result->reindexed );
+ $this->assertSame(
+ array(
+ $beta_id => 1,
+ $alpha_id => 2,
+ ),
+ $result->moved
+ );
+
+ array_walk( $products, static fn( $id ) => wc_get_product( $id )->delete( true ) );
+ }
+
+ /**
+ * @param string $name Product name (controls reindex sort order via post_title ASC).
+ * @param int $menu_order Initial menu_order value.
+ * @return int
+ */
+ private function create_product( string $name, int $menu_order ): int {
+ $product = new \WC_Product_Simple();
+ $product->set_name( $name );
+ $product->set_menu_order( $menu_order );
+ $product->save();
+
+ return $product->get_id();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingReindexServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingReindexServiceTest.php
new file mode 100644
index 00000000000..e0e753f366e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Products/ProductsOrderingReindexServiceTest.php
@@ -0,0 +1,138 @@
+<?php declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Products;
+
+use Automattic\WooCommerce\Internal\Products\ProductsOrderingReindexService;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the ProductsOrderingReindexService class.
+ */
+final class ProductsOrderingReindexServiceTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var ProductsOrderingReindexService
+ */
+ private ProductsOrderingReindexService $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new ProductsOrderingReindexService();
+ }
+
+ /**
+ * @testdox reindex_products assigns sequential positions starting at 1, ordered by menu_order then post_title.
+ * @dataProvider reindex_products_data_provider
+ *
+ * @param array<array{name:string,menu_order:int}> $products Products to create before reindexing.
+ * @param string[] $expected_order Product names in expected ascending position order.
+ */
+ public function test_reindex_products( array $products, array $expected_order ): void {
+ $name_to_id = array();
+ foreach ( $products as $product_data ) {
+ $product = new \WC_Product_Simple();
+ $product->set_name( $product_data['name'] );
+ $product->set_menu_order( $product_data['menu_order'] );
+ $product->save();
+ $name_to_id[ $product_data['name'] ] = $product->get_id();
+ }
+
+ $result = $this->sut->reindex_products( 2 );
+
+ $this->assertCount( count( $expected_order ), $result );
+ foreach ( $expected_order as $index => $name ) {
+ $this->assertSame(
+ $index + 1,
+ $result[ $name_to_id[ $name ] ],
+ "Product '{$name}' should have position " . ( $index + 1 ) . '.'
+ );
+ }
+ }
+
+ /**
+ * Data provider for test_reindex_products.
+ *
+ * @return array
+ */
+ public function reindex_products_data_provider(): array {
+ return array(
+ 'empty catalog' => array(
+ 'products' => array(),
+ 'expected_order' => array(),
+ ),
+ 'unindexed products sorted by title' => array(
+ 'products' => array(
+ array(
+ 'name' => 'Gamma',
+ 'menu_order' => 0,
+ ),
+ array(
+ 'name' => 'Alpha',
+ 'menu_order' => 0,
+ ),
+ array(
+ 'name' => 'Beta',
+ 'menu_order' => 0,
+ ),
+ ),
+ 'expected_order' => array( 'Alpha', 'Beta', 'Gamma' ),
+ ),
+ 'already sequentially indexed' => array(
+ 'products' => array(
+ array(
+ 'name' => 'First',
+ 'menu_order' => 1,
+ ),
+ array(
+ 'name' => 'Second',
+ 'menu_order' => 2,
+ ),
+ array(
+ 'name' => 'Third',
+ 'menu_order' => 3,
+ ),
+ ),
+ 'expected_order' => array(),
+ ),
+ 'sparse positions compacted' => array(
+ 'products' => array(
+ array(
+ 'name' => 'First',
+ 'menu_order' => 1,
+ ),
+ array(
+ 'name' => 'Second',
+ 'menu_order' => 5,
+ ),
+ array(
+ 'name' => 'Third',
+ 'menu_order' => 10,
+ ),
+ ),
+ 'expected_order' => array( 'First', 'Second', 'Third' ),
+ ),
+ 'collisions resolved by title' => array(
+ 'products' => array(
+ array(
+ 'name' => 'Beta',
+ 'menu_order' => 1,
+ ),
+ array(
+ 'name' => 'Alpha',
+ 'menu_order' => 1,
+ ),
+ array(
+ 'name' => 'Gamma',
+ 'menu_order' => 2,
+ ),
+ ),
+ 'expected_order' => array( 'Alpha', 'Beta', 'Gamma' ),
+ ),
+ );
+ }
+}