PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / API / V2 / Variations_Controller.php

Variations_Controller.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.17, at includes/API/V2/Variations_Controller.php

651 lines 27.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync read surface.
4 *
5 * @package WCPOS\WooCommercePOS\API\V2
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V2;
9
10 use WC_Product_Variation;
11 use WC_REST_Product_Variations_Controller;
12 use WCPOS\WooCommercePOS\Services\Barcode_Field;
13 use WCPOS\WooCommercePOS\Sync\Api;
14 use WCPOS\WooCommercePOS\Sync\Collection_Rules;
15 use WCPOS\WooCommercePOS\Sync\Collection_Rules_Plan;
16 use WCPOS\WooCommercePOS\Sync\Digest_Index;
17 use WCPOS\WooCommercePOS\Sync\Endpoint_Permissions;
18 use WCPOS\WooCommercePOS\Sync\Pos_Visibility;
19 use WCPOS\WooCommercePOS\Sync\Product_Serializer;
20 use WP_Error;
21 use WP_Query;
22 use WP_REST_Request;
23 use WP_REST_Response;
24 use WP_REST_Server;
25
26 // phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim.
27
28 /**
29 * Variations document endpoint — the collection's hydration AND list/seed lane (ADR 0034).
30 *
31 * Why a flat route: the change-signal yields BARE variation ids (no parent), and WooCommerce's
32 * only variation routes are parent-mediated (`products/<parent>/variations`). One flat route
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.
36 *
37 * Why it EXTENDS WooCommerce's variations controller: because that is all the route ever needed.
38 * WooCommerce's `get_objects()` already answers a cross-parent query — with no `product_id` in
39 * the route there is no parent constraint, and `include`/`search`/`orderby`/pagination are its
40 * own collection params. 1.9.x did exactly this: `parent::get_items( $request )`, one line
41 * (`API\V1\Product_Variations_Controller::wcpos_get_all_items`).
42 *
43 * The previous version of this class extended a bare `WP_REST_Controller` and rebuilt the query
44 * by hand — ~90 lines of raw postmeta SQL, five hand-declared args, no item schema — on the
45 * stated grounds that "wc/v3 has no cross-parent variations?include=". That claim was false, and
46 * the cost of acting on it was the payload: a variation hydrated through the PRODUCTS controller
47 * carries `images[]` instead of `image`, which blanked every variation thumbnail in the POS on
48 * 1.10.0 and wrote the parent's image onto every order line (#1710).
49 *
50 * What stays ours, and only this: the sync document envelope the engine reads
51 * (`documents[].{id,parent_id,payload,_rxdb_digest}`), POS visibility, the barcode carrier
52 * search, and the request bounds. Everything else is WooCommerce's.
53 */
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
72 use Endpoint_Permissions;
73
74 private const MAX_SKU_LENGTH = 4096;
75 private const MAX_SKU_TERMS = 100;
76 private const MAX_SEARCH_LENGTH = 256;
77 private const MAX_SEARCH_TERMS = 10;
78 private const MAX_PAGE = 1000;
79
80
81 public function register_routes(): void {
82 /*
83 * ONLY the flat sync route. `parent::register_routes()` is deliberately not called: the
84 * v2 namespace is a read/sync surface, and writes ride Write_Controller, which already
85 * pushes through WooCommerce's nested routes. Registering WC's CRUD routes here would
86 * widen the POS-marker-gated surface for no consumer.
87 *
88 * The args and the schema are WooCommerce's own, so `include`, `search`, `orderby`,
89 * `order`, `offset`, `page`, `per_page`, `status` … all behave exactly as they do on
90 * wc/v3, and the route documents itself in the REST index.
91 */
92 register_rest_route(
93 Api::ROUTE_NAMESPACE,
94 '/variations',
95 array(
96 array(
97 'methods' => WP_REST_Server::READABLE,
98 'callback' => array( $this, 'get_variations' ),
99 'permission_callback' => array( $this, 'permissions_check' ),
100 'args' => $this->get_collection_params(),
101 ),
102 'schema' => array( $this, 'get_public_item_schema' ),
103 )
104 );
105 }
106
107 /**
108 * Narrow WooCommerce's variation query to what the POS may serve.
109 *
110 * Everything WooCommerce already understands — `include`, `offset`, `order`, pagination,
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
116 * barcode-carrier search, and the sort keys the POS grids offer. This is the seam 1.9.x used
117 * for the same job (`API\V1\Product_Variations_Controller::prepare_objects_query`).
118 *
119 * @param WP_REST_Request $request Full details about the request.
120 *
121 * @return array
122 */
123 protected function prepare_objects_query( $request ) {
124 /*
125 * WooCommerce splits `sku` on commas without trimming, so `sku=A, B` looks for " B".
126 * Normalize before it sees the param rather than reimplementing its matching.
127 */
128 $sku = (string) ( $request->get_param( 'sku' ) ?? '' );
129 if ( '' !== $sku ) {
130 $terms = array_values(
131 array_filter(
132 array_map( 'trim', explode( ',', $sku ) ),
133 static function ( string $term ): bool {
134 return '' !== $term;
135 }
136 )
137 );
138 $sku = implode( ',', $terms );
139 $request->set_param( 'sku', $sku );
140 }
141
142 $args = parent::prepare_objects_query( $request );
143
144 /*
145 * A product is not a variation document.
146 *
147 * WooCommerce widens `post_type` to `array( 'product', 'product_variation' )` whenever
148 * `sku` is set, because the two share one SKU space. On THIS route that would serve a
149 * simple product as a variation — and the client would file it into its variations
150 * collection, the mirror image of the misfiled-variation pollution it already carries a
151 * one-shot repair for. Type purity on a variations route is ours to enforce.
152 */
153 $args['post_type'] = $this->post_type;
154
155 /*
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 * This route only ever offers what the store owner has for sale — on EVERY lane, including
198 * `include`.
199 *
200 * WooCommerce's Enabled checkbox on the variation metabox writes `post_status = private`
201 * when unchecked ({@see \WC_Meta_Box_Product_Data::save_variations()}), and WooCommerce
202 * honours that everywhere a customer can reach: `get_visible_children()` and
203 * `get_available_variations()` both drop it. A cashier must not be able to sell a variation
204 * the owner switched off, so the POS behaves the same way.
205 *
206 * The `include` lane is NOT exempt. Being asked for an id by name is not evidence the owner
207 * wants it sold: the client learns those ids from the parent's `variations[]`, which
208 * WooCommerce fills from `get_children()` — publish AND private — and from the change
209 * signal, which journals a disabled variation like any other post. A disabled id simply is
210 * not hydrated, the client's targeted-pull shortfall prunes it, and it leaves every till.
211 * Re-enabling saves the variation, which journals it, and it comes back.
212 *
213 * Set after `parent::prepare_objects_query()` so an explicit `status` param cannot widen it.
214 */
215 $args['post_status'] = 'publish';
216
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 $this->wcpos_sort_request = $request;
242 add_filter( 'posts_clauses', array( $this, 'wcpos_posts_clauses' ), 10, 2 );
243
244 return $args;
245 }
246
247 /**
248 * GET /variations — the flat collection's three lanes, one response shape.
249 *
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).
258 */
259 public function get_variations( WP_REST_Request $request ) {
260 $started = microtime( true );
261 $search_meta = null;
262 if ( $request->has_param( 'sku' ) || $request->has_param( 'search' ) ) {
263 $validation = $this->validate_search_request( $request );
264 if ( is_wp_error( $validation ) ) {
265 return $validation;
266 }
267 list( $ids, $search_meta ) = $this->search_variation_ids( $request );
268 } elseif ( array() === array_filter( (array) $request->get_param( 'include' ) ) ) {
269 /*
270 * A bare collection request — no `include`, no discovery term — answers page one of the
271 * POS-servable set with WooCommerce's own pagination, exactly as its `get_items()` would.
272 *
273 * This used to be a 400. That refusal is why the client still counts variations on the
274 * FROZEN `wcpos/v1` lane — the single remaining v1 call in the app — because the census
275 * probes a collection route and reads `X-WP-Total`, and no v2 variations route could
276 * answer "how many". Refusing the question was never a safety property: `include` is a
277 * filter, and a collection route with no filter is a collection.
278 */
279 list( $ids, $search_meta ) = $this->collection_page( $request );
280 } else {
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 ) );
331 }
332 _prime_post_caches( $ids, true, true );
333
334 // Hydrate through THE product assembly line (Product_Serializer), the same
335 // seam resolve/changes use (ADR 0003 — values come from the REST
336 // representation, never raw SQL). wc_get_product() returns a
337 // WC_Product_Variation for a variation id; the instanceof guard keeps a
338 // product id from being hydrated through this lane.
339 // Leg-3 (ADR 0014): attach each variation's stored 64-bit digest as `_rxdb_digest` so the client
340 // seeds its existence-reconcile manifest from this pull too (products get theirs via the proxy
341 // filter). Bulk-read once for the whole include set. A string — the digest exceeds int range.
342 // ::class, never the bare string: from inside this namespace
343 // class_exists( 'Digest_Index' ) probes the GLOBAL namespace and is
344 // forever false, so variation digests would never emit (review finding 3).
345 // Variations read the PRODUCTS id-space — one registry row owns both
346 // object types, so this lane cannot drift from the proxy lane's answer.
347 $digests = class_exists( Digest_Index::class )
348 ? ( new Digest_Index() )->read_digests( 'products', $ids )
349 : array();
350
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;
358 $documents = array();
359 foreach ( $ids as $id ) {
360 $variation = wc_get_product( $id );
361 if ( ! $variation instanceof WC_Product_Variation ) {
362 continue;
363 }
364 /*
365 * DISABLED variations are never hydrated — see the `post_status` note in
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.
368 *
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.
372 */
373 if ( 'publish' !== $variation->get_status() ) {
374 continue;
375 }
376 $payload = $serializer->serialize( $variation, $serialization_request );
377 $document = array(
378 'id' => $id,
379 'parent_id' => (int) $variation->get_parent_id(),
380 'payload' => $payload,
381 );
382 if ( isset( $digests[ $id ] ) ) {
383 $document['_rxdb_digest'] = $digests[ $id ];
384 }
385 $documents[] = $document;
386 }
387
388 $meta = array(
389 'duration_ms' => round( ( microtime( true ) - $started ) * 1000, 3 ),
390 'requested' => \count( $ids ),
391 'returned' => \count( $documents ),
392 );
393 if ( null !== $search_meta ) {
394 $meta = array_merge( $meta, $search_meta );
395 }
396
397 $response = rest_ensure_response(
398 array(
399 'documents' => $documents,
400 'meta' => $meta,
401 )
402 );
403
404 /*
405 * The pagination WooCommerce would have sent.
406 *
407 * No v2 route emitted `X-WP-Total`/`X-WP-TotalPages` — including this one, the only one
408 * that paginates. The client asks for them on every v2 GET (the response envelope mirrors
409 * them into the body), so it has been receiving an empty mirror and falling back to
410 * short-page detection, which cannot tell "last page" from "the server truncated".
411 */
412 if ( null !== $search_meta && $response instanceof WP_REST_Response ) {
413 $response->header( 'X-WP-Total', (string) $search_meta['total'] );
414 $response->header(
415 'X-WP-TotalPages',
416 (string) ( $search_meta['per_page'] > 0 ? (int) ceil( $search_meta['total'] / $search_meta['per_page'] ) : 0 )
417 );
418 }
419
420 return $response;
421 }
422
423 /**
424 * Reject search requests that could build excessively large SQL queries or offsets.
425 *
426 * @return true|WP_Error
427 */
428 private function validate_search_request( WP_REST_Request $request ) {
429 $sku = (string) ( $request->get_param( 'sku' ) ?? '' );
430 $skus = array_filter(
431 array_map( 'trim', explode( ',', $sku ) ),
432 static function ( string $term ): bool {
433 return '' !== $term;
434 }
435 );
436 if ( array() !== $skus ) {
437 $sku = implode( ',', $skus );
438 if ( self::MAX_SKU_LENGTH < \strlen( $sku ) ) {
439 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not exceed 4096 bytes', array( 'status' => 400 ) );
440 }
441 if ( self::MAX_SKU_TERMS < \count( $skus ) ) {
442 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'sku must not contain more than 100 comma-separated terms', array( 'status' => 400 ) );
443 }
444 } else {
445 $search = (string) $request->get_param( 'search' );
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 ) );
450 }
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 }
458 if ( self::MAX_SEARCH_TERMS < \count( $terms ) ) {
459 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'search must not contain more than 10 whitespace-separated terms', array( 'status' => 400 ) );
460 }
461 }
462
463 if ( self::MAX_PAGE < (int) $request->get_param( 'page' ) ) {
464 return new WP_Error( 'woocommerce_pos_variations_search_limit_exceeded', 'page must not exceed 1000', array( 'status' => 400 ) );
465 }
466
467 return true;
468 }
469
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 /**
498 * WooCommerce's collection params, plus the sort keys the POS grids offer.
499 *
500 * `orderby` is a validated enum. Appending here is what lets `prepare_objects_query()` act on
501 * these four — otherwise the request 400s during argument validation and the switch is dead
502 * code. 1.9.x extended the same enum for the same reason.
503 */
504 public function get_collection_params() {
505 $params = parent::get_collection_params();
506 $params['search']['sanitize_callback'] = 'rest_sanitize_request_arg';
507
508 if ( isset( $params['orderby']['enum'] ) && \is_array( $params['orderby']['enum'] ) ) {
509 $params['orderby']['enum'] = array_values(
510 array_unique(
511 array_merge(
512 $params['orderby']['enum'],
513 Collection_Rules::orderby_enum( 'variations' )
514 )
515 )
516 );
517 }
518
519 return $params;
520 }
521
522 /**
523 * De-duplicate variation searches joined through matching meta rows.
524 *
525 * @param string $groupby Existing GROUP BY clause.
526 * @param WP_Query $query Query being filtered.
527 */
528 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;
532 }
533
534 /**
535 * Does this discovery request still carry a term after normalization?
536 *
537 * `has_param()` is what selects discovery mode, and an empty or whitespace-only value passes
538 * it. This is the check that decides whether a query would actually be constrained.
539 */
540 private function has_discovery_constraint( WP_REST_Request $request ): bool {
541 $sku = (string) ( $request->get_param( 'sku' ) ?? '' );
542 if ( '' !== trim( $sku, " \t\n\r\0\x0B," ) ) {
543 return true;
544 }
545
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 }
551
552 return array() !== $terms;
553 }
554
555 /**
556 * One page of the POS-servable variation collection, with its total.
557 *
558 * WooCommerce's query pair, same as {@see search_variation_ids()} — the only difference is that
559 * a bare collection request carries no discovery constraint to normalize away, so the
560 * blank-scan guard that turns `?search=%20` into zero rows must NOT apply here. Visibility and
561 * `post_status` narrowing both ride `prepare_objects_query()`, so this page counts exactly what
562 * the client is allowed to receive.
563 *
564 * @return array{0: array<int, int>, 1: array{total: int, page: int, per_page: int}}
565 */
566 private function collection_page( WP_REST_Request $request ): array {
567 $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) );
568 $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
569 $request->set_param( 'per_page', $per_page );
570 $request->set_param( 'page', $page );
571
572 $results = $this->get_objects( $this->prepare_objects_query( $request ) );
573
574 $ids = array();
575 foreach ( $results['objects'] as $object ) {
576 if ( $object instanceof WC_Product_Variation ) {
577 $ids[] = $object->get_id();
578 }
579 }
580
581 return array(
582 $ids,
583 array(
584 'total' => (int) ( $results['total'] ?? \count( $ids ) ),
585 'page' => $page,
586 'per_page' => $per_page,
587 ),
588 );
589 }
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 */
602 private function search_variation_ids( WP_REST_Request $request ): array {
603 $per_page = max( 1, min( 100, (int) ( $request->get_param( 'per_page' ) ?? 10 ) ) );
604 $page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
605 $request->set_param( 'per_page', $per_page );
606 $request->set_param( 'page', $page );
607
608 $query_args = $this->prepare_objects_query( $request );
609
610 /*
611 * A discovery request whose terms normalize away — `?sku=`, `?sku=,%20`, `?search=%20` —
612 * has no constraint left. Inherited, that query would return the FIRST PAGE OF EVERY
613 * VARIATION and advertise the catalogue-wide total; the replaced SQL deliberately used
614 * `1 = 0`. A blank scan must hydrate nothing, not everything.
615 */
616 if ( ! $this->has_discovery_constraint( $request ) ) {
617 return array(
618 array(),
619 array(
620 'total' => 0,
621 'page' => $page,
622 'per_page' => $per_page,
623 ),
624 );
625 }
626
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 }
633
634 $ids = array();
635 foreach ( $results['objects'] as $object ) {
636 if ( $object instanceof WC_Product_Variation ) {
637 $ids[] = $object->get_id();
638 }
639 }
640
641 return array(
642 $ids,
643 array(
644 'total' => (int) ( $results['total'] ?? \count( $ids ) ),
645 'page' => $page,
646 'per_page' => $per_page,
647 ),
648 );
649 }
650 }
651