| @@ -17,11 +17,14 @@ | ||
| 17 | 17 | use WC_Data; |
| 18 | 18 | use WC_Product; |
| 19 | 19 | use WC_Product_Variable; |
| 20 | 20 | use WC_REST_Products_Controller; |
| 21 | +use WCPOS\WooCommercePOS\API\Product_Search; | |
| 21 | 22 | use WCPOS\WooCommercePOS\Logger; |
| 22 | 23 | use WCPOS\WooCommercePOS\Services\Barcode_Field; |
| 23 | 24 | use WCPOS\WooCommercePOS\Services\Variable_Price_Range; |
| 25 | +use WCPOS\WooCommercePOS\Sync\Collection_Rules; | |
| 26 | +use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan; | |
| 24 | 27 | use WCPOS\WooCommercePOS\Sync\Pos_Visibility; |
| 25 | 28 | use WP_Error; |
| 26 | 29 | use WP_Query; |
| 27 | 30 | use WP_REST_Request; |
| @@ -59,8 +62,19 @@ | ||
| 59 | 62 | */ |
| 60 | 63 | protected $wcpos_request; |
| 61 | 64 | |
| 62 | 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 | + /** | |
| 63 | 77 | * Memoized parent collection params. |
| 64 | 78 | * |
| 65 | 79 | * @var array|null |
| 66 | 80 | */ |
| @@ -81,10 +95,8 @@ | ||
| 81 | 95 | |
| 82 | 96 | add_filter( 'woocommerce_rest_prepare_product_object', array( $this, 'wcpos_product_response' ), 10, 3 ); |
| 83 | 97 | add_action( 'woocommerce_rest_insert_product_object', array( $this, 'wcpos_insert_product_object' ), 10, 3 ); |
| 84 | 98 | add_filter( 'woocommerce_rest_product_object_query', array( $this, 'wcpos_product_query' ), 10, 2 ); |
| 85 | - add_filter( 'posts_search', array( $this, 'wcpos_posts_search' ), 10, 2 ); | |
| 86 | - add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 ); | |
| 87 | 99 | |
| 88 | 100 | /* |
| 89 | 101 | * Check if the request is for all products and if the 'posts_per_page' is set to -1. |
| 90 | 102 | * Optimised query for getting all product IDs. |
| @@ -96,8 +108,23 @@ | ||
| 96 | 108 | return $dispatch_result; |
| 97 | 109 | } |
| 98 | 110 | |
| 99 | 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 | + /** | |
| 100 | 127 | * Create a single product. |
| 101 | 128 | * |
| 102 | 129 | * @param WP_REST_Request $request Full details about the request. |
| 103 | 130 | * |
| @@ -178,8 +205,14 @@ | ||
| 178 | 205 | if ( isset( $params['per_page'] ) && \is_array( $params['per_page'] ) ) { |
| 179 | 206 | $params['per_page']['minimum'] = -1; |
| 180 | 207 | } |
| 181 | 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 | + | |
| 182 | 215 | if ( ! $this->wcpos_parent_collection_supports_param( 'brand' ) ) { |
| 183 | 216 | $params['brand'] = array( |
| 184 | 217 | 'description' => /* translators: REST API collection parameter description. */ __( 'Limit result set to products assigned to brand IDs or slugs, separated by commas.', 'woocommerce-pos' ), |
| 185 | 218 | 'type' => 'string', |
| @@ -218,15 +251,12 @@ | ||
| 218 | 251 | } |
| 219 | 252 | |
| 220 | 253 | // Ensure 'orderby' is set and is an array before attempting to modify it. |
| 221 | 254 | if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) { |
| 222 | - // Define new sorting options. | |
| 223 | - $new_sort_options = array( | |
| 224 | - 'sku', | |
| 225 | - 'barcode', | |
| 226 | - 'stock_quantity', | |
| 227 | - 'stock_status', | |
| 228 | - ); | |
| 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' ); | |
| 229 | 259 | // Merge new options, avoiding duplicates. |
| 230 | 260 | $params['orderby']['enum'] = array_unique( array_merge( $params['orderby']['enum'], $new_sort_options ) ); |
| 231 | 261 | } |
| 232 | 262 | |
| @@ -357,50 +387,13 @@ | ||
| 357 | 387 | * @param string $search The search SQL query. |
| 358 | 388 | * @param WP_Query $wp_query The WP_Query instance (passed by reference). |
| 359 | 389 | * |
| 360 | 390 | * @return string |
| 391 | + * | |
| 392 | + * @deprecated Collection Rules now installs this behavior. | |
| 361 | 393 | */ |
| 362 | 394 | public function wcpos_posts_search( string $search, WP_Query $wp_query ) { |
| 363 | - global $wpdb; | |
| 364 | - | |
| 365 | - if ( empty( $search ) ) { | |
| 366 | - return $search; // skip processing - no search term in query. | |
| 367 | - } | |
| 368 | - | |
| 369 | - $q = $wp_query->query_vars; | |
| 370 | - $n = ! empty( $q['exact'] ) ? '' : '%'; | |
| 371 | - $search_terms = (array) $q['search_terms']; | |
| 372 | - | |
| 373 | - // Fields in the main 'posts' table. | |
| 374 | - $post_fields = array( 'post_title' ); | |
| 375 | - | |
| 376 | - // Meta fields to search. | |
| 377 | - $meta_fields = Barcode_Field::search_keys(); | |
| 378 | - | |
| 379 | - $search_conditions = array(); | |
| 380 | - | |
| 381 | - foreach ( $search_terms as $term ) { | |
| 382 | - $term = $n . $wpdb->esc_like( $term ) . $n; | |
| 383 | - | |
| 384 | - // Search in post fields. | |
| 385 | - foreach ( $post_fields as $field ) { | |
| 386 | - $search_conditions[] = $wpdb->prepare( "({$wpdb->posts}.$field LIKE %s)", $term ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is safe. | |
| 387 | - } | |
| 388 | - | |
| 389 | - // Search in meta fields. | |
| 390 | - foreach ( $meta_fields as $field ) { | |
| 391 | - $search_conditions[] = $wpdb->prepare( '(pm1.meta_value LIKE %s AND pm1.meta_key = %s)', $term, $field ); | |
| 392 | - } | |
| 393 | - } | |
| 394 | - | |
| 395 | - if ( ! empty( $search_conditions ) ) { | |
| 396 | - $search = ' AND (' . implode( ' OR ', $search_conditions ) . ') '; | |
| 397 | - if ( ! is_user_logged_in() ) { | |
| 398 | - $search .= " AND ($wpdb->posts.post_password = '') "; | |
| 399 | - } | |
| 400 | - } | |
| 401 | - | |
| 402 | - return $search; | |
| 395 | + return Product_Search::posts_search( $search, $wp_query ); | |
| 403 | 396 | } |
| 404 | 397 | |
| 405 | 398 | /** |
| 406 | 399 | * Filters all query clauses at once, for convenience. |
| @@ -411,26 +404,20 @@ | ||
| 411 | 404 | * @param string[] $clauses Associative array of the clauses for the query. |
| 412 | 405 | * @param WP_Query $wp_query The WP_Query instance (passed by reference). |
| 413 | 406 | */ |
| 414 | 407 | public function wcpos_posts_clauses( array $clauses, WP_Query $wp_query ): array { |
| 415 | - global $wpdb; | |
| 408 | + if ( ! isset( $this->wcpos_request ) ) { | |
| 409 | + return $clauses; | |
| 410 | + } | |
| 416 | 411 | |
| 417 | - // Handle NULL values in stock_quantity sorting | |
| 418 | - // By default, MySQL sorts NULLs first in ASC and last in DESC | |
| 419 | - // We want NULLs to always be last regardless of sort direction. | |
| 420 | - if ( isset( $this->wcpos_request ) ) { | |
| 421 | - $orderby = $this->wcpos_request->get_param( 'orderby' ); | |
| 422 | - $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 | + } | |
| 423 | 416 | |
| 424 | - if ( 'stock_quantity' === $orderby ) { | |
| 425 | - // Modify ORDER BY to put NULLs last | |
| 426 | - // Use CASE to assign a sort priority: non-NULL = 0, NULL = 1 | |
| 427 | - // Then sort by the actual value. | |
| 428 | - $clauses['orderby'] = "{$wpdb->postmeta}.meta_value IS NULL ASC, {$wpdb->postmeta}.meta_value + 0 {$order}"; | |
| 429 | - } | |
| 430 | - } | |
| 417 | + $plan = Collection_Rules::for_request( 'products', $this->wcpos_request, self::WCPOS_SORT_PARAM_MAP ); | |
| 431 | 418 | |
| 432 | - return $clauses; | |
| 419 | + return $plan->filter( Collection_Rules_Plan::HOOK_POSTS_CLAUSES, $clauses, $wp_query ); | |
| 433 | 420 | } |
| 434 | 421 | |
| 435 | 422 | /** |
| 436 | 423 | * Filter the query arguments for a request. |
| @@ -440,19 +427,8 @@ | ||
| 440 | 427 | * |
| 441 | 428 | * @return array $args Key value array of query var to query value. |
| 442 | 429 | */ |
| 443 | 430 | public function wcpos_product_query( array $args, WP_REST_Request $request ) { |
| 444 | - if ( ! empty( $request['search'] ) ) { | |
| 445 | - // We need to set the query up for a postmeta join. | |
| 446 | - add_filter( 'posts_join', array( $this, 'wcpos_posts_join_to_products_search' ), 10, 2 ); | |
| 447 | - add_filter( 'posts_groupby', array( $this, 'wcpos_posts_groupby_product_search' ), 10, 2 ); | |
| 448 | - } | |
| 449 | - | |
| 450 | - // if POS only products are enabled, exclude online-only products. | |
| 451 | - if ( $this->wcpos_pos_only_products_enabled() ) { | |
| 452 | - add_filter( 'posts_where', array( $this, 'wcpos_posts_where_product_exclude_online_only' ), 10, 2 ); | |
| 453 | - } | |
| 454 | - | |
| 455 | 431 | // Check for wcpos_include/wcpos_exclude parameter. |
| 456 | 432 | if ( isset( $request['wcpos_include'] ) || isset( $request['wcpos_exclude'] ) ) { |
| 457 | 433 | add_filter( 'posts_where', array( $this, 'wcpos_posts_where_product_include_exclude' ), 20, 2 ); |
| 458 | 434 | } |
| @@ -617,17 +593,13 @@ | ||
| 617 | 593 | * @param string $join The JOIN clause of the query. |
| 618 | 594 | * @param WP_Query $query The WP_Query instance (passed by reference). |
| 619 | 595 | * |
| 620 | 596 | * @return string |
| 597 | + * | |
| 598 | + * @deprecated Collection Rules now installs this behavior. | |
| 621 | 599 | */ |
| 622 | 600 | public function wcpos_posts_join_to_products_search( string $join, WP_Query $query ) { |
| 623 | - global $wpdb; | |
| 624 | - | |
| 625 | - if ( ! empty( $query->query_vars['s'] ) && false === strpos( $join, 'pm1' ) ) { | |
| 626 | - $join .= " LEFT JOIN {$wpdb->postmeta} pm1 ON {$wpdb->posts}.ID = pm1.post_id "; | |
| 627 | - } | |
| 628 | - | |
| 629 | - return $join; | |
| 601 | + return Product_Search::posts_join( $join, $query ); | |
| 630 | 602 | } |
| 631 | 603 | |
| 632 | 604 | /** |
| 633 | 605 | * Filters the GROUP BY clause of the query. |
| @@ -635,17 +607,13 @@ | ||
| 635 | 607 | * @param string $groupby The GROUP BY clause of the query. |
| 636 | 608 | * @param WP_Query $query The WP_Query instance (passed by reference). |
| 637 | 609 | * |
| 638 | 610 | * @return string |
| 611 | + * | |
| 612 | + * @deprecated Collection Rules now installs this behavior. | |
| 639 | 613 | */ |
| 640 | 614 | public function wcpos_posts_groupby_product_search( string $groupby, WP_Query $query ) { |
| 641 | - global $wpdb; | |
| 642 | - | |
| 643 | - if ( ! empty( $query->query_vars['s'] ) ) { | |
| 644 | - $groupby = "{$wpdb->posts}.ID"; | |
| 645 | - } | |
| 646 | - | |
| 647 | - return $groupby; | |
| 615 | + return Product_Search::posts_groupby( $groupby, $query ); | |
| 648 | 616 | } |
| 649 | 617 | |
| 650 | 618 | /** |
| 651 | 619 | * Filters the WHERE clause of the query. |
| @@ -656,8 +624,10 @@ | ||
| 656 | 624 | * @param string $where The WHERE clause of the query. |
| 657 | 625 | * @param WP_Query $query The WP_Query instance (passed by reference). |
| 658 | 626 | * |
| 659 | 627 | * @return string |
| 628 | + * | |
| 629 | + * @deprecated Collection Rules now installs this behavior. | |
| 660 | 630 | */ |
| 661 | 631 | public function wcpos_posts_where_product_exclude_online_only( string $where, WP_Query $query ) { |
| 662 | 632 | global $wpdb; |
| 663 | 633 | |
| @@ -739,33 +709,16 @@ | ||
| 739 | 709 | protected function prepare_objects_query( $request ) { |
| 740 | 710 | $args = parent::prepare_objects_query( $request ); |
| 741 | 711 | $args = $this->wcpos_apply_store_api_tax_operator_fallbacks( $args, $request ); |
| 742 | 712 | |
| 743 | - // Add custom 'orderby' options. | |
| 744 | - if ( isset( $request['orderby'] ) ) { | |
| 745 | - switch ( $request['orderby'] ) { | |
| 746 | - case 'sku': | |
| 747 | - $args['meta_key'] = '_sku'; | |
| 748 | - $args['orderby'] = 'meta_value'; | |
| 749 | - | |
| 750 | - break; | |
| 751 | - case 'barcode': | |
| 752 | - $args['meta_key'] = Barcode_Field::orderby_key(); | |
| 753 | - $args['orderby'] = 'meta_value'; | |
| 754 | - | |
| 755 | - break; | |
| 756 | - case 'stock_quantity': | |
| 757 | - $args['meta_key'] = '_stock'; | |
| 758 | - $args['orderby'] = 'meta_value_num'; | |
| 759 | - | |
| 760 | - break; | |
| 761 | - case 'stock_status': | |
| 762 | - $args['meta_key'] = '_stock_status'; | |
| 763 | - $args['orderby'] = 'meta_value'; | |
| 764 | - | |
| 765 | - break; | |
| 766 | - } | |
| 767 | - } | |
| 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 | + */ | |
| 768 | 721 | |
| 769 | 722 | return $args; |
| 770 | 723 | } |
| 771 | 724 | } |