diff --git a/plugins/woocommerce/changelog/api-28508-search-including-sku b/plugins/woocommerce/changelog/api-28508-search-including-sku new file mode 100644 index 00000000000..a9ba0a962b1 --- /dev/null +++ b/plugins/woocommerce/changelog/api-28508-search-including-sku @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adds a `search_sku` parameter to the v3 products endpoint. Allows for partial match search of the product SKU field. diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php index c2c03690b87..6c50a1cea48 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php @@ -25,6 +25,15 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { */ protected $namespace = 'wc/v3'; + /** + * A string to inject into a query to do a partial match SKU search. + * + * See prepare_objects_query() + * + * @var string + */ + private $search_sku_in_product_lookup_table = ''; + /** * Get the images for a product or product variation. * @@ -144,22 +153,32 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { ); } - // Filter by sku. - if ( ! empty( $request['sku'] ) ) { - $skus = explode( ',', $request['sku'] ); - // Include the current string as a SKU too. - if ( 1 < count( $skus ) ) { - $skus[] = $request['sku']; + if ( wc_product_sku_enabled() ) { + // Do a partial match for a sku. Supercedes sku parameter that does exact matching. + if ( ! empty( $request['search_sku'] ) ) { + // Store this for use in the query clause filters. + $this->search_sku_in_product_lookup_table = $request['search_sku']; + + unset( $request['sku'] ); } - $args['meta_query'] = $this->add_meta_query( // WPCS: slow query ok. - $args, - array( - 'key' => '_sku', - 'value' => $skus, - 'compare' => 'IN', - ) - ); + // Filter by sku. + if ( ! empty( $request['sku'] ) ) { + $skus = explode( ',', $request['sku'] ); + // Include the current string as a SKU too. + if ( 1 < count( $skus ) ) { + $skus[] = $request['sku']; + } + + $args['meta_query'] = $this->add_meta_query( // WPCS: slow query ok. + $args, + array( + 'key' => '_sku', + 'value' => $skus, + 'compare' => 'IN', + ) + ); + } } // Filter by tax class. @@ -201,7 +220,7 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { } // Force the post_type argument, since it's not a user input variable. - if ( ! empty( $request['sku'] ) ) { + if ( ! empty( $request['sku'] ) || ! empty( $request['search_sku'] ) ) { $args['post_type'] = array( 'product', 'product_variation' ); } else { $args['post_type'] = $this->post_type; @@ -217,6 +236,61 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { return $args; } + /** + * Get objects. + * + * @param array $query_args Query args. + * @return array + */ + public function get_objects( $query_args ) { + // Add filters for search criteria in product postmeta via the lookup table. + if ( ! empty( $this->search_sku_in_product_lookup_table ) ) { + add_filter( 'posts_join', array( $this, 'add_search_criteria_to_wp_query_join' ) ); + add_filter( 'posts_where', array( $this, 'add_search_criteria_to_wp_query_where' ) ); + } + + $result = parent::get_objects( $query_args ); + + // Remove filters for search criteria in product postmeta via the lookup table. + if ( ! empty( $this->search_sku_in_product_lookup_table ) ) { + remove_filter( 'posts_join', array( $this, 'add_search_criteria_to_wp_query_join' ) ); + remove_filter( 'posts_where', array( $this, 'add_search_criteria_to_wp_query_where' ) ); + + $this->search_sku_in_product_lookup_table = ''; + } + return $result; + } + + /** + * Join `wc_product_meta_lookup` table when SKU search query is present. + * + * @param string $join Join clause used to search posts. + * @return string + */ + public function add_search_criteria_to_wp_query_join( $join ) { + global $wpdb; + if ( ! empty( $this->search_sku_in_product_lookup_table ) && ! strstr( $join, 'wc_product_meta_lookup' ) ) { + $join .= " LEFT JOIN $wpdb->wc_product_meta_lookup wc_product_meta_lookup + ON $wpdb->posts.ID = wc_product_meta_lookup.product_id "; + } + return $join; + } + + /** + * Add a where clause for matching the SKU field. + * + * @param string $where Where clause used to search posts. + * @return string + */ + public function add_search_criteria_to_wp_query_where( $where ) { + global $wpdb; + if ( ! empty( $this->search_sku_in_product_lookup_table ) ) { + $like_search = '%' . $wpdb->esc_like( $this->search_sku_in_product_lookup_table ) . '%'; + $where .= ' AND ' . $wpdb->prepare( '(wc_product_meta_lookup.sku LIKE %s)', $like_search ); + } + return $where; + } + /** * Set product images. * @@ -1368,6 +1442,13 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { 'validate_callback' => 'rest_validate_request_arg', ); + $params['search_sku'] = array( + 'description' => __( 'Limit results to those with a SKU that partial matches a string.', 'woocommerce' ), + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => 'rest_validate_request_arg', + ); + return $params; } diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php index 6f220b9ff7b..a9a343ef491 100644 --- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php +++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php @@ -5,6 +5,57 @@ * Product Controller tests for V3 REST API. */ class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case { + /** + * @var WC_Product_Simple[] + */ + protected static $products = array(); + + /** + * Create products for tests. + * + * @return void + */ + public static function wpSetUpBeforeClass() { + self::$products[] = WC_Helper_Product::create_simple_product( + true, + array( + 'name' => 'Pancake', + 'sku' => 'pancake-1', + ) + ); + self::$products[] = WC_Helper_Product::create_simple_product( + true, + array( + 'name' => 'Waffle 1', + 'sku' => 'pancake-2', + ) + ); + self::$products[] = WC_Helper_Product::create_simple_product( + true, + array( + 'name' => 'French Toast', + 'sku' => 'waffle-2', + ) + ); + self::$products[] = WC_Helper_Product::create_simple_product( + true, + array( + 'name' => 'Waffle 3', + 'sku' => 'waffle-3', + ) + ); + } + + /** + * Clean up products after tests. + * + * @return void + */ + public static function wpTearDownAfterClass() { + foreach ( self::$products as $product ) { + WC_Helper_Product::delete_product( $product->get_id() ); + } + } /** * Setup our test server, endpoints, and user info. @@ -147,4 +198,116 @@ class WC_REST_Products_Controller_Tests extends WC_REST_Unit_Test_Case { $this->assertContains( $field, $response_fields, "Field $field was expected but not present in product API response." ); } } + + /** + * Test that the `search` parameter does partial matching in the product name, but not the SKU. + * + * @return void + */ + public function test_products_search_with_search_param_only() { + wp_set_current_user( $this->user ); + + $request = new WP_REST_Request( 'GET', '/wc/v3/products' ); + $request->set_query_params( + array( + 'search' => 'waffle', + 'order' => 'asc', + 'orderby' => 'id', + ) + ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $response_products = $response->get_data(); + + $this->assertEquals( 2, count( $response_products ) ); + $this->assertEquals( $response_products[0]['name'], 'Waffle 1' ); + $this->assertEquals( $response_products[0]['sku'], 'pancake-2' ); + $this->assertEquals( $response_products[1]['name'], 'Waffle 3' ); + $this->assertEquals( $response_products[1]['sku'], 'waffle-3' ); + } + + /** + * Test that the `search_sku` parameter does partial matching in the product SKU, but not the name. + * + * @return void + */ + public function test_products_search_with_search_sku_param_only() { + wp_set_current_user( $this->user ); + + $request = new WP_REST_Request( 'GET', '/wc/v3/products' ); + $request->set_query_params( + array( + 'search_sku' => 'waffle', + 'order' => 'asc', + 'orderby' => 'id', + ) + ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $response_products = $response->get_data(); + + $this->assertEquals( 2, count( $response_products ) ); + $this->assertEquals( $response_products[0]['name'], 'French Toast' ); + $this->assertEquals( $response_products[0]['sku'], 'waffle-2' ); + $this->assertEquals( $response_products[1]['name'], 'Waffle 3' ); + $this->assertEquals( $response_products[1]['sku'], 'waffle-3' ); + } + + /** + * Test that using the `search` and `search_sku` parameters together only matches when both match. + * + * @return void + */ + public function test_products_search_with_search_and_search_sku_param() { + wp_set_current_user( $this->user ); + + $request = new WP_REST_Request( 'GET', '/wc/v3/products' ); + $request->set_query_params( + array( + 'search' => 'waffle', + 'search_sku' => 'waffle', + 'order' => 'asc', + 'orderby' => 'id', + ) + ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $response_products = $response->get_data(); + + $this->assertEquals( 1, count( $response_products ) ); + $this->assertEquals( $response_products[0]['name'], 'Waffle 3' ); + $this->assertEquals( $response_products[0]['sku'], 'waffle-3' ); + } + + /** + * Test that the `search_sku` parameter does nothing when product SKUs are disabled. + * + * @return void + */ + public function test_products_search_with_search_sku_when_skus_disabled() { + wp_set_current_user( $this->user ); + + add_filter( 'wc_product_sku_enabled', '__return_false' ); + + $request = new WP_REST_Request( 'GET', '/wc/v3/products' ); + $request->set_query_params( + array( + 'search' => 'waffle', + 'search_sku' => 'waffle', + 'order' => 'asc', + 'orderby' => 'id', + ) + ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $response_products = $response->get_data(); + + $this->assertEquals( 2, count( $response_products ) ); + $this->assertEquals( $response_products[0]['name'], 'Waffle 1' ); + $this->assertEquals( $response_products[0]['sku'], 'pancake-2' ); + $this->assertEquals( $response_products[1]['name'], 'Waffle 3' ); + $this->assertEquals( $response_products[1]['sku'], 'waffle-3' ); + + remove_filter( 'wc_product_sku_enabled', '__return_false' ); + } }