| @@ -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; |
| @@ -23,13 +25,15 @@ | ||
| 23 | 25 | |
| 24 | 26 | // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim. |
| 25 | 27 | |
| 26 | 28 | /** |
| 27 | - * Variations document endpoint (on-demand variation fetch). | |
| 29 | + * Variations document endpoint — the collection's hydration AND list/seed lane (ADR 0034). | |
| 28 | 30 | * |
| 29 | 31 | * Why a flat route: the change-signal yields BARE variation ids (no parent), and WooCommerce's |
| 30 | 32 | * only variation routes are parent-mediated (`products/<parent>/variations`). One flat route |
| 31 | - * lets the client pull a deferred variation set in ONE round trip with no parent->child dance. | |
| 33 | + * IS the cross-parent collection: bare pages seed the complete replica (the idle trickle), | |
| 34 | + * `include=` is one filter on it (targeted hydration, no parent->child dance), and the | |
| 35 | + * SKU/barcode discovery search is another. | |
| 32 | 36 | * |
| 33 | 37 | * Why it EXTENDS WooCommerce's variations controller: because that is all the route ever needed. |
| 34 | 38 | * WooCommerce's `get_objects()` already answers a cross-parent query — with no `product_id` in |
| 35 | 39 | * the route there is no parent constraint, and `include`/`search`/`orderby`/pagination are its |
| @@ -47,8 +51,25 @@ | ||
| 47 | 51 | * (`documents[].{id,parent_id,payload,_rxdb_digest}`), POS visibility, the barcode carrier |
| 48 | 52 | * search, and the request bounds. Everything else is WooCommerce's. |
| 49 | 53 | */ |
| 50 | 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 | + | |
| 51 | 72 | use Endpoint_Permissions; |
| 52 | 73 | |
| 53 | 74 | private const MAX_SKU_LENGTH = 4096; |
| 54 | 75 | private const MAX_SKU_TERMS = 100; |
| @@ -86,9 +107,13 @@ | ||
| 86 | 107 | /** |
| 87 | 108 | * Narrow WooCommerce's variation query to what the POS may serve. |
| 88 | 109 | * |
| 89 | 110 | * Everything WooCommerce already understands — `include`, `offset`, `order`, pagination, |
| 90 | - * status — comes from `parent::prepare_objects_query()`. Layered on top: POS visibility, the | |
| 111 | + * status — comes from `parent::prepare_objects_query()`, which also applies | |
| 112 | + * `woocommerce_rest_product_variation_object_query` internally (wc/v3's CRUD controller fires | |
| 113 | + * it there, not in `get_items()`), so third-party query scoping reaches every lane built | |
| 114 | + * through this method. Layered on top — deliberately AFTER that filter, so a third party | |
| 115 | + * cannot widen what the POS may serve: POS visibility, the | |
| 91 | 116 | * barcode-carrier search, and the sort keys the POS grids offer. This is the seam 1.9.x used |
| 92 | 117 | * for the same job (`API\V1\Product_Variations_Controller::prepare_objects_query`). |
| 93 | 118 | * |
| 94 | 119 | * @param WP_REST_Request $request Full details about the request. |
| @@ -132,10 +157,9 @@ | ||
| 132 | 157 | * |
| 133 | 158 | * WooCommerce maps `search` onto `s`, which searches post_title/content — useless for a |
| 134 | 159 | * variation, whose title is a generated attribute string. The POS searches what a cashier |
| 135 | 160 | * actually types or scans: the SKU and whichever meta key the store configured as its |
| 136 | - * barcode field (`Barcode_Field::search_keys()`). Any term matching any carrier wins, | |
| 137 | - * 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. | |
| 138 | 162 | * |
| 139 | 163 | * `sku` is left to WooCommerce: its own exact/comma-list handling is what the |
| 140 | 164 | * sku-beats-search precedence rule relies on. |
| 141 | 165 | */ |
| @@ -147,17 +171,23 @@ | ||
| 147 | 171 | } |
| 148 | 172 | if ( '' !== $search && '' === $sku ) { |
| 149 | 173 | unset( $args['s'] ); |
| 150 | 174 | $args['wcpos_variation_search'] = true; |
| 151 | - $carriers = array( 'relation' => 'OR' ); | |
| 152 | - 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' ); | |
| 153 | 182 | foreach ( Barcode_Field::search_keys() as $key ) { |
| 154 | - $carriers[] = array( | |
| 183 | + $term_carriers[] = array( | |
| 155 | 184 | 'key' => $key, |
| 156 | 185 | 'value' => $term, |
| 157 | 186 | 'compare' => 'LIKE', |
| 158 | 187 | ); |
| 159 | 188 | } |
| 189 | + $carriers[] = $term_carriers; | |
| 160 | 190 | } |
| 161 | 191 | if ( 1 < \count( $carriers ) ) { |
| 162 | 192 | $args['meta_query'] = $this->add_meta_query( $args, $carriers ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query |
| 163 | 193 | } |
| @@ -197,47 +227,35 @@ | ||
| 197 | 227 | */ |
| 198 | 228 | $args = ( new Pos_Visibility() )->apply_to_wp_query_args( $args, Pos_Visibility::VARIATIONS ); |
| 199 | 229 | |
| 200 | 230 | /* |
| 201 | - * The POS sorts on fields WooCommerce does not offer as orderby values. They are declared | |
| 202 | - * in get_collection_params() below — without that, `orderby=sku` is rejected by REST | |
| 203 | - * 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. | |
| 204 | 240 | */ |
| 205 | - if ( isset( $request['orderby'] ) ) { | |
| 206 | - switch ( $request['orderby'] ) { | |
| 207 | - case 'sku': | |
| 208 | - $args['meta_key'] = '_sku'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 209 | - $args['orderby'] = 'meta_value'; | |
| 241 | + $this->wcpos_sort_request = $request; | |
| 242 | + add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 ); | |
| 210 | 243 | |
| 211 | - break; | |
| 212 | - case 'barcode': | |
| 213 | - $args['meta_key'] = Barcode_Field::orderby_key(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 214 | - $args['orderby'] = 'meta_value'; | |
| 215 | - | |
| 216 | - break; | |
| 217 | - case 'stock_quantity': | |
| 218 | - $args['meta_key'] = '_stock'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 219 | - $args['orderby'] = 'meta_value_num'; | |
| 220 | - | |
| 221 | - break; | |
| 222 | - case 'stock_status': | |
| 223 | - $args['meta_key'] = '_stock_status'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key | |
| 224 | - $args['orderby'] = 'meta_value'; | |
| 225 | - | |
| 226 | - break; | |
| 227 | - } | |
| 228 | - } | |
| 229 | - | |
| 230 | 244 | return $args; |
| 231 | 245 | } |
| 232 | 246 | |
| 233 | 247 | /** |
| 234 | - * GET /variations?include=12,34,56 — hydrate the given variation ids. | |
| 248 | + * GET /variations — the flat collection's three lanes, one response shape. | |
| 235 | 249 | * |
| 236 | - * Mirrors the wc/v3 `products?include=` shape; the parent is resolved | |
| 237 | - * server-side off the loaded variation object (get_parent_id), so the client | |
| 238 | - * never needs to know parents. Unknown / non-variation ids are skipped | |
| 239 | - * (deletes are handled by the change-signal tombstone path, not here). | |
| 250 | + * `?sku=`/`?search=` discovers by barcode carrier; a bare request serves one | |
| 251 | + * collection page (the trickle's seed lane); `?include=12,34` hydrates the | |
| 252 | + * named ids. All three resolve ids through WooCommerce's collection query, | |
| 253 | + * then hydrate through the shared assembly line below. Mirrors the wc/v3 | |
| 254 | + * `products?include=` shape; the parent is resolved server-side off the | |
| 255 | + * loaded variation object (get_parent_id), so the client never needs to know | |
| 256 | + * parents. Unknown / non-variation ids are skipped (deletes are handled by | |
| 257 | + * the change-signal tombstone path, not here). | |
| 240 | 258 | */ |
| 241 | 259 | public function get_variations( WP_REST_Request $request ) { |
| 242 | 260 | $started = microtime( true ); |
| 243 | 261 | $search_meta = null; |
| @@ -259,13 +277,58 @@ | ||
| 259 | 277 | * filter, and a collection route with no filter is a collection. |
| 260 | 278 | */ |
| 261 | 279 | list( $ids, $search_meta ) = $this->collection_page( $request ); |
| 262 | 280 | } else { |
| 263 | - $ids = array_values( array_unique( array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); | |
| 264 | - // Leg-3 (ADR 0014 WP-M5): drop POS-hidden (`online_only`) variations from the served set. A hidden | |
| 265 | - // id simply isn't hydrated → the client's targeted pull returns nothing for it → Leg-3 prunes it. | |
| 266 | - // (Products get the equivalent exclusion via the catalog-proxy `post__not_in` filter.) | |
| 267 | - $ids = ( new Pos_Visibility() )->filter_visible_children( $ids ); | |
| 281 | + /* | |
| 282 | + * The ask runs through the SAME query WooCommerce's own collection read builds | |
| 283 | + * (#1751): `parent::prepare_objects_query()` maps `include` to `post__in` and — in | |
| 284 | + * wc/v3's CRUD controller — applies `woocommerce_rest_product_variation_object_query` | |
| 285 | + * internally, so third-party query scoping reaches this lane like every other | |
| 286 | + * (hook-parity contract #1738). The collection and discovery lanes always had that | |
| 287 | + * property; this lane loaded ids directly and bypassed it. POS visibility and the | |
| 288 | + * publish gate ride the same args (layered in our override). | |
| 289 | + * | |
| 290 | + * The paging/ordering params are PINNED, not honoured: this lane answers a named | |
| 291 | + * ask, so `per_page` covers the whole ask, `offset`/`page` cannot skip any of it | |
| 292 | + * (a skipped id is absent from documents, which the client reads as "prune this | |
| 293 | + * id"), and `orderby=include` keeps WooCommerce from ordering by a meta key whose | |
| 294 | + * EXISTS join would silently drop every variation lacking that meta row. Pinning | |
| 295 | + * `orderby` also keeps the args complete for direct (non-dispatched) invocations, | |
| 296 | + * which carry no route defaults. Served order is the include order either way — | |
| 297 | + * the intersect below is the final authority. | |
| 298 | + */ | |
| 299 | + $include_ids = array_values( array_unique( array_map( 'intval', (array) $request->get_param( 'include' ) ) ) ); | |
| 300 | + // Pins live on a QUERY-ONLY clone: the dispatched request stays exactly | |
| 301 | + // as the client sent it, for the serializer's prepare-filters and for | |
| 302 | + // anything downstream reading it after dispatch. | |
| 303 | + $query_request = clone $request; | |
| 304 | + $query_request->set_param( 'per_page', max( 1, count( $include_ids ) ) ); | |
| 305 | + $query_request->set_param( 'page', 1 ); | |
| 306 | + $query_request->set_param( 'offset', 0 ); | |
| 307 | + $query_request->set_param( 'orderby', 'include' ); | |
| 308 | + $query_request->set_param( 'order', 'asc' ); | |
| 309 | + $args = $this->prepare_objects_query( $query_request ); | |
| 310 | + | |
| 311 | + /* | |
| 312 | + * The ask is a CEILING. WooCommerce's variations controller UNIONS some | |
| 313 | + * collection params into `post__in` (`on_sale=true` array-unions every on-sale | |
| 314 | + * id on top of the ask), so without this intersection a stray param would | |
| 315 | + * hydrate the whole store into the till. No request param or filter may widen | |
| 316 | + * the served set beyond the named ids — narrowing is fine, that is what the | |
| 317 | + * object_query filter and the visibility exclusion are for. An emptied ask pins | |
| 318 | + * to `array( 0 )`, the same never-matches sentinel Pos_Visibility uses. | |
| 319 | + */ | |
| 320 | + $post_in = array_values( array_intersect( array_map( 'intval', (array) ( $args['post__in'] ?? array() ) ), $include_ids ) ); | |
| 321 | + $args['post__in'] = array() === $post_in ? array( 0 ) : $post_in; | |
| 322 | + $results = $this->get_objects( $args ); | |
| 323 | + | |
| 324 | + $allowed_ids = array(); | |
| 325 | + foreach ( $results['objects'] as $object ) { | |
| 326 | + if ( $object instanceof WC_Product_Variation ) { | |
| 327 | + $allowed_ids[] = $object->get_id(); | |
| 328 | + } | |
| 329 | + } | |
| 330 | + $ids = array_values( array_intersect( $include_ids, $allowed_ids ) ); | |
| 268 | 331 | } |
| 269 | 332 | _prime_post_caches( $ids, true, true ); |
| 270 | 333 | |
| 271 | 334 | // Hydrate through THE product assembly line (Product_Serializer), the same |
| @@ -284,10 +347,15 @@ | ||
| 284 | 347 | $digests = class_exists( Digest_Index::class ) |
| 285 | 348 | ? ( new Digest_Index() )->read_digests( 'products', $ids ) |
| 286 | 349 | : array(); |
| 287 | 350 | |
| 288 | - $serialization_request = new WP_REST_Request( 'GET', '/' ); | |
| 289 | - $serializer = new Product_Serializer(); | |
| 351 | + $serializer = new Product_Serializer(); | |
| 352 | + // A CLONE of the live request, not a synthetic bare one (so prepare-filters | |
| 353 | + // see the real request context), and not the live request itself (the | |
| 354 | + // serializer stamps store scope and a per-variation `product_id` onto | |
| 355 | + // whatever it is handed; the dispatched request must leave this method as | |
| 356 | + // the client sent it). | |
| 357 | + $serialization_request = clone $request; | |
| 290 | 358 | $documents = array(); |
| 291 | 359 | foreach ( $ids as $id ) { |
| 292 | 360 | $variation = wc_get_product( $id ); |
| 293 | 361 | if ( ! $variation instanceof WC_Product_Variation ) { |
| @@ -294,15 +362,14 @@ | ||
| 294 | 362 | continue; |
| 295 | 363 | } |
| 296 | 364 | /* |
| 297 | 365 | * DISABLED variations are never hydrated — see the `post_status` note in |
| 298 | - * prepare_objects_query(). The gate lives here as well because the `include` lane does | |
| 299 | - * not build a WP_Query at all: it loads each id directly, so the query-level narrowing | |
| 300 | - * that covers the collection and discovery lanes cannot reach it. | |
| 366 | + * prepare_objects_query(). The query-level publish gate covers ALL lanes, including | |
| 367 | + * `include`; this check only guards a status change between the id query and object load. | |
| 301 | 368 | * |
| 302 | - * Dropping it here (rather than out of $ids) deliberately leaves `meta.requested` | |
| 303 | - * counting the ask: requested > returned is precisely the shortfall the client's | |
| 304 | - * targeted pull reads as "prune this id". | |
| 369 | + * `meta.requested` now counts the query-eligible ask: a disabled or query-filtered id is | |
| 370 | + * absent from $ids. The client's targeted-pull shortfall — absence from documents — is | |
| 371 | + * unchanged. | |
| 305 | 372 | */ |
| 306 | 373 | if ( 'publish' !== $variation->get_status() ) { |
| 307 | 374 | continue; |
| 308 | 375 | } |
| @@ -375,12 +442,20 @@ | ||
| 375 | 442 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not contain more than 100 comma-separated terms', array( 'status' => 400 ) ); |
| 376 | 443 | } |
| 377 | 444 | } else { |
| 378 | 445 | $search = (string) $request->get_param( 'search' ); |
| 379 | - if ( self::MAX_SEARCH_LENGTH < \strlen( $search ) ) { | |
| 380 | - 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 ) ); | |
| 381 | 450 | } |
| 382 | - $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 | + } | |
| 383 | 458 | if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) { |
| 384 | 459 | return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) ); |
| 385 | 460 | } |
| 386 | 461 | } |
| @@ -392,8 +467,35 @@ | ||
| 392 | 467 | return true; |
| 393 | 468 | } |
| 394 | 469 | |
| 395 | 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 | + /** | |
| 396 | 498 | * WooCommerce's collection params, plus the sort keys the POS grids offer. |
| 397 | 499 | * |
| 398 | 500 | * `orderby` is a validated enum. Appending here is what lets `prepare_objects_query()` act on |
| 399 | 501 | * these four — otherwise the request 400s during argument validation and the switch is dead |
| @@ -400,8 +502,9 @@ | ||
| 400 | 502 | * code. 1.9.x extended the same enum for the same reason. |
| 401 | 503 | */ |
| 402 | 504 | public function get_collection_params() { |
| 403 | 505 | $params = parent::get_collection_params(); |
| 506 | + $params['search']['sanitize_callback'] = 'rest_sanitize_request_arg'; | |
| 404 | 507 | |
| 405 | 508 | if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) { |
| 406 | 509 | $params['orderby']['enum'] = array_values( |
| 407 | 510 | array_unique( |
| @@ -406,9 +509,9 @@ | ||
| 406 | 509 | $params['orderby']['enum'] = array_values( |
| 407 | 510 | array_unique( |
| 408 | 511 | array_merge( |
| 409 | 512 | $params['orderby']['enum'], |
| 410 | - array( 'sku', 'barcode', 'stock_quantity', 'stock_status' ) | |
| 513 | + Collection_Rules::orderby_enum( 'variations' ) | |
| 411 | 514 | ) |
| 412 | 515 | ) |
| 413 | 516 | ); |
| 414 | 517 | } |
| @@ -440,24 +543,17 @@ | ||
| 440 | 543 | return true; |
| 441 | 544 | } |
| 442 | 545 | |
| 443 | 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 | + } | |
| 444 | 551 | |
| 445 | - return array() !== (array) preg_split( '/\s+/', trim( $search ), -1, PREG_SPLIT_NO_EMPTY ); | |
| 552 | + return array() !== $terms; | |
| 446 | 553 | } |
| 447 | 554 | |
| 448 | 555 | /** |
| 449 | - * Discover a page of published, POS-visible variation ids by SKU/barcode. | |
| 450 | - * | |
| 451 | - * The query is WooCommerce's — `prepare_objects_query()` + `get_objects()`, the same pair its | |
| 452 | - * own `get_items()` uses. This method previously hand-built the SQL: a `wp_posts`/`wp_postmeta` | |
| 453 | - * INNER JOIN with `LIKE` predicates assembled per (field, term) pair, a second COUNT(DISTINCT) | |
| 454 | - * query for the total, and the hidden-id exclusion spliced into the same placeholder list. All | |
| 455 | - * of it duplicated `WP_Query` — which is where such copies go wrong, quietly and later. | |
| 456 | - * | |
| 457 | - * @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}} | |
| 458 | - */ | |
| 459 | - /** | |
| 460 | 556 | * One page of the POS-servable variation collection, with its total. |
| 461 | 557 | * |
| 462 | 558 | * WooCommerce's query pair, same as {@see search_variation_ids()} — the only difference is that |
| 463 | 559 | * a bare collection request carries no discovery constraint to normalize away, so the |
| @@ -491,8 +587,19 @@ | ||
| 491 | 587 | ), |
| 492 | 588 | ); |
| 493 | 589 | } |
| 494 | 590 | |
| 591 | + /** | |
| 592 | + * Discover a page of published, POS-visible variation ids by SKU/barcode. | |
| 593 | + * | |
| 594 | + * The query is WooCommerce's — `prepare_objects_query()` + `get_objects()`, the same pair its | |
| 595 | + * own `get_items()` uses. This method previously hand-built the SQL: a `wp_posts`/`wp_postmeta` | |
| 596 | + * INNER JOIN with `LIKE` predicates assembled per (field, term) pair, a second COUNT(DISTINCT) | |
| 597 | + * query for the total, and the hidden-id exclusion spliced into the same placeholder list. All | |
| 598 | + * of it duplicated `WP_Query` — which is where such copies go wrong, quietly and later. | |
| 599 | + * | |
| 600 | + * @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}} | |
| 601 | + */ | |
| 495 | 602 | private function search_variation_ids( WP_REST_Request $request ): array { |
| 496 | 603 | $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) ); |
| 497 | 604 | $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ); |
| 498 | 605 | $request->set_param( 'per_page', $per_page ); |