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 dd0269ebf6..066e4181d6 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 @@ -280,35 +280,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { } } - $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); - - if ( ! empty( $request['tax_relation'] ) ) { - $args['tax_query'] = array( 'relation' => $request['tax_relation'] ); - } - - foreach ( $taxonomies as $taxonomy ) { - $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; - $tax_exclude = $base . '_exclude'; - - if ( ! empty( $request[ $base ] ) ) { - $args['tax_query'][] = array( - 'taxonomy' => $taxonomy->name, - 'field' => 'term_id', - 'terms' => $request[ $base ], - 'include_children' => false, - ); - } - - if ( ! empty( $request[ $tax_exclude ] ) ) { - $args['tax_query'][] = array( - 'taxonomy' => $taxonomy->name, - 'field' => 'term_id', - 'terms' => $request[ $tax_exclude ], - 'include_children' => false, - 'operator' => 'NOT IN', - ); - } - } + $args = $this->prepare_tax_query( $args, $request ); // Force the post_type argument, since it's not a user input variable. $args['post_type'] = $this->post_type; @@ -2799,39 +2771,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { 'sanitize_callback' => array( $this, 'sanitize_post_statuses' ), ); - $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); - - if ( ! empty( $taxonomies ) ) { - $query_params['tax_relation'] = array( - 'description' => __( 'Limit result set based on relationship between multiple taxonomies.' ), - 'type' => 'string', - 'enum' => array( 'AND', 'OR' ), - ); - } - - foreach ( $taxonomies as $taxonomy ) { - $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; - - $query_params[ $base ] = array( - /* translators: %s: Taxonomy name. */ - 'description' => sprintf( __( 'Limit result set to all items that have the specified term assigned in the %s taxonomy.' ), $base ), - 'type' => 'array', - 'items' => array( - 'type' => 'integer', - ), - 'default' => array(), - ); - - $query_params[ $base . '_exclude' ] = array( - /* translators: %s: Taxonomy name. */ - 'description' => sprintf( __( 'Limit result set to all items except those that have the specified term assigned in the %s taxonomy.' ), $base ), - 'type' => 'array', - 'items' => array( - 'type' => 'integer', - ), - 'default' => array(), - ); - } + $query_params = $this->prepare_taxonomy_limit_schema( $query_params ); if ( 'post' === $this->post_type ) { $query_params['sticky'] = array( @@ -2899,4 +2839,168 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { return $statuses; } + + /** + * Prepares the 'tax_query' for a collection of posts. + * + * @since 5.7.0 + * + * @param array $args WP_Query arguments. + * @param WP_REST_Request $request Full details about the request. + * @return array Updated query arguments. + */ + private function prepare_tax_query( array $args, WP_REST_Request $request ) { + $relation = $request['tax_relation']; + + if ( $relation ) { + $args['tax_query'] = array( 'relation' => $relation ); + } + + $taxonomies = wp_list_filter( + get_object_taxonomies( $this->post_type, 'objects' ), + array( 'show_in_rest' => true ) + ); + + foreach ( $taxonomies as $taxonomy ) { + $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; + + $tax_include = $request[ $base ]; + $tax_exclude = $request[ $base . '_exclude' ]; + + if ( $tax_include ) { + $terms = array(); + $include_children = false; + + if ( rest_is_array( $tax_include ) ) { + $terms = $tax_include; + } elseif ( rest_is_object( $tax_include ) ) { + $terms = empty( $tax_include['terms'] ) ? array() : $tax_include['terms']; + $include_children = ! empty( $tax_include['include_children'] ); + } + + if ( $terms ) { + $args['tax_query'][] = array( + 'taxonomy' => $taxonomy->name, + 'field' => 'term_id', + 'terms' => $terms, + 'include_children' => $include_children, + ); + } + } + + if ( $tax_exclude ) { + $terms = array(); + $include_children = false; + + if ( rest_is_array( $tax_exclude ) ) { + $terms = $tax_exclude; + } elseif ( rest_is_object( $tax_exclude ) ) { + $terms = empty( $tax_exclude['terms'] ) ? array() : $tax_exclude['terms']; + $include_children = ! empty( $tax_exclude['include_children'] ); + } + + if ( $terms ) { + $args['tax_query'][] = array( + 'taxonomy' => $taxonomy->name, + 'field' => 'term_id', + 'terms' => $terms, + 'include_children' => $include_children, + 'operator' => 'NOT IN', + ); + } + } + } + + return $args; + } + + /** + * Prepares the collection schema for including and excluding items by terms. + * + * @since 5.7.0 + * + * @param array $query_params Collection schema. + * @return array Updated schema. + */ + private function prepare_taxonomy_limit_schema( array $query_params ) { + $taxonomies = wp_list_filter( get_object_taxonomies( $this->post_type, 'objects' ), array( 'show_in_rest' => true ) ); + + if ( ! $taxonomies ) { + return $query_params; + } + + $query_params['tax_relation'] = array( + 'description' => __( 'Limit result set based on relationship between multiple taxonomies.' ), + 'type' => 'string', + 'enum' => array( 'AND', 'OR' ), + ); + + $limit_schema = array( + 'type' => array( 'object', 'array' ), + 'oneOf' => array( + array( + 'title' => __( 'Term ID List' ), + 'description' => __( 'Match terms with the listed IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + ), + array( + 'title' => __( 'Term ID Taxonomy Query' ), + 'description' => __( 'Perform an advanced term query.' ), + 'type' => 'object', + 'properties' => array( + 'terms' => array( + 'description' => __( 'Term IDs.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + ), + 'default' => array(), + ), + 'include_children' => array( + 'description' => __( 'Whether to include child terms in the terms limiting the result set.' ), + 'type' => 'boolean', + 'default' => false, + ), + ), + 'additionalProperties' => false, + ), + ), + ); + + $include_schema = array_merge( + array( + /* translators: %s: Taxonomy name. */ + 'description' => __( 'Limit result set to items with specific terms assigned in the %s taxonomy.' ), + ), + $limit_schema + ); + $exclude_schema = array_merge( + array( + /* translators: %s: Taxonomy name. */ + 'description' => __( 'Limit result set to items except those with specific terms assigned in the %s taxonomy.' ), + ), + $limit_schema + ); + + foreach ( $taxonomies as $taxonomy ) { + $base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; + $base_exclude = $base . '_exclude'; + + $query_params[ $base ] = $include_schema; + $query_params[ $base ]['description'] = sprintf( $query_params[ $base ]['description'], $base ); + + $query_params[ $base_exclude ] = $exclude_schema; + $query_params[ $base_exclude ]['description'] = sprintf( $query_params[ $base_exclude ]['description'], $base ); + + if ( ! $taxonomy->hierarchical ) { + unset( $query_params[ $base ]['oneOf'][1]['properties']['include_children'] ); + unset( $query_params[ $base_exclude ]['oneOf'][1]['properties']['include_children'] ); + } + } + + return $query_params; + } } diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index f6470f5241..39d1d77e9a 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -1115,6 +1115,185 @@ class WP_Test_REST_Posts_Controller extends WP_Test_REST_Post_Type_Controller_Te $this->assertSame( $id1, $data[2]['id'] ); } + /** + * @ticket 39494 + */ + public function test_get_items_with_category_including_children() { + $taxonomy = get_taxonomy( 'category' ); + + $cat1 = static::factory()->term->create( array( 'taxonomy' => $taxonomy->name ) ); + $cat2 = static::factory()->term->create( + array( + 'taxonomy' => $taxonomy->name, + 'parent' => $cat1, + ) + ); + + $post_ids = array( + static::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_category' => array( $cat1 ), + ) + ), + static::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_category' => array( $cat2 ), + ) + ), + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( + $taxonomy->rest_base, + array( + 'terms' => array( $cat1 ), + 'include_children' => true, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEqualSets( $post_ids, array_column( $data, 'id' ) ); + } + + /** + * @ticket 39494 + */ + public function test_get_items_with_category_excluding_children() { + $taxonomy = get_taxonomy( 'category' ); + + $cat1 = static::factory()->term->create( array( 'taxonomy' => $taxonomy->name ) ); + $cat2 = static::factory()->term->create( + array( + 'taxonomy' => $taxonomy->name, + 'parent' => $cat1, + ) + ); + + $post_ids = array( + static::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_category' => array( $cat1 ), + ) + ), + static::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_category' => array( $cat2 ), + ) + ), + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( + $taxonomy->rest_base, + array( + 'terms' => array( $cat1 ), + 'include_children' => false, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertCount( 1, $data ); + $this->assertEquals( $post_ids[0], $data[0]['id'] ); + } + + /** + * @ticket 39494 + */ + public function test_get_items_without_category_or_its_children() { + $taxonomy = get_taxonomy( 'category' ); + + $cat1 = static::factory()->term->create( array( 'taxonomy' => $taxonomy->name ) ); + $cat2 = static::factory()->term->create( + array( + 'taxonomy' => $taxonomy->name, + 'parent' => $cat1, + ) + ); + + $post_ids = array( + static::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_category' => array( $cat1 ), + ) + ), + static::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_category' => array( $cat2 ), + ) + ), + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( + $taxonomy->rest_base . '_exclude', + array( + 'terms' => array( $cat1 ), + 'include_children' => true, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEmpty( + array_intersect( + $post_ids, + array_column( $data, 'id' ) + ) + ); + } + + /** + * @ticket 39494 + */ + public function test_get_items_without_category_but_allowing_its_children() { + $taxonomy = get_taxonomy( 'category' ); + + $cat1 = static::factory()->term->create( array( 'taxonomy' => $taxonomy->name ) ); + $cat2 = static::factory()->term->create( + array( + 'taxonomy' => $taxonomy->name, + 'parent' => $cat1, + ) + ); + + $p1 = static::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_category' => array( $cat1 ), + ) + ); + $p2 = static::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_category' => array( $cat2 ), + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( + $taxonomy->rest_base . '_exclude', + array( + 'terms' => array( $cat1 ), + 'include_children' => false, + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $found_ids = array_column( $data, 'id' ); + + $this->assertNotContains( $p1, $found_ids ); + $this->assertContains( $p2, $found_ids ); + } + /** * @ticket 44326 */ diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index bbfafc5af9..beae0fbecd 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -427,39 +427,149 @@ mockedApiResponse.Schema = { "required": false }, "categories": { - "description": "Limit result set to all items that have the specified term assigned in the categories taxonomy.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], + "description": "Limit result set to items with specific terms assigned in the categories taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "include_children": { + "description": "Whether to include child terms in the terms limiting the result set.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + ], "required": false }, "categories_exclude": { - "description": "Limit result set to all items except those that have the specified term assigned in the categories taxonomy.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], + "description": "Limit result set to items except those with specific terms assigned in the categories taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + }, + "include_children": { + "description": "Whether to include child terms in the terms limiting the result set.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + ], "required": false }, "tags": { - "description": "Limit result set to all items that have the specified term assigned in the tags taxonomy.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], + "description": "Limit result set to items with specific terms assigned in the tags taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + } + }, + "additionalProperties": false + } + ], "required": false }, "tags_exclude": { - "description": "Limit result set to all items except those that have the specified term assigned in the tags taxonomy.", - "type": "array", - "items": { - "type": "integer" - }, - "default": [], + "description": "Limit result set to items except those with specific terms assigned in the tags taxonomy.", + "type": [ + "object", + "array" + ], + "oneOf": [ + { + "title": "Term ID List", + "description": "Match terms with the listed IDs.", + "type": "array", + "items": { + "type": "integer" + } + }, + { + "title": "Term ID Taxonomy Query", + "description": "Perform an advanced term query.", + "type": "object", + "properties": { + "terms": { + "description": "Term IDs.", + "type": "array", + "items": { + "type": "integer" + }, + "default": [] + } + }, + "additionalProperties": false + } + ], "required": false }, "sticky": { @@ -3128,8 +3238,95 @@ mockedApiResponse.Schema = { "POST" ], "args": { + "src": { + "description": "URL to the edited image file.", + "type": "string", + "format": "uri", + "required": true + }, + "modifiers": { + "description": "Array of image edits.", + "type": "array", + "minItems": 1, + "items": { + "description": "Image edit.", + "type": "object", + "required": [ + "type", + "args" + ], + "oneOf": [ + { + "title": "Rotation", + "properties": { + "type": { + "description": "Rotation type.", + "type": "string", + "enum": [ + "rotate" + ] + }, + "args": { + "description": "Rotation arguments.", + "type": "object", + "required": [ + "angle" + ], + "properties": { + "angle": { + "description": "Angle to rotate clockwise in degrees.", + "type": "number" + } + } + } + } + }, + { + "title": "Crop", + "properties": { + "type": { + "description": "Crop type.", + "type": "string", + "enum": [ + "crop" + ] + }, + "args": { + "description": "Crop arguments.", + "type": "object", + "required": [ + "left", + "top", + "width", + "height" + ], + "properties": { + "left": { + "description": "Horizontal position from the left to begin the crop as a percentage of the image width.", + "type": "number" + }, + "top": { + "description": "Vertical position from the top to begin the crop as a percentage of the image height.", + "type": "number" + }, + "width": { + "description": "Width of the crop as a percentage of the image width.", + "type": "number" + }, + "height": { + "description": "Height of the crop as a percentage of the image height.", + "type": "number" + } + } + } + } + } + ] + }, + "required": false + }, "rotation": { - "description": "The amount to rotate the image clockwise in degrees.", + "description": "The amount to rotate the image clockwise in degrees. DEPRECATED: Use `modifiers` instead.", "type": "integer", "minimum": 0, "exclusiveMinimum": true, @@ -3138,38 +3335,32 @@ mockedApiResponse.Schema = { "required": false }, "x": { - "description": "As a percentage of the image, the x position to start the crop from.", + "description": "As a percentage of the image, the x position to start the crop from. DEPRECATED: Use `modifiers` instead.", "type": "number", "minimum": 0, "maximum": 100, "required": false }, "y": { - "description": "As a percentage of the image, the y position to start the crop from.", + "description": "As a percentage of the image, the y position to start the crop from. DEPRECATED: Use `modifiers` instead.", "type": "number", "minimum": 0, "maximum": 100, "required": false }, "width": { - "description": "As a percentage of the image, the width to crop the image to.", + "description": "As a percentage of the image, the width to crop the image to. DEPRECATED: Use `modifiers` instead.", "type": "number", "minimum": 0, "maximum": 100, "required": false }, "height": { - "description": "As a percentage of the image, the height to crop the image to.", + "description": "As a percentage of the image, the height to crop the image to. DEPRECATED: Use `modifiers` instead.", "type": "number", "minimum": 0, "maximum": 100, "required": false - }, - "src": { - "description": "URL to the edited image file.", - "type": "string", - "format": "uri", - "required": true } } }