| @@ -8,15 +8,14 @@ | ||
| 8 | 8 | namespace WCPOS\WooCommercePOS\API\V2; |
| 9 | 9 | |
| 10 | 10 | use WC_Product_Variation; |
| 11 | 11 | use WC_REST_Product_Variations_Controller; |
| 12 | -use WCPOS\WooCommercePOS\Services\Barcode_Field; | |
| 13 | 12 | use WCPOS\WooCommercePOS\Sync\Api; |
| 14 | 13 | use WCPOS\WooCommercePOS\Sync\Collection_Rules; |
| 15 | 14 | use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan; |
| 16 | 15 | use WCPOS\WooCommercePOS\Sync\Digest_Index; |
| 17 | 16 | use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| 18 | -use WCPOS\WooCommercePOS\Sync\Pos_Visibility; | |
| 17 | +use WCPOS\WooCommercePOS\Sync\Product_Search; | |
| 19 | 18 | use WCPOS\WooCommercePOS\Sync\Product_Serializer; |
| 20 | 19 | use WP_Error; |
| 21 | 20 | use WP_Query; |
| 22 | 21 | use WP_REST_Request; |
| @@ -59,12 +58,13 @@ | ||
| 59 | 58 | */ |
| 60 | 59 | private const WCPOS_SORT_PARAM_MAP = array( |
| 61 | 60 | 'orderby' => 'orderby', |
| 62 | 61 | 'order' => 'order', |
| 62 | + 'search' => 'search', | |
| 63 | 63 | ); |
| 64 | 64 | |
| 65 | 65 | /** |
| 66 | - * The request whose declared sort `wcpos_posts_clauses()` applies. | |
| 66 | + * The request whose declared rules wrap the collection query. | |
| 67 | 67 | * |
| 68 | 68 | * @var null|WP_REST_Request |
| 69 | 69 | */ |
| 70 | 70 | private $wcpos_sort_request = null; |
| @@ -73,9 +73,8 @@ | ||
| 73 | 73 | |
| 74 | 74 | private const MAX_SKU_LENGTH = 4096; |
| 75 | 75 | private const MAX_SKU_TERMS = 100; |
| 76 | 76 | private const MAX_SEARCH_LENGTH = 256; |
| 77 | - private const MAX_SEARCH_TERMS = 10; | |
| 78 | 77 | private const MAX_PAGE = 1000; |
| 79 | 78 | |
| 80 | 79 | |
| 81 | 80 | public function register_routes(): void { |
| @@ -152,49 +151,8 @@ | ||
| 152 | 151 | */ |
| 153 | 152 | $args['post_type'] = $this->post_type; |
| 154 | 153 | |
| 155 | 154 | /* |
| 156 | - * `search` means the barcode CARRIERS here, not the post title. | |
| 157 | - * | |
| 158 | - * WooCommerce maps `search` onto `s`, which searches post_title/content — useless for a | |
| 159 | - * variation, whose title is a generated attribute string. The POS searches what a cashier | |
| 160 | - * actually types or scans: the SKU and whichever meta key the store configured as its | |
| 161 | - * barcode field (`Barcode_Field::search_keys()`). Every term must match at least one carrier. | |
| 162 | - * | |
| 163 | - * `sku` is left to WooCommerce: its own exact/comma-list handling is what the | |
| 164 | - * sku-beats-search precedence rule relies on. | |
| 165 | - */ | |
| 166 | - $search = (string) ( $request->get_param( 'search' ) ?? '' ); | |
| 167 | - if ( '' !== $sku ) { | |
| 168 | - // SKU is an exact lookup and outranks a fuzzy one; leaving WooCommerce's post-title | |
| 169 | - // `s` in place would AND the two and return nothing. | |
| 170 | - unset( $args['s'] ); | |
| 171 | - } | |
| 172 | - if ( '' !== $search && '' === $sku ) { | |
| 173 | - unset( $args['s'] ); | |
| 174 | - $args['wcpos_variation_search'] = true; | |
| 175 | - $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 176 | - if ( false === $terms ) { | |
| 177 | - $terms = array(); | |
| 178 | - } | |
| 179 | - $carriers = array( 'relation' => 'AND' ); | |
| 180 | - foreach ( $terms as $term ) { | |
| 181 | - $term_carriers = array( 'relation' => 'OR' ); | |
| 182 | - foreach ( Barcode_Field::search_keys() as $key ) { | |
| 183 | - $term_carriers[] = array( | |
| 184 | - 'key' => $key, | |
| 185 | - 'value' => $term, | |
| 186 | - 'compare' => 'LIKE', | |
| 187 | - ); | |
| 188 | - } | |
| 189 | - $carriers[] = $term_carriers; | |
| 190 | - } | |
| 191 | - if ( 1 < \count( $carriers ) ) { | |
| 192 | - $args['meta_query'] = $this->add_meta_query( $args, $carriers ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query | |
| 193 | - } | |
| 194 | - } | |
| 195 | - | |
| 196 | - /* | |
| 197 | 155 | * This route only ever offers what the store owner has for sale — on EVERY lane, including |
| 198 | 156 | * `include`. |
| 199 | 157 | * |
| 200 | 158 | * WooCommerce's Enabled checkbox on the variation metabox writes `post_status = private` |
| @@ -213,38 +171,25 @@ | ||
| 213 | 171 | * Set after `parent::prepare_objects_query()` so an explicit `status` param cannot widen it. |
| 214 | 172 | */ |
| 215 | 173 | $args['post_status'] = 'publish'; |
| 216 | 174 | |
| 217 | - /* | |
| 218 | - * Leg-3 (ADR 0014 WP-M5): POS-hidden (`online_only`) variations are never served. As a | |
| 219 | - * query exclusion rather than a post-hoc filter of the result, so paging and totals count | |
| 220 | - * the same set the client is allowed to see. | |
| 221 | - * | |
| 222 | - * Through the helper, NOT a raw `post__not_in` merge: `parent::prepare_objects_query()` | |
| 223 | - * maps `include` to `post__in`, and WP_Query IGNORES `post__not_in` when `post__in` is | |
| 224 | - * present — so `?search=X&include=<hidden id>` would have served a hidden variation. | |
| 225 | - * `apply_to_wp_query_args()` already owns that trap: it intersects `post__in` with the | |
| 226 | - * hidden set and pins an empty intersection to `array( 0 )`. | |
| 227 | - */ | |
| 228 | - $args = ( new Pos_Visibility() )->apply_to_wp_query_args( $args, Pos_Visibility::VARIATIONS ); | |
| 229 | - | |
| 230 | - /* | |
| 231 | - * The POS sorts on fields WooCommerce does not offer as orderby values. They are | |
| 232 | - * declared in Sync\Collection_Rules and projected into get_collection_params() | |
| 233 | - * below — without that, `orderby=sku` is rejected by REST argument validation | |
| 234 | - * before anything here runs. | |
| 235 | - * | |
| 236 | - * They are applied as SQL clauses, NOT as `meta_key` + `orderby => meta_value`: | |
| 237 | - * that pair INNER JOINs postmeta and drops every variation with no value for the | |
| 238 | - * key, so the sort silently filtered. `wcpos_posts_clauses()` LEFT JOINs instead | |
| 239 | - * and orders the meta-less rows last. | |
| 240 | - */ | |
| 241 | 175 | $this->wcpos_sort_request = $request; |
| 242 | - add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 ); | |
| 176 | + $plan = Collection_Rules::for_request( 'variations', $request, self::WCPOS_SORT_PARAM_MAP ); | |
| 177 | + $args = $plan->filter( Collection_Rules_Plan::HOOK_PREPARE_ARGS, $args ); | |
| 243 | 178 | |
| 244 | 179 | return $args; |
| 245 | 180 | } |
| 246 | 181 | |
| 182 | + /** Apply the same rule topology to discovery, collection pages, and named includes. */ | |
| 183 | + protected function get_objects( $query_args ) { | |
| 184 | + $plan = Collection_Rules::for_request( 'variations', $this->wcpos_sort_request, self::WCPOS_SORT_PARAM_MAP ); | |
| 185 | + return $plan->around( | |
| 186 | + function () use ( $query_args ) { | |
| 187 | + return parent::get_objects( $query_args ); | |
| 188 | + } | |
| 189 | + ); | |
| 190 | + } | |
| 191 | + | |
| 247 | 192 | /** |
| 248 | 193 | * GET /variations — the flat collection's three lanes, one response shape. |
| 249 | 194 | * |
| 250 | 195 | * `?sku=`/`?search=` discovers by barcode carrier; a bare request serves one |
| @@ -450,13 +395,10 @@ | ||
| 450 | 395 | } |
| 451 | 396 | if ( self::MAX_SEARCH_LENGTH < $characters ) { |
| 452 | 397 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 characters', array( 'status' => 400 ) ); |
| 453 | 398 | } |
| 454 | - $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 455 | - if ( false === $terms ) { | |
| 456 | - return new WP_Error( 'woocommerce_pos_variations_search_invalid', 'search must be valid UTF-8', array( 'status' => 400 ) ); | |
| 457 | - } | |
| 458 | - if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) { | |
| 399 | + $terms = Collection_Rules::search_terms( trim( $search ) ); | |
| 400 | + if ( Collection_Rules::rules( 'variations' )['search']['term_cap'] < \count( $terms ) ) { | |
| 459 | 401 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) ); |
| 460 | 402 | } |
| 461 | 403 | } |
| 462 | 404 | |
| @@ -476,8 +418,9 @@ | ||
| 476 | 418 | * |
| 477 | 419 | * @param array $clauses Associative array of the clauses for the query. |
| 478 | 420 | * @param WP_Query $wp_query The WP_Query instance. |
| 479 | 421 | * |
| 422 | + * @deprecated Collection Rules now installs this behavior; retained for Pro callers. | |
| 480 | 423 | * @return array |
| 481 | 424 | */ |
| 482 | 425 | public function wcpos_posts_clauses( array $clauses, WP_Query $wp_query ): array { |
| 483 | 426 | if ( null === $this->wcpos_sort_request ) { |
| @@ -523,13 +466,13 @@ | ||
| 523 | 466 | * De-duplicate variation searches joined through matching meta rows. |
| 524 | 467 | * |
| 525 | 468 | * @param string $groupby Existing GROUP BY clause. |
| 526 | 469 | * @param WP_Query $query Query being filtered. |
| 470 | + * | |
| 471 | + * @deprecated Collection Rules owns variation grouping. | |
| 527 | 472 | */ |
| 528 | 473 | public function group_search_results( string $groupby, WP_Query $query ): string { |
| 529 | - global $wpdb; | |
| 530 | - | |
| 531 | - return ! empty( $query->query_vars['wcpos_variation_search'] ) ? "{$wpdb->posts}.ID" : $groupby; | |
| 474 | + return Product_Search::variation_groupby( $groupby, $query->query_vars ); | |
| 532 | 475 | } |
| 533 | 476 | |
| 534 | 477 | /** |
| 535 | 478 | * Does this discovery request still carry a term after normalization? |
| @@ -543,12 +486,9 @@ | ||
| 543 | 486 | return true; |
| 544 | 487 | } |
| 545 | 488 | |
| 546 | 489 | $search = (string) ( $request->get_param( 'search' ) ?? '' ); |
| 547 | - $terms = preg_split( '/[\s\p{Z}\p{C}]+/u', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 548 | - if ( false === $terms ) { | |
| 549 | - return false; | |
| 550 | - } | |
| 490 | + $terms = Collection_Rules::search_terms( trim( $search ) ); | |
| 551 | 491 | |
| 552 | 492 | return array() !== $terms; |
| 553 | 493 | } |
| 554 | 494 | |
| @@ -623,14 +563,9 @@ | ||
| 623 | 563 | ), |
| 624 | 564 | ); |
| 625 | 565 | } |
| 626 | 566 | |
| 627 | - add_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10, 2 ); | |
| 628 | - try { | |
| 629 | - $results = $this->get_objects( $query_args ); | |
| 630 | - } finally { | |
| 631 | - remove_filter( 'posts_groupby', array( $this, 'group_search_results' ), 10 ); | |
| 632 | - } | |
| 567 | + $results = $this->get_objects( $query_args ); | |
| 633 | 568 | |
| 634 | 569 | $ids = array(); |
| 635 | 570 | foreach ( $results['objects'] as $object ) { |
| 636 | 571 | if ( $object instanceof WC_Product_Variation ) { |