| @@ -10,8 +10,10 @@ | ||
| 10 | 10 | use WC_Product_Variation; |
| 11 | 11 | use WC_REST_Product_Variations_Controller; |
| 12 | 12 | use WCPOS\WooCommercePOS\Services\Barcode_Field; |
| 13 | 13 | use WCPOS\WooCommercePOS\Sync\Api; |
| 14 | +use WCPOS\WooCommercePOS\Sync\Collection_Rules; | |
| 15 | +use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan; | |
| 14 | 16 | use WCPOS\WooCommercePOS\Sync\Digest_Index; |
| 15 | 17 | use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions; |
| 16 | 18 | use WCPOS\WooCommercePOS\Sync\Pos_Visibility; |
| 17 | 19 | use WCPOS\WooCommercePOS\Sync\Product_Serializer; |
| @@ -49,8 +51,25 @@ | ||
| 49 | 51 | * (`documents[].{id,parent_id,payload,_rxdb_digest}`), POS visibility, the barcode carrier |
| 50 | 52 | * search, and the request bounds. Everything else is WooCommerce's. |
| 51 | 53 | */ |
| 52 | 54 | class Variations_Controller extends WC_REST_Product_Variations_Controller { |
| 55 | + /** | |
| 56 | + * Request keys the variation Collection Rules plan reads on this lane. | |
| 57 | + * | |
| 58 | + * @var array | |
| 59 | + */ | |
| 60 | + private const WCPOS_SORT_PARAM_MAP = array( | |
| 61 | + 'orderby' => 'orderby', | |
| 62 | + 'order' => 'order', | |
| 63 | + ); | |
| 64 | + | |
| 65 | + /** | |
| 66 | + * The request whose declared sort `wcpos_posts_clauses()` applies. | |
| 67 | + * | |
| 68 | + * @var null|WP_REST_Request | |
| 69 | + */ | |
| 70 | + private $wcpos_sort_request = null; | |
| 71 | + | |
| 53 | 72 | use Endpoint_Permissions; |
| 54 | 73 | |
| 55 | 74 | private const MAX_SKU_LENGTH = 4096; |
| 56 | 75 | private const MAX_SKU_TERMS = 100; |
| @@ -138,10 +157,9 @@ | ||
| 138 | 157 | * |
| 139 | 158 | * WooCommerce maps `search` onto `s`, which searches post_title/content — useless for a |
| 140 | 159 | * variation, whose title is a generated attribute string. The POS searches what a cashier |
| 141 | 160 | * actually types or scans: the SKU and whichever meta key the store configured as its |
| 142 | - * barcode field (`Barcode_Field::search_keys()`). Any term matching any carrier wins, | |
| 143 | - * which is the semantics the previous hand-rolled SQL had and the specs pin. | |
| 161 | + * barcode field (`Barcode_Field::search_keys()`). Every term must match at least one carrier. | |
| 144 | 162 | * |
| 145 | 163 | * `sku` is left to WooCommerce: its own exact/comma-list handling is what the |
| 146 | 164 | * sku-beats-search precedence rule relies on. |
| 147 | 165 | */ |
| @@ -153,17 +171,23 @@ | ||
| 153 | 171 | } |
| 154 | 172 | if ( '' !== $search && '' === $sku ) { |
| 155 | 173 | unset( $args['s'] ); |
| 156 | 174 | $args['wcpos_variation_search'] = true; |
| 157 | - $carriers = array( 'relation' => 'OR' ); | |
| 158 | - foreach ( (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ) as $term ) { | |
| 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' ); | |
| 159 | 182 | foreach ( Barcode_Field::search_keys() as $key ) { |
| 160 | - $carriers[] = array( | |
| 183 | + $term_carriers[] = array( | |
| 161 | 184 | 'key' => $key, |
| 162 | 185 | 'value' => $term, |
| 163 | 186 | 'compare' => 'LIKE', |
| 164 | 187 | ); |
| 165 | 188 | } |
| 189 | + $carriers[] = $term_carriers; | |
| 166 | 190 | } |
| 167 | 191 | if ( 1 < \count( $carriers ) ) { |
| 168 | 192 | $args['meta_query'] = $this->add_meta_query( $args, $carriers ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| 169 | 193 | } |
| @@ -203,37 +227,21 @@ | ||
| 203 | 227 | */ |
| 204 | 228 | $args = ( new Pos_Visibility() )->apply_to_wp_query_args( $args, Pos_Visibility::VARIATIONS ); |
| 205 | 229 | |
| 206 | 230 | /* |
| 207 | - * The POS sorts on fields WooCommerce does not offer as orderby values. They are declared | |
| 208 | - * in get_collection_params() below — without that, `orderby=sku` is rejected by REST | |
| 209 | - * argument validation before this switch ever runs. | |
| 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. | |
| 210 | 240 | */ |
| 211 | - if ( isset( $request['orderby'] ) ) { | |
| 212 | - switch ( $request['orderby'] ) { | |
| 213 | - case 'sku': | |
| 214 | - $args['meta_key'] = '_sku'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 215 | - $args['orderby'] = 'meta_value'; | |
| 241 | + $this->wcpos_sort_request = $request; | |
| 242 | + add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 ); | |
| 216 | 243 | |
| 217 | - break; | |
| 218 | - case 'barcode': | |
| 219 | - $args['meta_key'] = Barcode_Field::orderby_key(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 220 | - $args['orderby'] = 'meta_value'; | |
| 221 | - | |
| 222 | - break; | |
| 223 | - case 'stock_quantity': | |
| 224 | - $args['meta_key'] = '_stock'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 225 | - $args['orderby'] = 'meta_value_num'; | |
| 226 | - | |
| 227 | - break; | |
| 228 | - case 'stock_status': | |
| 229 | - $args['meta_key'] = '_stock_status'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 230 | - $args['orderby'] = 'meta_value'; | |
| 231 | - | |
| 232 | - break; | |
| 233 | - } | |
| 234 | - } | |
| 235 | - | |
| 236 | 244 | return $args; |
| 237 | 245 | } |
| 238 | 246 | |
| 239 | 247 | /** |
| @@ -434,12 +442,20 @@ | ||
| 434 | 442 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not contain more than 100 comma-separated terms', array( 'status' => 400 ) ); |
| 435 | 443 | } |
| 436 | 444 | } else { |
| 437 | 445 | $search = (string) $request->get_param( 'search' ); |
| 438 | - if ( self::MAX_SEARCH_LENGTH < \strlen( $search ) ) { | |
| 439 | - return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 bytes', array( 'status' => 400 ) ); | |
| 446 | + // Unlike mb_strlen(), PCRE is independent of blog_charset and detects malformed UTF-8. | |
| 447 | + $characters = preg_match_all( '/./us', $search ); | |
| 448 | + if ( false === $characters ) { | |
| 449 | + return new WP_Error( 'woocommerce_pos_variations_search_invalid', 'search must be valid UTF-8', array( 'status' => 400 ) ); | |
| 440 | 450 | } |
| 441 | - $terms = (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 451 | + if ( self::MAX_SEARCH_LENGTH < $characters ) { | |
| 452 | + return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not exceed 256 characters', array( 'status' => 400 ) ); | |
| 453 | + } | |
| 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 | + } | |
| 442 | 458 | if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) { |
| 443 | 459 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) ); |
| 444 | 460 | } |
| 445 | 461 | } |
| @@ -451,8 +467,35 @@ | ||
| 451 | 467 | return true; |
| 452 | 468 | } |
| 453 | 469 | |
| 454 | 470 | /** |
| 471 | + * Apply the declared POS variation sorts to the SQL clauses. | |
| 472 | + * | |
| 473 | + * `posts_clauses` fires for EVERY WP_Query, so the body is guarded by post type and by | |
| 474 | + * the plan itself — it contributes nothing unless this request claimed one of the | |
| 475 | + * declared sorts. | |
| 476 | + * | |
| 477 | + * @param array $clauses Associative array of the clauses for the query. | |
| 478 | + * @param WP_Query $wp_query The WP_Query instance. | |
| 479 | + * | |
| 480 | + * @return array | |
| 481 | + */ | |
| 482 | + public function wcpos_posts_clauses( array $clauses, WP_Query $wp_query ): array { | |
| 483 | + if ( null === $this->wcpos_sort_request ) { | |
| 484 | + return $clauses; | |
| 485 | + } | |
| 486 | + | |
| 487 | + $post_type = $wp_query->query_vars['post_type'] ?? null; | |
| 488 | + if ( 'product_variation' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'product_variation', $post_type, true ) ) ) { | |
| 489 | + return $clauses; | |
| 490 | + } | |
| 491 | + | |
| 492 | + $plan = Collection_Rules::for_request( 'variations', $this->wcpos_sort_request, self::WCPOS_SORT_PARAM_MAP ); | |
| 493 | + | |
| 494 | + return $plan->filter( Collection_Rules_Plan::HOOK_POSTS_CLAUSES, $clauses, $wp_query ); | |
| 495 | + } | |
| 496 | + | |
| 497 | + /** | |
| 455 | 498 | * WooCommerce's collection params, plus the sort keys the POS grids offer. |
| 456 | 499 | * |
| 457 | 500 | * `orderby` is a validated enum. Appending here is what lets `prepare_objects_query()` act on |
| 458 | 501 | * these four — otherwise the request 400s during argument validation and the switch is dead |
| @@ -459,8 +502,9 @@ | ||
| 459 | 502 | * code. 1.9.x extended the same enum for the same reason. |
| 460 | 503 | */ |
| 461 | 504 | public function get_collection_params() { |
| 462 | 505 | $params = parent::get_collection_params(); |
| 506 | + $params['search']['sanitize_callback'] = 'rest_sanitize_request_arg'; | |
| 463 | 507 | |
| 464 | 508 | if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) { |
| 465 | 509 | $params['orderby']['enum'] = array_values( |
| 466 | 510 | array_unique( |
| @@ -465,9 +509,9 @@ | ||
| 465 | 509 | $params['orderby']['enum'] = array_values( |
| 466 | 510 | array_unique( |
| 467 | 511 | array_merge( |
| 468 | 512 | $params['orderby']['enum'], |
| 469 | - array( 'sku', 'barcode', 'stock_quantity', 'stock_status' ) | |
| 513 | + Collection_Rules::orderby_enum( 'variations' ) | |
| 470 | 514 | ) |
| 471 | 515 | ) |
| 472 | 516 | ); |
| 473 | 517 | } |
| @@ -499,10 +543,14 @@ | ||
| 499 | 543 | return true; |
| 500 | 544 | } |
| 501 | 545 | |
| 502 | 546 | $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 | + } | |
| 503 | 551 | |
| 504 | - return array() !== (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 552 | + return array() !== $terms; | |
| 505 | 553 | } |
| 506 | 554 | |
| 507 | 555 | /** |
| 508 | 556 | * One page of the POS-servable variation collection, with its total. |