diff --git a/src/wp-includes/class-hierarchical-sort.php b/src/wp-includes/class-hierarchical-sort.php new file mode 100644 index 0000000000000..2b08ae4048443 --- /dev/null +++ b/src/wp-includes/class-hierarchical-sort.php @@ -0,0 +1,141 @@ + 'id=>parent', + 'posts_per_page' => -1, + ) + ); + $query = new WP_Query( $new_args ); + $posts = $query->posts; + + return self::sort( $posts ); + } + + private static function get_ancestor( $post_id ) { + return get_post( $post_id )->post_parent ?? 0; + } + + /** + * Sort posts by hierarchy. + * + * Takes an array of posts and sorts them based on their parent-child relationships. + * It also tracks the level depth of each post in the hierarchy. + * + * Example input: + * ``` + * [ + * ['ID' => 4, 'post_parent' => 2], + * ['ID' => 2, 'post_parent' => 0], + * ['ID' => 3, 'post_parent' => 2], + * ] + * ``` + * + * Example output: + * ``` + * [ + * 'post_ids' => [2, 4, 3], + * 'levels' => [0, 1, 1] + * ] + * ``` + * + * @param array $posts Array of post objects containing ID and post_parent properties. + * + * @return array { + * Sorted post IDs and their hierarchical levels + * + * @type array $post_ids Array of post IDs + * @type array $levels Array of levels for the corresponding post ID in the same index + * } + */ + private static function sort( $posts ) { + /* + * Arrange pages in two arrays: + * + * - $top_level: posts whose parent is 0 + * - $children: post ID as the key and an array of children post IDs as the value. + * Example: $children[10][] contains all sub-pages whose parent is 10. + * + * Additionally, keep track of the levels of each post in $levels. + * Example: $levels[10] = 0 means the post ID is a top-level page. + * + */ + $top_level = array(); + $children = array(); + foreach ( $posts as $post ) { + if ( empty( $post->post_parent ) ) { + $top_level[] = $post->ID; + } else { + $children[ $post->post_parent ][] = $post->ID; + } + } + + $ids = array(); + $levels = array(); + self::add_hierarchical_ids( $ids, $levels, 0, $top_level, $children ); + + // Process remaining children. + if ( ! empty( $children ) ) { + foreach ( $children as $parent_id => $child_ids ) { + $level = 0; + $ancestor = $parent_id; + while ( 0 !== $ancestor ) { + ++$level; + $ancestor = self::get_ancestor( $ancestor ); + } + self::add_hierarchical_ids( $ids, $levels, $level, $child_ids, $children ); + } + } + + return array( + 'post_ids' => $ids, + 'levels' => $levels, + ); + } + + private static function add_hierarchical_ids( &$ids, &$levels, $level, $to_process, $children ) { + foreach ( $to_process as $id ) { + if ( in_array( $id, $ids, true ) ) { + continue; + } + $ids[] = $id; + $levels[ $id ] = $level; + + if ( isset( $children[ $id ] ) ) { + self::add_hierarchical_ids( $ids, $levels, $level + 1, $children[ $id ], $children ); + unset( $children[ $id ] ); + } + } + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index 95851199c6e4d..962dc72db42f1 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -47,6 +47,13 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { */ protected $allow_batch = array( 'v1' => true ); + /** + * Holds information about each post's level. + * Level means the depth of the post in the hierarchy: + * top-level posts have level 0, their children have level 1, and so on. + */ + protected $levels = array(); + /** * Constructor. * @@ -402,6 +409,14 @@ static function ( $format ) { // Force the post_type argument, since it's not a user input variable. $args['post_type'] = $this->post_type; + if ( Hierarchical_Sort::is_eligible( $request ) ) { + $result = Hierarchical_Sort::run( $args ); + $this->levels = $result['levels']; + + $args['post__in'] = $result['post_ids']; + $args['orderby'] = 'post__in'; + } + /** * Filters WP_Query arguments when querying posts via the REST API. * @@ -2090,6 +2105,10 @@ public function prepare_item_for_response( $item, $request ) { } } + if ( Hierarchical_Sort::is_eligible( $request ) ) { + $response->data['level'] = $this->levels[ $post->ID ]; + } + /** * Filters the post data for a REST API response. * @@ -3008,6 +3027,13 @@ public function get_collection_params() { 'default' => array(), ); } + if ( $post_type->hierarchical ) { + $query_params['orderby_hierarchy'] = array( + 'description' => __( 'Whether the post should be grouped by parent-child relationship (hierarchy).' ), + 'type' => 'boolean', + 'default' => false, + ); + } $query_params['search_columns'] = array( 'default' => array(), diff --git a/src/wp-settings.php b/src/wp-settings.php index 635f6de248dd5..16902b231ff49 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -404,6 +404,7 @@ require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api-directives-processor.php'; require ABSPATH . WPINC . '/interactivity-api/interactivity-api.php'; require ABSPATH . WPINC . '/class-wp-plugin-dependencies.php'; +require ABSPATH . WPINC . '/class-hierarchical-sort.php'; add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) ); add_action( 'after_setup_theme', array( wp_interactivity(), 'add_hooks' ) ); diff --git a/tests/phpunit/includes/class-hierarchical-sort-test.php b/tests/phpunit/includes/class-hierarchical-sort-test.php new file mode 100644 index 0000000000000..a0d17c9035c20 --- /dev/null +++ b/tests/phpunit/includes/class-hierarchical-sort-test.php @@ -0,0 +1,204 @@ + 11, + 'post_parent' => 9, + ), + (object) array( + 'ID' => 12, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 8, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 3, + 'post_parent' => 12, + ), + (object) array( + 'ID' => 6, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 9, + 'post_parent' => 8, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 12, + ), + (object) array( + 'ID' => 5, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 10, + 'post_parent' => 8, + ), + ); + + $result = Hierarchical_Sort::run( $input ); + $this->assertEquals( array( 12, 3, 6, 5, 4, 7, 8, 9, 11, 10 ), $result['post_ids'] ); + $this->assertEquals( + array( + 12 => 0, + 3 => 1, + 6 => 2, + 5 => 2, + 4 => 1, + 7 => 2, + 8 => 0, + 9 => 1, + 11 => 2, + 10 => 1, + ), + $result['levels'] + ); + } + + public function test_return_orphans() { + /* + * Keep this updated as the input array changes. + * The sorted hierarchy would be as follows: + * + * - 11 (orphan) + * - 4 (orphan) + * -- 7 + * + */ + $input = array( + (object) array( + 'ID' => 11, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 2, + ), + ); + + $result = Hierarchical_Sort::run( $input ); + $this->assertEquals( array( 11, 4, 7 ), $result['post_ids'] ); + $this->assertEquals( + array( + 11 => 1, + 4 => 1, + 7 => 2, + ), + $result['levels'] + ); + } + + public function test_post_with_empty_post_parent_are_considered_top_level() { + /* + * Keep this updated as the input array changes. + * The sorted hierarchy would be as follows: + * + * 2 + * - 3 + * -- 5 + * -- 6 + * - 4 + * -- 7 + * 8 + * - 9 + * -- 11 + * - 10 + * + */ + $input = array( + (object) array( + 'ID' => 11, + 'post_parent' => 9, + ), + (object) array( + 'ID' => 2, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 8, + 'post_parent' => '', // Empty post parent, should be considered top-level. + ), + (object) array( + 'ID' => 3, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 5, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 9, + 'post_parent' => 8, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 6, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 10, + 'post_parent' => 8, + ), + ); + + $result = Hierarchical_Sort::run( $input ); + $this->assertEquals( array( 2, 3, 5, 6, 4, 7, 8, 9, 11, 10 ), $result['post_ids'] ); + $this->assertEquals( + array( + 2 => 0, + 3 => 1, + 5 => 2, + 6 => 2, + 4 => 1, + 7 => 2, + 8 => 0, + 9 => 1, + 11 => 2, + 10 => 1, + ), + $result['levels'] + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-pages-controller.php b/tests/phpunit/tests/rest-api/rest-pages-controller.php index 9717a7fcda1c6..37478a1ec850c 100644 --- a/tests/phpunit/tests/rest-api/rest-pages-controller.php +++ b/tests/phpunit/tests/rest-api/rest-pages-controller.php @@ -79,6 +79,7 @@ public function test_registered_query_params() { 'offset', 'order', 'orderby', + 'orderby_hierarchy', 'page', 'parent', 'parent_exclude', diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 448548aab0c07..3fc6df7d0128d 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -1808,6 +1808,12 @@ mockedApiResponse.Schema = { "default": [], "required": false }, + "orderby_hierarchy": { + "description": "Whether the post should be grouped by parent-child relationship (hierarchy).", + "type": "boolean", + "default": false, + "required": false + }, "search_columns": { "default": [], "description": "Array of column names to be searched.",