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.26 1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 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 All 170 releases
← All changes | includes/API/V2/Variations_Controller.php +176 -69 1.10.2 → 1.10.17 View file →
@@ -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 );