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 068cc70bc2a9d..8a899a0c36559 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 @@ -198,8 +198,24 @@ public function check_password_required( $required, $post ) { */ public function get_items( $request ) { + // Process orderby first, to allow for an early exit when a request is a client error (400) + $orderby_keys = array(); + if ( isset( $request['orderby'] ) ) { + // The orderby query string parameter can look like ?orderby=menu_order, ?orderby[]=menu_order, or ?orderby[menu_order]=asc. + if ( rest_is_array( $request['orderby'] ) ) { + // An array: ?orderby[]=menu_order + // A fake array: ?orderby=menu_order,title + // A string: ?orderby=menu_order + // The latter two because rest_is_array() and rest_sanitize_array() pass scalars through wp_parse_list() + $orderby_keys = $request['orderby']; + } else { + // An object: ?orderby[menu_order]=asc + $orderby_keys = array_keys( $request['orderby'] ); + } + } + // Ensure a search string is set in case the orderby is set to 'relevance'. - if ( ! empty( $request['orderby'] ) && 'relevance' === $request['orderby'] && empty( $request['search'] ) ) { + if ( ! empty( $request['orderby'] ) && in_array( 'relevance', $orderby_keys, true ) && empty( $request['search'] ) ) { return new WP_Error( 'rest_no_search_term_defined', __( 'You need to define a search term to order by relevance.' ), @@ -208,7 +224,7 @@ public function get_items( $request ) { } // Ensure an include parameter is set in case the orderby is set to 'include'. - if ( ! empty( $request['orderby'] ) && 'include' === $request['orderby'] && empty( $request['include'] ) ) { + if ( ! empty( $request['orderby'] ) && in_array( 'include', $orderby_keys, true ) && empty( $request['include'] ) ) { return new WP_Error( 'rest_orderby_include_missing_include', __( 'You need to define an include parameter to order by include.' ), @@ -1072,7 +1088,7 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul } // Map to proper WP_Query orderby param. - if ( isset( $query_args['orderby'] ) && isset( $request['orderby'] ) ) { + if ( isset( $query_args['orderby'] ) ) { $orderby_mappings = array( 'id' => 'ID', 'include' => 'post__in', @@ -1080,8 +1096,42 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul 'include_slugs' => 'post_name__in', ); - if ( isset( $orderby_mappings[ $request['orderby'] ] ) ) { - $query_args['orderby'] = $orderby_mappings[ $request['orderby'] ]; + // The orderby query string parameter can look like ?orderby=menu_order, ?orderby[]=menu_order, or ?orderby[menu_order]=asc. + if ( rest_is_array( $query_args['orderby'] ) ) { + // An array: ?orderby[]=menu_order + // A fake array: ?orderby=menu_order,title + // A string: ?orderby=menu_order + // The latter two because rest_is_array() and rest_sanitize_array() pass scalars through wp_parse_list() + $orderby_mappings_in_request = array_intersect_key( $orderby_mappings, array_flip( $query_args['orderby'] ) ); + + $new_arg = array(); + // Remaps values. Preserves value order. + foreach ( $query_args['orderby'] as $orderby ) { + if ( array_key_exists( $orderby, $orderby_mappings_in_request ) ) { + $new_arg[] = $orderby_mappings_in_request[ $orderby ]; + } else { + $new_arg[] = $orderby; + } + } + + // WP_Query expects a space separated list. + $query_args['orderby'] = join( ' ', $new_arg ); + } else { + // An object: ?orderby[menu_order]=asc + $orderby_mappings_in_request = array_intersect_key( $orderby_mappings, $query_args['orderby'] ); + + $new_arg = array(); + // Remaps keys. Preserves key order. + foreach ( $query_args['orderby'] as $orderby => $order ) { + if ( array_key_exists( $orderby, $orderby_mappings_in_request ) ) { + $new_arg[ $orderby_mappings_in_request[ $orderby ] ] = $order; + } else { + $new_arg[ $orderby ] = $order; + } + } + + // WP_Query expects an array with orderby (database table column) keys and order ("asc", "desc") values. + $query_args['orderby'] = $new_arg; } } @@ -2794,28 +2844,75 @@ public function get_collection_params() { 'enum' => array( 'asc', 'desc' ), ); - $query_params['orderby'] = array( - 'description' => __( 'Sort collection by post attribute.' ), - 'type' => 'string', - 'default' => 'date', - 'enum' => array( - 'author', - 'date', - 'id', - 'include', - 'modified', - 'parent', - 'relevance', - 'slug', - 'include_slugs', - 'title', - ), + $orderby_enum = array( + 'author', + 'date', + 'id', + 'include', + 'modified', + 'parent', + 'relevance', + 'slug', + 'include_slugs', + 'title', ); if ( 'page' === $this->post_type || post_type_supports( $this->post_type, 'page-attributes' ) ) { - $query_params['orderby']['enum'][] = 'menu_order'; + $orderby_enum[] = 'menu_order'; } + $query_params['orderby'] = array( + 'description' => __( 'Sort collection by post attribute.' ), + 'type' => array( 'string', 'array', 'object' ), + 'anyOf' => array( + // ?orderby[]=date&orderby[]=title OR ?orderby=date,title + array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $orderby_enum, + ), + ), + /* The string case *must* come after the array case above. + * anyOf will use the first schema that validates, and the array case above will + * correctly validate ?orderby=date and ?orderby=date,title, which both look like + * strings but are handled as arrays since rest_is_array() and rest_sanitize_array() + * will pass scalars through wp_parse_list(). + * + * If the string case were first, we'd need more complex logic in ->get_items() and + * ->prepare_items_query(). + * + * This string case, then, is never used and is here only for documentation in the + * schema. + * + * Note that oneOf may seem more natural than anyOf here, but, because of this string + * parsing behavior of the array case, string inputs match both the array case and the + * string case. + */ + // ?orderby=date + array( + 'type' => 'string', + 'enum' => $orderby_enum, + ), + // ?orderby[date]=desc&orderby[title]=desc + array( + 'type' => 'object', + 'properties' => array_fill_keys( + $orderby_enum, + array( + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ) + ), + 'additionalProperties' => false, + ), + ), + 'default' => 'date', + ); + $post_type = get_post_type_object( $this->post_type ); if ( $post_type->hierarchical || 'attachment' === $this->post_type ) { diff --git a/tests/phpunit/tests/rest-api/rest-pages-controller.php b/tests/phpunit/tests/rest-api/rest-pages-controller.php index 00047cce80e08..c4bcaecae23f8 100644 --- a/tests/phpunit/tests/rest-api/rest-pages-controller.php +++ b/tests/phpunit/tests/rest-api/rest-pages-controller.php @@ -282,6 +282,218 @@ public function test_get_items_menu_order_query() { $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); } + public function test_get_items_multiple_orderby_query() { + $id2 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Bacon', + ) + ); + // Keep this ordering to make sure ids are generated in this order. + $id1 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Apples', + ) + ); + $id3 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Cats', + ) + ); + $id4 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'BBB', + 'menu_order' => 1, + ) + ); + $id5 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'AAA', + 'menu_order' => 2, + ) + ); + + // Order by 'title'. Single string orderby values are still respected. + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'orderby', 'title' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id5, $id1, $id2, $id4, $id3 ), wp_list_pluck( $data, 'id' ) ); + + // Order by 'menu order' and 'title' using comma separated values. Values may have arbitrary whitespace. + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'orderby', ' menu_order , title ' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id1, $id2, $id3, $id4, $id5 ), wp_list_pluck( $data, 'id' ) ); + + // Order by 'menu order' and 'title' using array + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'orderby', array( 'menu_order', 'title' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id1, $id2, $id3, $id4, $id5 ), wp_list_pluck( $data, 'id' ) ); + + // Can map orderby param to WP_Query param + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'order', 'asc' ); + $request->set_param( 'orderby', 'id' ); //eg WP_Query uses ID internally + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id2, $id1, $id3, $id4, $id5 ), wp_list_pluck( $data, 'id' ) ); + + // Invalid 'orderby' should error when in array. + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'orderby', array( 'title', 'bad-order' ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + public function test_get_items_multiple_orderby_and_order_query() { + $id2 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Bacon', + ) + ); + // Keep this ordering to make sure ids are generated in this order. + $id1 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Apples', + ) + ); + $id3 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Cats', + ) + ); + $id4 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'BBB', + 'menu_order' => 1, + ) + ); + $id5 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'AAA', + 'menu_order' => 2, + ) + ); + + // Order by 'menu_order' and 'title' using object + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( + 'orderby', + array( + 'menu_order' => 'asc', + 'title' => 'desc', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id3, $id2, $id1, $id4, $id5 ), wp_list_pluck( $data, 'id' ) ); + + // Can map orderby param to WP_Query param + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( + 'orderby', + array( + 'id' => 'desc', + ) + ); //eg WP_Query uses ID internally + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id5, $id4, $id3, $id1, $id2 ), wp_list_pluck( $data, 'id' ) ); + + // Invalid 'orderby' should error when in array. + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'orderby', array( array( 'title' => 'asc' ), array( 'bad-order' => 'desc' ) ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + + // Invalid when structure sort is unexpected + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'orderby', array( array( 'title' => 'bad-value' ) ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + /** + * Internal function used to test rest_query_var-orderby + */ + public function custom_rest_query_var_orderby( $value ) { + return array( + 'menu_order' => 'asc', + 'title' => 'asc', + ); + } + + public function test_rest_query_var_orderby_filter() { + // Keep this ordering to make sure ids are generated in this order. + $id1 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Apples', + ) + ); + $id2 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Bacon', + 'menu_order' => 1, + ) + ); + $id3 = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_title' => 'Cats', + ) + ); + add_filter( 'rest_query_var-orderby', array( $this, 'custom_rest_query_var_orderby' ) ); + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'orderby', array( 'title' => 'desc' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id1, $id3, $id2 ), wp_list_pluck( $data, 'id' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'orderby', array( 'title' ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id1, $id3, $id2 ), wp_list_pluck( $data, 'id' ) ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); + $request->set_param( 'orderby', 'title' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( array( $id1, $id3, $id2 ), wp_list_pluck( $data, 'id' ) ); + + remove_filter( 'rest_query_var-orderby', array( $this, 'custom_rest_query_var_orderby' ) ); + } + public function test_get_items_min_max_pages_query() { $request = new WP_REST_Request( 'GET', '/wp/v2/pages' ); $request->set_param( 'per_page', 0 );