| @@ -21,8 +21,10 @@ | ||
| 21 | 21 | use WCPOS\WooCommercePOS\API\Product_Search; |
| 22 | 22 | use WCPOS\WooCommercePOS\Logger; |
| 23 | 23 | use WCPOS\WooCommercePOS\Services\Barcode_Field; |
| 24 | 24 | use WCPOS\WooCommercePOS\Services\Variable_Price_Range; |
| 25 | +use WCPOS\WooCommercePOS\Sync\Collection_Rules; | |
| 26 | +use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan; | |
| 25 | 27 | use WCPOS\WooCommercePOS\Sync\Pos_Visibility; |
| 26 | 28 | use WP_Error; |
| 27 | 29 | use WP_Query; |
| 28 | 30 | use WP_REST_Request; |
| @@ -60,8 +62,19 @@ | ||
| 60 | 62 | */ |
| 61 | 63 | protected $wcpos_request; |
| 62 | 64 | |
| 63 | 65 | /** |
| 66 | + * Request keys the product Collection Rules plan reads on this lane. | |
| 67 | + * | |
| 68 | + * @var array | |
| 69 | + */ | |
| 70 | + private const WCPOS_SORT_PARAM_MAP = array( | |
| 71 | + 'orderby' => 'orderby', | |
| 72 | + 'order' => 'order', | |
| 73 | + 'search' => 'search', | |
| 74 | + ); | |
| 75 | + | |
| 76 | + /** | |
| 64 | 77 | * Memoized parent collection params. |
| 65 | 78 | * |
| 66 | 79 | * @var array|null |
| 67 | 80 | */ |
| @@ -82,10 +95,8 @@ | ||
| 82 | 95 | |
| 83 | 96 | add_filter( 'woocommerce_rest_prepare_product_object', array( $this, 'wcpos_product_response' ), 10, 3 ); |
| 84 | 97 | add_action( 'woocommerce_rest_insert_product_object', array( $this, 'wcpos_insert_product_object' ), 10, 3 ); |
| 85 | 98 | add_filter( 'woocommerce_rest_product_object_query', array( $this, 'wcpos_product_query' ), 10, 2 ); |
| 86 | - add_filter( 'posts_search', array( $this, 'wcpos_posts_search' ), 10, 2 ); | |
| 87 | - add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 ); | |
| 88 | 99 | |
| 89 | 100 | /* |
| 90 | 101 | * Check if the request is for all products and if the 'posts_per_page' is set to -1. |
| 91 | 102 | * Optimised query for getting all product IDs. |
| @@ -97,8 +108,23 @@ | ||
| 97 | 108 | return $dispatch_result; |
| 98 | 109 | } |
| 99 | 110 | |
| 100 | 111 | /** |
| 112 | + * Apply the collection's declared rules for the duration of a direct read. | |
| 113 | + * | |
| 114 | + * @param WP_REST_Request $request Full details about the request. | |
| 115 | + * @return \WP_Error|\WP_REST_Response | |
| 116 | + */ | |
| 117 | + public function get_items( $request ) { | |
| 118 | + $plan = Collection_Rules::for_request( 'products', $request, self::WCPOS_SORT_PARAM_MAP ); | |
| 119 | + return $plan->around( | |
| 120 | + function () use ( $request ) { | |
| 121 | + return parent::get_items( $request ); | |
| 122 | + } | |
| 123 | + ); | |
| 124 | + } | |
| 125 | + | |
| 126 | + /** | |
| 101 | 127 | * Create a single product. |
| 102 | 128 | * |
| 103 | 129 | * @param WP_REST_Request $request Full details about the request. |
| 104 | 130 | * |
| @@ -179,8 +205,14 @@ | ||
| 179 | 205 | if ( isset( $params['per_page'] ) && \is_array( $params['per_page'] ) ) { |
| 180 | 206 | $params['per_page']['minimum'] = -1; |
| 181 | 207 | } |
| 182 | 208 | |
| 209 | + // Search text is literal on every lane: `sanitize_text_field` would strip `%30` | |
| 210 | + // and blank malformed UTF-8 before the declared search rule ever saw them. | |
| 211 | + if ( isset( $params['search'] ) && \is_array( $params['search'] ) ) { | |
| 212 | + $params['search']['sanitize_callback'] = 'rest_sanitize_request_arg'; | |
| 213 | + } | |
| 214 | + | |
| 183 | 215 | if ( ! $this->wcpos_parent_collection_supports_param( 'brand' ) ) { |
| 184 | 216 | $params['brand'] = array( |
| 185 | 217 | 'description' => /* translators: REST API collection parameter description. */ __( 'Limit result set to products assigned to brand IDs or slugs, separated by commas.', 'woocommerce-pos' ), |
| 186 | 218 | 'type' => 'string', |
| @@ -219,15 +251,12 @@ | ||
| 219 | 251 | } |
| 220 | 252 | |
| 221 | 253 | // Ensure 'orderby' is set and is an array before attempting to modify it. |
| 222 | 254 | if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) { |
| 223 | - // Define new sorting options. | |
| 224 | - $new_sort_options = array( | |
| 225 | - 'sku', | |
| 226 | - 'barcode', | |
| 227 | - 'stock_quantity', | |
| 228 | - 'stock_status', | |
| 229 | - ); | |
| 255 | + // The POS sorts are DECLARED once, in Sync\Collection_Rules, and projected here. | |
| 256 | + // The v2 proxy lane claims the same list, so a sort cannot be advertised on one | |
| 257 | + // Read Lane and rejected on the other (#1779). | |
| 258 | + $new_sort_options = Collection_Rules::orderby_enum( 'products' ); | |
| 230 | 259 | // Merge new options, avoiding duplicates. |
| 231 | 260 | $params['orderby']['enum'] = array_unique( array_merge( $params['orderby']['enum'], $new_sort_options ) ); |
| 232 | 261 | } |
| 233 | 262 | |
| @@ -358,8 +387,10 @@ | ||
| 358 | 387 | * @param string $search The search SQL query. |
| 359 | 388 | * @param WP_Query $wp_query The WP_Query instance (passed by reference). |
| 360 | 389 | * |
| 361 | 390 | * @return string |
| 391 | + * | |
| 392 | + * @deprecated Collection Rules now installs this behavior. | |
| 362 | 393 | */ |
| 363 | 394 | public function wcpos_posts_search( string $search, WP_Query $wp_query ) { |
| 364 | 395 | return Product_Search::posts_search( $search, $wp_query ); |
| 365 | 396 | } |
| @@ -373,26 +404,20 @@ | ||
| 373 | 404 | * @param string[] $clauses Associative array of the clauses for the query. |
| 374 | 405 | * @param WP_Query $wp_query The WP_Query instance (passed by reference). |
| 375 | 406 | */ |
| 376 | 407 | public function wcpos_posts_clauses( array $clauses, WP_Query $wp_query ): array { |
| 377 | - global $wpdb; | |
| 408 | + if ( ! isset( $this->wcpos_request ) ) { | |
| 409 | + return $clauses; | |
| 410 | + } | |
| 378 | 411 | |
| 379 | - // Handle NULL values in stock_quantity sorting | |
| 380 | - // By default, MySQL sorts NULLs first in ASC and last in DESC | |
| 381 | - // We want NULLs to always be last regardless of sort direction. | |
| 382 | - if ( isset( $this->wcpos_request ) ) { | |
| 383 | - $orderby = $this->wcpos_request->get_param( 'orderby' ); | |
| 384 | - $order = strtoupper( $this->wcpos_request->get_param( 'order' ) ?? 'ASC' ); | |
| 412 | + $post_type = $wp_query->query_vars['post_type'] ?? null; | |
| 413 | + if ( 'product' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'product', $post_type, true ) ) ) { | |
| 414 | + return $clauses; | |
| 415 | + } | |
| 385 | 416 | |
| 386 | - if ( 'stock_quantity' === $orderby ) { | |
| 387 | - // Modify ORDER BY to put NULLs last | |
| 388 | - // Use CASE to assign a sort priority: non-NULL = 0, NULL = 1 | |
| 389 | - // Then sort by the actual value. | |
| 390 | - $clauses['orderby'] = "{$wpdb->postmeta}.meta_value IS NULL ASC, {$wpdb->postmeta}.meta_value + 0 {$order}"; | |
| 391 | - } | |
| 392 | - } | |
| 417 | + $plan = Collection_Rules::for_request( 'products', $this->wcpos_request, self::WCPOS_SORT_PARAM_MAP ); | |
| 393 | 418 | |
| 394 | - return $clauses; | |
| 419 | + return $plan->filter( Collection_Rules_Plan::HOOK_POSTS_CLAUSES, $clauses, $wp_query ); | |
| 395 | 420 | } |
| 396 | 421 | |
| 397 | 422 | /** |
| 398 | 423 | * Filter the query arguments for a request. |
| @@ -402,19 +427,8 @@ | ||
| 402 | 427 | * |
| 403 | 428 | * @return array $args Key value array of query var to query value. |
| 404 | 429 | */ |
| 405 | 430 | public function wcpos_product_query( array $args, WP_REST_Request $request ) { |
| 406 | - if ( ! empty( $request['search'] ) ) { | |
| 407 | - // We need to set the query up for a postmeta join. | |
| 408 | - add_filter( 'posts_join', array( $this, 'wcpos_posts_join_to_products_search' ), 10, 2 ); | |
| 409 | - add_filter( 'posts_groupby', array( $this, 'wcpos_posts_groupby_product_search' ), 10, 2 ); | |
| 410 | - } | |
| 411 | - | |
| 412 | - // if POS only products are enabled, exclude online-only products. | |
| 413 | - if ( $this->wcpos_pos_only_products_enabled() ) { | |
| 414 | - add_filter( 'posts_where', array( $this, 'wcpos_posts_where_product_exclude_online_only' ), 10, 2 ); | |
| 415 | - } | |
| 416 | - | |
| 417 | 431 | // Check for wcpos_include/wcpos_exclude parameter. |
| 418 | 432 | if ( isset( $request['wcpos_include'] ) || isset( $request['wcpos_exclude'] ) ) { |
| 419 | 433 | add_filter( 'posts_where', array( $this, 'wcpos_posts_where_product_include_exclude' ), 20, 2 ); |
| 420 | 434 | } |
| @@ -579,8 +593,10 @@ | ||
| 579 | 593 | * @param string $join The JOIN clause of the query. |
| 580 | 594 | * @param WP_Query $query The WP_Query instance (passed by reference). |
| 581 | 595 | * |
| 582 | 596 | * @return string |
| 597 | + * | |
| 598 | + * @deprecated Collection Rules now installs this behavior. | |
| 583 | 599 | */ |
| 584 | 600 | public function wcpos_posts_join_to_products_search( string $join, WP_Query $query ) { |
| 585 | 601 | return Product_Search::posts_join( $join, $query ); |
| 586 | 602 | } |
| @@ -591,8 +607,10 @@ | ||
| 591 | 607 | * @param string $groupby The GROUP BY clause of the query. |
| 592 | 608 | * @param WP_Query $query The WP_Query instance (passed by reference). |
| 593 | 609 | * |
| 594 | 610 | * @return string |
| 611 | + * | |
| 612 | + * @deprecated Collection Rules now installs this behavior. | |
| 595 | 613 | */ |
| 596 | 614 | public function wcpos_posts_groupby_product_search( string $groupby, WP_Query $query ) { |
| 597 | 615 | return Product_Search::posts_groupby( $groupby, $query ); |
| 598 | 616 | } |
| @@ -606,8 +624,10 @@ | ||
| 606 | 624 | * @param string $where The WHERE clause of the query. |
| 607 | 625 | * @param WP_Query $query The WP_Query instance (passed by reference). |
| 608 | 626 | * |
| 609 | 627 | * @return string |
| 628 | + * | |
| 629 | + * @deprecated Collection Rules now installs this behavior. | |
| 610 | 630 | */ |
| 611 | 631 | public function wcpos_posts_where_product_exclude_online_only( string $where, WP_Query $query ) { |
| 612 | 632 | global $wpdb; |
| 613 | 633 | |
| @@ -689,33 +709,16 @@ | ||
| 689 | 709 | protected function prepare_objects_query( $request ) { |
| 690 | 710 | $args = parent::prepare_objects_query( $request ); |
| 691 | 711 | $args = $this->wcpos_apply_store_api_tax_operator_fallbacks( $args, $request ); |
| 692 | 712 | |
| 693 | - // Add custom 'orderby' options. | |
| 694 | - if ( isset( $request['orderby'] ) ) { | |
| 695 | - switch ( $request['orderby'] ) { | |
| 696 | - case 'sku': | |
| 697 | - $args['meta_key'] = '_sku'; | |
| 698 | - $args['orderby'] = 'meta_value'; | |
| 699 | - | |
| 700 | - break; | |
| 701 | - case 'barcode': | |
| 702 | - $args['meta_key'] = Barcode_Field::orderby_key(); | |
| 703 | - $args['orderby'] = 'meta_value'; | |
| 704 | - | |
| 705 | - break; | |
| 706 | - case 'stock_quantity': | |
| 707 | - $args['meta_key'] = '_stock'; | |
| 708 | - $args['orderby'] = 'meta_value_num'; | |
| 709 | - | |
| 710 | - break; | |
| 711 | - case 'stock_status': | |
| 712 | - $args['meta_key'] = '_stock_status'; | |
| 713 | - $args['orderby'] = 'meta_value'; | |
| 714 | - | |
| 715 | - break; | |
| 716 | - } | |
| 717 | - } | |
| 713 | + /* | |
| 714 | + * The POS sorts (`sku`, `barcode`, `stock_quantity`, `stock_status`) are NOT | |
| 715 | + * mapped onto `meta_key` + `orderby => meta_value` here any more. That pair | |
| 716 | + * INNER JOINs postmeta, so it dropped every product with no value for the key — | |
| 717 | + * a sort acting as a filter (#1779 follow-up). `Sync\Collection_Rules` declares | |
| 718 | + * them and `wcpos_posts_clauses()` applies them as a LEFT JOIN, on this lane and | |
| 719 | + * on the v2 proxy alike. | |
| 720 | + */ | |
| 718 | 721 | |
| 719 | 722 | return $args; |
| 720 | 723 | } |
| 721 | 724 | } |