diff --git a/src/wp-includes/class-wp-query.php b/src/wp-includes/class-wp-query.php index a6b105ef28..2e1f2f8b57 100644 --- a/src/wp-includes/class-wp-query.php +++ b/src/wp-includes/class-wp-query.php @@ -610,6 +610,7 @@ class WP_Query { 'post_parent__not_in', 'author__in', 'author__not_in', + 'search_columns', ); foreach ( $array_keys as $key ) { @@ -637,6 +638,7 @@ class WP_Query { * @since 5.1.0 Introduced the `$meta_compare_key` parameter. * @since 5.3.0 Introduced the `$meta_type_key` parameter. * @since 6.1.0 Introduced the `$update_menu_item_cache` parameter. + * @since 6.2.0 Introduced the `$search_columns` parameter. * * @param string|array $query { * Optional. Array or string of Query parameters. @@ -750,6 +752,8 @@ class WP_Query { * return posts containing 'pillow' but not 'sofa'. The * character used for exclusion can be modified using the * the 'wp_query_search_exclusion_prefix' filter. + * @type array $search_columns Array of column names to be searched. Accepts 'post_title', + * 'post_excerpt' and 'post_content'. Default empty array. * @type int $second Second of the minute. Default empty. Accepts numbers 0-59. * @type bool $sentence Whether to search by phrase. Default false. * @type bool $suppress_filters Whether to suppress filters. Default false. @@ -1410,6 +1414,32 @@ class WP_Query { $searchand = ''; $q['search_orderby_title'] = array(); + $default_search_columns = array( 'post_title', 'post_excerpt', 'post_content' ); + $search_columns = ! empty( $q['search_columns'] ) ? $q['search_columns'] : $default_search_columns; + if ( ! is_array( $search_columns ) ) { + $search_columns = array( $search_columns ); + } + + /** + * Filters the columns to search in a WP_Query search. + * + * The supported columns are `post_title`, `post_excerpt` and `post_content`. + * They are all included by default. + * + * @since 6.2.0 + * + * @param string[] $search_columns Array of column names to be searched. + * @param string $search Text being searched. + * @param WP_Query $query The current WP_Query instance. + */ + $search_columns = (array) apply_filters( 'post_search_columns', $search_columns, $q['s'], $this ); + + // Use only supported search columns. + $search_columns = array_intersect( $search_columns, $default_search_columns ); + if ( empty( $search_columns ) ) { + $search_columns = $default_search_columns; + } + /** * Filters the prefix that indicates that a search term should be excluded from results. * @@ -1439,11 +1469,17 @@ class WP_Query { $like = $n . $wpdb->esc_like( $term ) . $n; - if ( ! empty( $this->allow_query_attachment_by_filename ) ) { - $search .= $wpdb->prepare( "{$searchand}(({$wpdb->posts}.post_title $like_op %s) $andor_op ({$wpdb->posts}.post_excerpt $like_op %s) $andor_op ({$wpdb->posts}.post_content $like_op %s) $andor_op (sq1.meta_value $like_op %s))", $like, $like, $like, $like ); - } else { - $search .= $wpdb->prepare( "{$searchand}(({$wpdb->posts}.post_title $like_op %s) $andor_op ({$wpdb->posts}.post_excerpt $like_op %s) $andor_op ({$wpdb->posts}.post_content $like_op %s))", $like, $like, $like ); + $search_columns_parts = array(); + foreach ( $search_columns as $search_column ) { + $search_columns_parts[ $search_column ] = $wpdb->prepare( "({$wpdb->posts}.$search_column $like_op %s)", $like ); } + + if ( ! empty( $this->allow_query_attachment_by_filename ) ) { + $search_columns_parts['attachment'] = $wpdb->prepare( "(sq1.meta_value $like_op %s)", $like ); + } + + $search .= "$searchand(" . implode( " $andor_op ", $search_columns_parts ) . ')'; + $searchand = ' AND '; } 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 3ec9a3a134..76ca226c59 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 @@ -249,6 +249,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { 'parent' => 'post_parent__in', 'parent_exclude' => 'post_parent__not_in', 'search' => 's', + 'search_columns' => 'search_columns', 'slug' => 'post_name__in', 'status' => 'post_status', ); @@ -2891,6 +2892,16 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { ); } + $query_params['search_columns'] = array( + 'default' => array(), + 'description' => __( 'Array of column names to be searched.' ), + 'type' => 'array', + 'items' => array( + 'enum' => array( 'post_title', 'post_content', 'post_excerpt' ), + 'type' => 'string', + ), + ); + $query_params['slug'] = array( 'description' => __( 'Limit result set to posts with one or more specific slugs.' ), 'type' => 'array', diff --git a/tests/phpunit/tests/query/searchColumns.php b/tests/phpunit/tests/query/searchColumns.php new file mode 100644 index 0000000000..55fc32b29f --- /dev/null +++ b/tests/phpunit/tests/query/searchColumns.php @@ -0,0 +1,414 @@ +post->create( + array( + 'post_status' => 'publish', + 'post_title' => 'foo title', + 'post_excerpt' => 'foo excerpt', + 'post_content' => 'foo content', + ) + ); + self::$pid2 = $factory->post->create( + array( + 'post_status' => 'publish', + 'post_title' => 'bar title', + 'post_excerpt' => 'foo bar excerpt', + 'post_content' => 'foo bar content', + ) + ); + + self::$pid3 = $factory->post->create( + array( + 'post_status' => 'publish', + 'post_title' => 'baz title', + 'post_excerpt' => 'baz bar excerpt', + 'post_content' => 'baz bar foo content', + ) + ); + } + + /** + * Tests that search uses default search columns when search columns are empty. + * + * @ticket 43867 + */ + public function test_s_should_use_default_search_columns_when_empty_search_columns() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array(), + 'fields' => 'ids', + ) + ); + + $this->assertStringContainsString( 'post_title', $q->request, 'SQL request should contain post_title string.' ); + $this->assertStringContainsString( 'post_excerpt', $q->request, 'SQL request should contain post_excerpt string.' ); + $this->assertStringContainsString( 'post_content', $q->request, 'SQL request should contain post_content string.' ); + $this->assertSame( array( self::$pid1, self::$pid2, self::$pid3 ), $q->posts, 'Query results should be equal to the set.' ); + } + + /** + * Tests that search supports the `post_title` search column. + * + * @ticket 43867 + */ + public function test_s_should_support_post_title_search_column() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_title' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid1 ), $q->posts ); + } + + /** + * Tests that search supports the `post_excerpt` search column. + * + * @ticket 43867 + */ + public function test_s_should_support_post_excerpt_search_column() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_excerpt' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid1, self::$pid2 ), $q->posts ); + } + + /** + * Tests that search supports the `post_content` search column. + * + * @ticket 43867 + */ + public function test_s_should_support_post_content_search_column() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_content' ), + 'fields' => 'ids', + ) + ); + $this->assertSame( array( self::$pid1, self::$pid2, self::$pid3 ), $q->posts ); + } + + /** + * Tests that search supports the `post_title` and `post_excerpt` search columns together. + * + * @ticket 43867 + */ + public function test_s_should_support_post_title_and_post_excerpt_search_columns() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_title', 'post_excerpt' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid1, self::$pid2 ), $q->posts ); + } + + /** + * Tests that search supports the `post_title` and `post_content` search columns together. + * + * @ticket 43867 + */ + public function test_s_should_support_post_title_and_post_content_search_columns() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_title', 'post_content' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid1, self::$pid2, self::$pid3 ), $q->posts ); + } + + /** + * Tests that search supports the `post_excerpt` and `post_content` search columns together. + * + * @ticket 43867 + */ + public function test_s_should_support_post_excerpt_and_post_content_search_columns() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_excerpt', 'post_content' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid1, self::$pid2, self::$pid3 ), $q->posts ); + } + + /** + * Tests that search supports the `post_title`, `post_excerpt` and `post_content` search columns together. + * + * @ticket 43867 + */ + public function test_s_should_support_post_title_and_post_excerpt_and_post_content_search_columns() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_title', 'post_excerpt', 'post_content' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid1, self::$pid2, self::$pid3 ), $q->posts ); + } + + /** + * Tests that search uses default search columns when using a non-existing search column. + * + * @ticket 43867 + */ + public function test_s_should_use_default_search_columns_when_using_non_existing_search_column() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_non_existing_column' ), + 'fields' => 'ids', + ) + ); + + $this->assertStringContainsString( 'post_title', $q->request, 'SQL request should contain post_title string.' ); + $this->assertStringContainsString( 'post_excerpt', $q->request, 'SQL request should contain post_excerpt string.' ); + $this->assertStringContainsString( 'post_content', $q->request, 'SQL request should contain post_content string.' ); + $this->assertSame( array( self::$pid1, self::$pid2, self::$pid3 ), $q->posts, 'Query results should be equal to the set.' ); + } + + /** + * Tests that search ignores a non-existing search column when used together with a supported one. + * + * @ticket 43867 + */ + public function test_s_should_ignore_non_existing_search_column_when_used_with_supported_one() { + $q = new WP_Query( + array( + 's' => 'foo', + 'search_columns' => array( 'post_title', 'post_non_existing_column' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid1 ), $q->posts ); + } + + /** + * Tests that search supports search columns when searching multiple terms. + * + * @ticket 43867 + */ + public function test_s_should_support_search_columns_when_searching_multiple_terms() { + $q = new WP_Query( + array( + 's' => 'foo bar', + 'search_columns' => array( 'post_content' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid2, self::$pid3 ), $q->posts ); + } + + /** + * Tests that search supports search columns when searching for a sentence. + * + * @ticket 43867 + */ + public function test_s_should_support_search_columns_when_sentence_true() { + $q = new WP_Query( + array( + 's' => 'bar foo', + 'search_columns' => array( 'post_content' ), + 'sentence' => true, + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid3 ), $q->posts ); + } + + /** + * Tests that search supports search columns when searching for a sentence. + * + * @ticket 43867 + */ + public function test_s_should_support_search_columns_when_sentence_false() { + $q = new WP_Query( + array( + 's' => 'bar foo', + 'search_columns' => array( 'post_content' ), + 'sentence' => false, + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid2, self::$pid3 ), $q->posts ); + } + + /** + * Tests that search supports search columns when using term exclusion. + * + * @ticket 43867 + */ + public function test_s_should_support_search_columns_when_searching_with_term_exclusion() { + $q = new WP_Query( + array( + 's' => 'bar -baz', + 'search_columns' => array( 'post_excerpt', 'post_content' ), + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid2 ), $q->posts ); + } + + /** + * Tests that search columns is filterable with the `post_search_columns` filter. + * + * @ticket 43867 + */ + public function test_search_columns_should_be_filterable() { + add_filter( 'post_search_columns', array( $this, 'post_supported_search_column' ), 10, 3 ); + $q = new WP_Query( + array( + 's' => 'foo', + 'fields' => 'ids', + ) + ); + + $this->assertSame( array( self::$pid1 ), $q->posts ); + } + + /** + * Filter callback that sets a supported search column. + * + * @param string[] $search_columns Array of column names to be searched. + * @param string $search Text being searched. + * @param WP_Query $wp_query The current WP_Query instance. + * @return string[] $search_columns Array of column names to be searched. + */ + public function post_supported_search_column( $search_columns, $search, $wp_query ) { + $search_columns = array( 'post_title' ); + return $search_columns; + } + + /** + * Tests that search columns ignores non-supported search columns from the `post_search_columns` filter. + * + * @ticket 43867 + */ + public function test_search_columns_should_not_be_filterable_with_non_supported_search_columns() { + add_filter( 'post_search_columns', array( $this, 'post_non_supported_search_column' ), 10, 3 ); + $q = new WP_Query( + array( + 's' => 'foo', + 'fields' => 'ids', + ) + ); + + $this->assertStringNotContainsString( 'post_name', $q->request, "SQL request shouldn't contain post_name string." ); + $this->assertSame( array( self::$pid1, self::$pid2, self::$pid3 ), $q->posts, 'Query results should be equal to the set.' ); + } + + /** + * Filter callback that sets an existing but non-supported search column. + * + * @param string[] $search_columns Array of column names to be searched. + * @param string $search Text being searched. + * @param WP_Query $wp_query The current WP_Query instance. + * @return string[] $search_columns Array of column names to be searched. + */ + public function post_non_supported_search_column( $search_columns, $search, $wp_query ) { + $search_columns = array( 'post_name' ); + return $search_columns; + } + + /** + * Tests that search columns ignores non-existing search columns from the `post_search_columns` filter. + * + * @ticket 43867 + */ + public function test_search_columns_should_not_be_filterable_with_non_existing_search_column() { + add_filter( 'post_search_columns', array( $this, 'post_non_existing_search_column' ), 10, 3 ); + $q = new WP_Query( + array( + 's' => 'foo', + 'fields' => 'ids', + ) + ); + + $this->assertStringNotContainsString( 'post_non_existing_column', $q->request, "SQL request shouldn't contain post_non_existing_column string." ); + $this->assertSame( array( self::$pid1, self::$pid2, self::$pid3 ), $q->posts, 'Query results should be equal to the set.' ); + } + + /** + * Filter callback that sets a non-existing search column. + * + * @param string[] $search_columns Array of column names to be searched. + * @param string $search Text being searched. + * @param WP_Query $wp_query The current WP_Query instance. + * @return string[] $search_columns Array of column names to be searched. + */ + public function post_non_existing_search_column( $search_columns, $search, $wp_query ) { + $search_columns = array( 'post_non_existing_column' ); + return $search_columns; + } + +} diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 0a37e44a95..7794ee417e 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -230,6 +230,7 @@ class WP_Test_REST_Attachments_Controller extends WP_Test_REST_Post_Type_Control 'parent_exclude', 'per_page', 'search', + 'search_columns', 'slug', 'status', ), diff --git a/tests/phpunit/tests/rest-api/rest-pages-controller.php b/tests/phpunit/tests/rest-api/rest-pages-controller.php index a6a9cfa3f4..615e840210 100644 --- a/tests/phpunit/tests/rest-api/rest-pages-controller.php +++ b/tests/phpunit/tests/rest-api/rest-pages-controller.php @@ -86,6 +86,7 @@ class WP_Test_REST_Pages_Controller extends WP_Test_REST_Post_Type_Controller_Te 'parent_exclude', 'per_page', 'search', + 'search_columns', 'slug', 'status', ), diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index 2a378c84c0..fb9ac378e1 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -204,6 +204,7 @@ class WP_Test_REST_Posts_Controller extends WP_Test_REST_Post_Type_Controller_Te 'page', 'per_page', 'search', + 'search_columns', 'slug', 'status', 'sticky', @@ -1527,6 +1528,38 @@ class WP_Test_REST_Posts_Controller extends WP_Test_REST_Post_Type_Controller_Te $this->assertPostsWhere( " AND {posts}.ID NOT IN ($id3) AND {posts}.post_type = 'post' AND (({posts}.post_status = 'publish'))" ); } + /** + * Tests that Rest Post controller supports search columns. + * + * @ticket 43867 + * @covers WP_REST_Posts_Controller::get_items + */ + public function test_get_items_with_custom_search_columns() { + $id1 = self::factory()->post->create( + array( + 'post_title' => 'Title contain foo and bar', + 'post_content' => 'Content contain bar', + 'post_excerpt' => 'Excerpt contain baz', + ) + ); + $id2 = self::factory()->post->create( + array( + 'post_title' => 'Title contain baz', + 'post_content' => 'Content contain foo and bar', + 'post_excerpt' => 'Excerpt contain foo, bar and baz', + ) + ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $request->set_param( 'search', 'foo bar' ); + $request->set_param( 'search_columns', array( 'post_title' ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status(), 'Response should have a status code 200.' ); + $data = $response->get_data(); + $this->assertCount( 1, $data, 'Response should contain one result.' ); + $this->assertSame( $id1, $data[0]['id'], 'Result should match expected value.' ); + } + /** * @ticket 55592 * diff --git a/tests/phpunit/tests/rest-api/wpRestMenuItemsController.php b/tests/phpunit/tests/rest-api/wpRestMenuItemsController.php index 22ca3a830c..cda306409b 100644 --- a/tests/phpunit/tests/rest-api/wpRestMenuItemsController.php +++ b/tests/phpunit/tests/rest-api/wpRestMenuItemsController.php @@ -147,6 +147,7 @@ class Tests_REST_WpRestMenuItemsController extends WP_Test_REST_Post_Type_Contro $this->assertArrayHasKey( 'page', $properties ); $this->assertArrayHasKey( 'per_page', $properties ); $this->assertArrayHasKey( 'search', $properties ); + $this->assertArrayHasKey( 'search_columns', $properties ); $this->assertArrayHasKey( 'slug', $properties ); $this->assertArrayHasKey( 'status', $properties ); } diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 1dde2a8d48..6ee471a87b 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -395,6 +395,20 @@ mockedApiResponse.Schema = { ], "required": false }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, "slug": { "description": "Limit result set to posts with one or more specific slugs.", "type": "array", @@ -1752,6 +1766,20 @@ mockedApiResponse.Schema = { "default": [], "required": false }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, "slug": { "description": "Limit result set to posts with one or more specific slugs.", "type": "array", @@ -2833,6 +2861,20 @@ mockedApiResponse.Schema = { "default": [], "required": false }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, "slug": { "description": "Limit result set to posts with one or more specific slugs.", "type": "array", @@ -3543,6 +3585,20 @@ mockedApiResponse.Schema = { ], "required": false }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, "slug": { "description": "Limit result set to posts with one or more specific slugs.", "type": "array", @@ -4339,6 +4395,20 @@ mockedApiResponse.Schema = { ], "required": false }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, "slug": { "description": "Limit result set to posts with one or more specific slugs.", "type": "array", @@ -6490,6 +6560,20 @@ mockedApiResponse.Schema = { ], "required": false }, + "search_columns": { + "default": [], + "description": "Array of column names to be searched.", + "type": "array", + "items": { + "enum": [ + "post_title", + "post_content", + "post_excerpt" + ], + "type": "string" + }, + "required": false + }, "slug": { "description": "Limit result set to posts with one or more specific slugs.", "type": "array",