PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
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 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Sync / Collection_Rules_Plan.php

Collection_Rules_Plan.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.19, at includes/Sync/Collection_Rules_Plan.php

1,050 lines 35.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS collection query rules — per-request plan.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use Throwable;
11 use WP_REST_Request;
12
13 use const WCPOS\WooCommercePOS\VERSION;
14
15 /**
16 * One collection read's worth of Collection Rules, resolved against one request.
17 *
18 * Immutable after construction: the params it CLAIMS, the storage dialect it targets and
19 * the sort it owns are all decided once, so no clause body re-reads the request or
20 * re-detects storage halfway through a query.
21 *
22 * # Claims discipline
23 *
24 * Every param is claimed OR forwarded, never both. A claimed param is stripped from the
25 * request the proxy forwards to wc/v3 (`forwarded_params()`), so wc/v3's enum validator
26 * never sees a WCPOS-only `orderby` and its own `search` handling never clobbers an id
27 * set this plan has taken ownership of.
28 *
29 * # Two application modes
30 *
31 * `filter()` also serves legacy callbacks (Pro subclasses them). This method never
32 * touches global filter state; search and visibility use the scoped `around()` path.
33 *
34 * `around()` is the scoped read path and the ONLY path that installs anything. Bindings are
35 * captured as closures — never re-derived tuples — installed, and unwound in reverse
36 * inside a `finally`, so a throwing forward leaves `$wp_filter` exactly as it found it.
37 *
38 * # The WooCommerce-owned sorts
39 *
40 * The HPOS sort writes `ORDER BY` only when WooCommerce left `$clauses['orderby']` empty
41 * — `wcpos/v1`'s guard, kept deliberately. The design called for retiring it on the
42 * theory that a rule which claims a sort owns the ordering outright, but that theory is
43 * false today: `OrdersTableQuery::sanitize_order_orderby()` maps `total` itself (to
44 * `wc_orders.total_amount`, with a sanitized direction), so writing unconditionally would
45 * overwrite a correct WooCommerce clause with our own and change v1's SQL. `status`,
46 * `customer_id` and `payment_method` are absent from that table, so they do reach us
47 * empty. The guard additionally checks that the clause WooCommerce wrote is for the sort
48 * we claimed — on the proxy lane the claimed name is stripped before the forward, so a
49 * non-empty clause there belongs to wc/v3's default sort, not ours.
50 * `Test_Collection_Rules_Guard_HPOS` pins which sort falls on which side, so a future
51 * WooCommerce mapping change fails loudly instead of silently flipping ownership.
52 */
53 final class Collection_Rules_Plan {
54 /**
55 * WC query-args hook — `meta_query` rows contributed by filter rules.
56 *
57 * @var string
58 */
59 public const HOOK_QUERY_ARGS = 'woocommerce_rest_shop_order_object_query';
60
61 /**
62 * The v1 controller's own query-args preparation step — legacy sort args.
63 *
64 * Not a WordPress hook: it is the second half of `prepare_objects_query()`, which
65 * mutates args rather than filtering them. It is dispatched through the same keyed
66 * surface so every clause body lives behind one seam.
67 *
68 * @var string
69 */
70 public const HOOK_PREPARE_ARGS = 'prepare_objects_query';
71
72 /**
73 * Legacy storage — raw id sets appended to the `WHERE` clause.
74 *
75 * @var string
76 */
77 public const HOOK_POSTS_WHERE = 'posts_where';
78
79 /**
80 * Legacy storage — a sort that the WP_Query `orderby` vocabulary cannot express.
81 *
82 * @var string
83 */
84 public const HOOK_POSTS_ORDERBY = 'posts_orderby';
85
86 /**
87 * Legacy storage — a postmeta sort that must not filter the result set.
88 *
89 * @var string
90 */
91 public const HOOK_POSTS_CLAUSES = 'posts_clauses';
92
93 /**
94 * HPOS storage — filter rules, appended to the `WHERE` clause.
95 *
96 * The `woocommerce_orders_table_query_clauses` hook carries two unrelated roles and
97 * v1 registers a separate callback for each, so the keys are suffixed by role; a
98 * single key would make each callback apply both and duplicate the `WHERE` fragment.
99 *
100 * @var string
101 */
102 public const HOOK_HPOS_FILTERS = 'woocommerce_orders_table_query_clauses/filters';
103
104 /**
105 * HPOS storage — the sort, written into the `ORDER BY` clause.
106 *
107 * @var string
108 */
109 public const HOOK_HPOS_ORDERBY = 'woocommerce_orders_table_query_clauses/orderby';
110
111 /**
112 * Collection slug this plan was built for.
113 *
114 * @var string
115 */
116 private $collection;
117
118 /**
119 * Declaration rows for the collection.
120 *
121 * @var array
122 */
123 private $rules;
124
125 /**
126 * Resolved storage dialect.
127 *
128 * @var string
129 */
130 private $storage;
131
132 /**
133 * Claimed canonical name => claimed value.
134 *
135 * @var array<string, mixed>
136 */
137 private $claims = array();
138
139 /**
140 * Request keys the claims were read from, so they can be stripped when forwarding.
141 *
142 * @var string[]
143 */
144 private $claimed_keys = array();
145
146 /**
147 * The canonical sort this plan owns, or null.
148 *
149 * @var string|null
150 */
151 private $sort;
152
153 /**
154 * The raw `order` param, read but never claimed — wc/v3 needs it forwarded.
155 *
156 * @var string|null
157 */
158 private $request_order;
159
160 /**
161 * Literal search phrase.
162 *
163 * @var string
164 */
165 private $search = '';
166
167 /**
168 * Exact SKU lookup, taking precedence over variation search.
169 *
170 * @var string
171 */
172 private $sku = '';
173
174 /**
175 * The lane's declared visibility type.
176 *
177 * @var string|null
178 */
179 private $visibility_type;
180
181 /**
182 * Build a plan. Use `Collection_Rules::for_request()`.
183 *
184 * @internal
185 *
186 * @param string $collection Collection slug.
187 * @param array $rules Declaration rows.
188 * @param string $storage Resolved storage dialect.
189 * @param WP_REST_Request $request Request to claim params from.
190 * @param array $param_map Canonical name => request key.
191 */
192 public function __construct( string $collection, array $rules, string $storage, WP_REST_Request $request, array $param_map ) {
193 $this->collection = $collection;
194 $this->rules = $rules;
195 $lane = 0 === strpos( $request->get_route(), '/wcpos/v1/' ) ? 'direct' : 'proxy';
196 $this->rules['search'] = array_replace( $rules['search'] ?? array(), $rules['search']['lanes'][ $lane ] ?? array() );
197 $this->storage = $storage;
198
199 $order_key = $this->request_key( $param_map, 'order' );
200 $raw_order = null === $order_key ? null : $request->get_param( $order_key );
201 $this->request_order = \is_string( $raw_order ) && '' !== $raw_order ? $raw_order : null;
202
203 $this->claim_sort( $request, $param_map );
204 $this->claim_filters( $request, $param_map );
205 $key = $this->request_key( $param_map, $rules['search']['param'] ?? 'search' );
206 $search = null === $key ? null : $request->get_param( $key );
207 if ( isset( $rules['search'] ) && \is_string( $search ) && '' !== $search ) {
208 $terms = Collection_Rules::search_terms( $search );
209 if ( 'orders' !== $collection || array() !== $terms || false === preg_match( '//u', $search ) ) {
210 $this->search = $search;
211 if ( 'products' === $collection && false !== preg_match( '//u', $search ) ) {
212 $parts = Collection_Rules::search_terms( $search, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE );
213 $last = end( $parts );
214 $this->search = array() === $parts ? '' : substr( $search, $parts[0][1], $last[1] + strlen( $last[0] ) - $parts[0][1] );
215 }
216 $this->claims['search'] = $this->search;
217 $this->claimed_keys[] = $key;
218 }
219 }
220 $sku_param = $this->rules['search']['exact_sku_param'] ?? null;
221 if ( null !== $sku_param ) {
222 $this->sku = trim( (string) ( $request->get_param( $sku_param ) ?? '' ), " \t\n\r\0\x0B," );
223 }
224 $type = $rules['visibility']['type'] ?? null;
225 $this->visibility_type = \is_array( $type ) ? $type[ $lane ] : $type;
226 }
227
228 /**
229 * The collection this plan was built for.
230 *
231 * @return string
232 */
233 public function collection(): string {
234 return $this->collection;
235 }
236
237 /**
238 * The storage dialect this plan targets.
239 *
240 * @return string
241 */
242 public function storage(): string {
243 return $this->storage;
244 }
245
246 /**
247 * Whether this plan contributes nothing (unknown collection, or nothing claimed).
248 *
249 * @return bool
250 */
251 public function is_empty(): bool {
252 return null === $this->sort && array() === $this->claims && null === $this->visibility_type;
253 }
254
255 /**
256 * The canonical sort this plan owns, or null.
257 *
258 * @return string|null
259 */
260 public function sort(): ?string {
261 return $this->sort;
262 }
263
264 /**
265 * Canonical name => claimed value, for every param this plan took ownership of.
266 *
267 * @return array<string, mixed>
268 */
269 public function claims(): array {
270 $claims = $this->claims;
271 if ( null !== $this->sort ) {
272 $claims['orderby'] = $this->sort;
273 }
274
275 return $claims;
276 }
277
278 /**
279 * Strip every claimed request key from a set of query params.
280 *
281 * The complement of `claims()`: what remains is what the proxy forwards to wc/v3.
282 *
283 * @param array $params Query params to narrow.
284 *
285 * @return array
286 */
287 public function forwarded_params( array $params ): array {
288 foreach ( $this->claimed_keys as $key ) {
289 unset( $params[ $key ] );
290 }
291
292 return $params;
293 }
294
295 /**
296 * Apply this plan's clause body for one keyed role.
297 *
298 * Type-preserving: the return type always matches `$value`. An unrecognised key is a
299 * caller bug, reported through `_doing_it_wrong` and passed through unchanged rather
300 * than throwing into the middle of a query.
301 *
302 * @param string $hook One of the `HOOK_*` constants.
303 * @param mixed $value The value to filter (args array, clause string, clauses array).
304 * @param mixed ...$context Hook context — typically the query object, then its args.
305 *
306 * @return mixed
307 */
308 public function filter( string $hook, $value, ...$context ) {
309 $value = $this->apply_read_rule( $hook, $value, $context[0] ?? null );
310 switch ( $hook ) {
311 case 'posts_search':
312 case 'posts_join':
313 case 'posts_groupby':
314 case 'search_orderby':
315 return $value;
316 case self::HOOK_QUERY_ARGS:
317 return \is_array( $value ) ? $this->apply_meta_filters( $value ) : $value;
318
319 case self::HOOK_PREPARE_ARGS:
320 return \is_array( $value ) ? $this->apply_legacy_sort_args( $value ) : $value;
321
322 case self::HOOK_POSTS_WHERE:
323 return \is_string( $value ) ? $this->apply_legacy_id_sets( $value ) : $value;
324
325 case self::HOOK_POSTS_ORDERBY:
326 return \is_string( $value ) ? $this->apply_legacy_sort_clause( $value, $context[0] ?? null ) : $value;
327
328 case self::HOOK_POSTS_CLAUSES:
329 return \is_array( $value ) ? $this->apply_meta_sort_clauses( $value, $context[0] ?? null ) : $value;
330
331 case self::HOOK_HPOS_FILTERS:
332 return \is_array( $value ) ? $this->apply_hpos_filters( $value, $context[0] ?? null ) : $value;
333
334 case self::HOOK_HPOS_ORDERBY:
335 return \is_array( $value ) ? $this->apply_hpos_sort( $value, $context[0] ?? null, $context[1] ?? array() ) : $value;
336 }
337
338 _doing_it_wrong(
339 __METHOD__,
340 esc_html(
341 sprintf(
342 /* translators: %s: the unrecognised Collection Rules hook key. */
343 __( 'Unknown Collection Rules hook "%s"; the value was passed through unchanged.', 'woocommerce-pos' ),
344 $hook
345 )
346 ),
347 esc_html( VERSION )
348 );
349
350 return $value;
351 }
352
353 /**
354 * Install this plan's callbacks, run `$run`, then unwind every binding in reverse.
355 *
356 * The scoped read lanes' ONLY install path. Bindings are closures captured here, so the
357 * unwind removes the exact callables that were added — never a re-derived tuple that
358 * could miss. An exception from `$run` propagates AFTER the unwind.
359 *
360 * @param callable $run The forward to wrap.
361 *
362 * @return mixed Whatever `$run` returns.
363 *
364 * @throws Throwable Re-thrown from `$run`, after the unwind.
365 */
366 public function around( callable $run ) {
367 $bindings = $this->install();
368
369 try {
370 return $run();
371 } finally {
372 foreach ( array_reverse( $bindings ) as $binding ) {
373 remove_filter( $binding[0], $binding[1], $binding[2] );
374 }
375 }
376 }
377
378 /**
379 * Whether this plan claims any non-empty id set.
380 *
381 * Both Read Lanes ask the declaration table this question rather than
382 * testing for the presence of a specific request param, so a new `id_set`
383 * row applies on both lanes or neither.
384 *
385 * Note this is deliberately narrower than `isset( $request['wcpos_include'] )`:
386 * a present-but-empty value claims nothing. That is not a behaviour change —
387 * both clause bodies already skip empty sets (`apply_legacy_id_sets()` iterates
388 * `claimed_id_sets()`, `apply_hpos_filters()` guards on `array() !== $value`),
389 * so installing the callback for an empty set appended nothing anyway.
390 *
391 * @return bool
392 */
393 public function claims_id_sets(): bool {
394 return array() !== $this->claimed_id_sets();
395 }
396
397 /**
398 * Whether the claimed sort needs the legacy `posts_orderby` rewrite.
399 *
400 * Reads the sort's declaration instead of naming a sort inline, so a second
401 * `posts_orderby` recipe added to the table is picked up by both Read Lanes.
402 *
403 * @return bool
404 */
405 public function needs_legacy_posts_orderby(): bool {
406 return null !== $this->sort && isset( $this->rules['sorts'][ $this->sort ]['posts']['posts_orderby'] );
407 }
408
409 /**
410 * Whether the claimed sort is a postmeta sort applied through `posts_clauses`.
411 *
412 * Reads the sort's declaration rather than naming a sort inline, so a `meta_sort`
413 * row added to the table is picked up by every lane that asks.
414 *
415 * @return bool
416 */
417 public function needs_meta_sort(): bool {
418 return Collection_Rules::STORAGE_POSTS === $this->storage
419 && null !== $this->sort
420 && '' !== (string) ( $this->rules['sorts'][ $this->sort ]['posts']['meta_sort']['key'] ?? '' );
421 }
422
423 /**
424 * Attach every callback this plan needs for a proxied forward.
425 *
426 * @return array<int, array{0: string, 1: callable, 2: int}> Bindings, in install order.
427 */
428 private function install(): array {
429 $bindings = array();
430
431 if ( $this->is_empty() ) {
432 return $bindings;
433 }
434
435 if ( null !== $this->visibility_type ) {
436 $hooks = array(
437 self::HOOK_POSTS_WHERE => 10,
438 self::HOOK_POSTS_CLAUSES => 10,
439 );
440 if ( '' !== $this->search ) {
441 $hooks += array(
442 'posts_search' => 10,
443 'posts_join' => 10,
444 'posts_groupby' => 10,
445 'search_orderby' => 20,
446 );
447 }
448 foreach ( $hooks as $role => $priority ) {
449 $hook = 'search_orderby' === $role ? 'posts_clauses' : $role;
450 $callback = function ( $value, $query ) use ( $role ) {
451 $type = 'variations' === $this->collection ? 'product_variation' : 'product';
452 return in_array( $type, (array) ( $query->query_vars['post_type'] ?? null ), true )
453 ? $this->filter( $role, $value, $query ) : $value;
454 };
455 add_filter( $hook, $callback, $priority, 2 );
456 $bindings[] = array( $hook, $callback, $priority );
457 }
458 if ( 'products' === $this->collection ) {
459 $callback = function ( $args ) {
460 return $this->filter( self::HOOK_PREPARE_ARGS, $args );
461 };
462 add_filter( 'woocommerce_rest_product_object_query', $callback );
463 $bindings[] = array( 'woocommerce_rest_product_object_query', $callback, 10 );
464 }
465 if ( '' !== $this->search ) {
466 $vars = static function ( $vars ) {
467 $vars[] = 'wcpos_search_phrase';
468 return $vars;
469 };
470 add_filter( 'woocommerce_rest_query_vars', $vars );
471 $bindings[] = array( 'woocommerce_rest_query_vars', $vars, 10 );
472 }
473 return $bindings;
474 }
475
476 // `meta_query` rows are storage-neutral (`wc_get_orders()` honours them on both),
477 // and the legacy sort args are a no-op under HPOS, so one binding covers both.
478 if ( array() !== $this->claimed_meta_filters() || $this->has_legacy_meta_sort() ) {
479 $args_callback = function ( $args ) {
480 $args = $this->filter( self::HOOK_QUERY_ARGS, $args );
481
482 return $this->filter( self::HOOK_PREPARE_ARGS, $args );
483 };
484 add_filter( self::HOOK_QUERY_ARGS, $args_callback, 10, 1 );
485 $bindings[] = array( self::HOOK_QUERY_ARGS, $args_callback, 10 );
486 }
487
488 if ( Collection_Rules::STORAGE_HPOS === $this->storage ) {
489 // v1 registers the filter callback before the sort callback, both at priority
490 // 10, so the clauses are built in that order. One closure applying them in the
491 // same order produces the identical clause string.
492 $clauses_callback = function ( $clauses, $query = null, $args = array() ) {
493 $clauses = $this->filter( self::HOOK_HPOS_FILTERS, $clauses, $query );
494
495 return $this->filter( self::HOOK_HPOS_ORDERBY, $clauses, $query, $args );
496 };
497 add_filter( 'woocommerce_orders_table_query_clauses', $clauses_callback, 10, 3 );
498 $bindings[] = array( 'woocommerce_orders_table_query_clauses', $clauses_callback, 10 );
499
500 return $bindings;
501 }
502
503 if ( $this->needs_legacy_posts_orderby() ) {
504 $orderby_callback = function ( $orderby, $query = null ) {
505 return $this->filter( self::HOOK_POSTS_ORDERBY, $orderby, $query );
506 };
507 add_filter( 'posts_orderby', $orderby_callback, 10, 2 );
508 $bindings[] = array( 'posts_orderby', $orderby_callback, 10 );
509 }
510
511 if ( $this->claims_id_sets() || '' !== $this->search ) {
512 /*
513 * `posts_where` fires for EVERY WP_Query, and `wcpos/v1` leaves its callback
514 * installed for the remainder of the request without a post-type guard (frozen
515 * behaviour, reproduced verbatim in the clause body). The proxy lane scopes the
516 * binding to this forward AND guards it, so no unrelated query inside the
517 * forward can pick up an order id set.
518 */
519 $where_callback = function ( $where, $query = null ) {
520 $post_type = $query->query_vars['post_type'] ?? null;
521 // Legacy order queries may carry post_type as a string OR an array
522 // (wc_get_order_types() / explicit `type` args); both must match or
523 // the proxy lane drops the id-set clause while v1 still applies it.
524 if ( 'shop_order' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'shop_order', $post_type, true ) ) ) {
525 return $where;
526 }
527
528 return $this->filter( self::HOOK_POSTS_WHERE, $where, $query );
529 };
530 add_filter( 'posts_where', $where_callback, 10, 2 );
531 $bindings[] = array( 'posts_where', $where_callback, 10 );
532 }
533
534 return $bindings;
535 }
536
537 /**
538 * Apply the declared search and visibility bodies without installing hooks.
539 *
540 * @param string $hook Clause role.
541 * @param mixed $value Value to filter.
542 * @param mixed $query Query instance.
543 * @return mixed
544 */
545 private function apply_read_rule( string $hook, $value, $query ) {
546 global $wpdb;
547 $rule = $this->rules['search'] ?? array();
548 $q = $query->query_vars ?? array();
549 if ( self::HOOK_PREPARE_ARGS === $hook && \is_array( $value ) && null !== $this->visibility_type ) {
550 $value = ( new Pos_Visibility() )->apply_to_wp_query_args( $value, $this->collection, null, $this->visibility_type );
551 if ( 'variations' === $this->collection && 'meta_query' === $rule['query'] ) {
552 return Product_Search::variation_args( $value, $this->search, $this->sku, $rule );
553 }
554 if ( '' !== $this->search ) {
555 $value['s'] = $this->search;
556 // Every product/variation lane uses the same literal splitter.
557 $value['wcpos_search_phrase'] = $this->search;
558 }
559 }
560 if ( self::HOOK_POSTS_WHERE === $hook && ! empty( $this->rules['visibility']['where_backstop'] ) ) {
561 $value = ( new Pos_Visibility() )->apply_to_sql_where( $value, "{$wpdb->posts}.ID", $this->visibility_type );
562 }
563 if ( '' === $this->search ) {
564 return $value;
565 }
566 if ( 'orders' === $this->collection ) {
567 if ( self::HOOK_HPOS_FILTERS === $hook ) {
568 $value['where'] .= ' AND ' . Order_Search::hpos_where(
569 $this->search,
570 array(
571 'orders' => $query->get_table_name( 'orders' ),
572 'addresses' => $query->get_table_name( 'addresses' ),
573 ),
574 $rule
575 );
576 } elseif ( self::HOOK_POSTS_WHERE === $hook ) {
577 $value .= ' AND ' . Order_Search::posts_where( $this->search, $rule );
578 }
579 } elseif ( 'variations' === $this->collection && 'wp_terms' === $rule['query'] ) {
580 switch ( $hook ) {
581 case 'posts_search':
582 return Product_Search::variation_posts_search( $value, $q, $rule );
583 case 'posts_join':
584 return empty( $q['s'] ) ? $value : Product_Search::posts_join( $value, $q );
585 case 'posts_groupby':
586 return empty( $q['s'] ) ? $value : Product_Search::posts_groupby( $value, $q );
587 }
588 } elseif ( 'variations' === $this->collection ) {
589 if ( 'posts_groupby' === $hook ) {
590 $value = Product_Search::variation_groupby( $value, $q );
591 }
592 } else {
593 switch ( $hook ) {
594 case 'posts_search':
595 return Product_Search::posts_search( $value, $q, $rule );
596 case 'posts_join':
597 return Product_Search::posts_join( $value, $q );
598 case 'posts_groupby':
599 return Product_Search::posts_groupby( $value, $q );
600 case 'search_orderby':
601 if ( $rule['rank_exact'] ) {
602 $value['orderby'] = Product_Search::posts_orderby( (string) ( $value['orderby'] ?? '' ), $q, $rule );
603 }
604 }
605 }
606 return $value;
607 }
608
609 /**
610 * Claim the `orderby` param when its value names a sort this collection declares.
611 *
612 * @param WP_REST_Request $request Request to read.
613 * @param array $param_map Canonical name => request key.
614 */
615 private function claim_sort( WP_REST_Request $request, array $param_map ): void {
616 $key = $this->request_key( $param_map, 'orderby' );
617 if ( null === $key ) {
618 return;
619 }
620
621 $value = $request->get_param( $key );
622 if ( ! \is_string( $value ) || ! isset( $this->rules['sorts'][ $value ] ) ) {
623 return;
624 }
625
626 $this->sort = $value;
627 $this->claimed_keys[] = $key;
628 }
629
630 /**
631 * Claim every filter param the map exposes and the request carries.
632 *
633 * @param WP_REST_Request $request Request to read.
634 * @param array $param_map Canonical name => request key.
635 */
636 private function claim_filters( WP_REST_Request $request, array $param_map ): void {
637 foreach ( $this->rules['filters'] ?? array() as $canonical => $rule ) {
638 $entry = $param_map[ $canonical ] ?? null;
639 if ( null === $entry ) {
640 continue;
641 }
642 $key = $this->request_key( $param_map, $canonical );
643 if ( null === $key ) {
644 continue;
645 }
646
647 $value = $request->get_param( $key );
648 if ( null === $value ) {
649 continue;
650 }
651
652 if ( \is_array( $entry ) && 'search' === ( $entry['when'] ?? null ) ) {
653 $search = $request->get_param( 'search' );
654 if ( ! \is_string( $search ) || '' === trim( $search ) ) {
655 continue;
656 }
657 }
658
659 $this->claims[ $canonical ] = $this->normalize( $value, $rule, \is_array( $entry ) ? ( $entry['parse'] ?? null ) : null );
660 $this->claimed_keys[] = $key;
661 }
662 }
663
664 /**
665 * Coerce a claimed value into the shape its rule expects.
666 *
667 * @param mixed $value The raw request value.
668 * @param array $rule The filter row.
669 * @param string|null $parse Optional map-declared parser.
670 *
671 * @return mixed
672 */
673 private function normalize( $value, array $rule, ?string $parse ) {
674 if ( isset( $rule['id_set'] ) ) {
675 /*
676 * `wcpos/v1` guards with `! empty()` and then casts with
677 * `array_map( 'intval', (array) $value )`, which collapses a comma-joined string
678 * to its first id. That is frozen wire behaviour, so it stays the default; the
679 * proxy map opts into `wp_parse_id_list` explicitly. Either way an empty result
680 * still counts as CLAIMED — the param is stripped from the forward — it simply
681 * contributes no clause.
682 */
683 if ( 'id_list' === $parse ) {
684 return wp_parse_id_list( $value );
685 }
686
687 return empty( $value ) ? array() : array_map( 'intval', (array) $value );
688 }
689
690 if ( 'key' === ( $rule['sanitize'] ?? null ) ) {
691 if ( \is_array( $value ) ) {
692 return array_map( 'sanitize_key', array_values( $value ) );
693 }
694
695 return sanitize_key( \is_scalar( $value ) ? (string) $value : '' );
696 }
697
698 return $value;
699 }
700
701 /**
702 * Resolve a canonical name to the request key the map exposes it under.
703 *
704 * @param array $param_map Canonical name => request key.
705 * @param string $canonical Canonical name.
706 *
707 * @return string|null Null when the map does not expose the name.
708 */
709 private function request_key( array $param_map, string $canonical ): ?string {
710 $entry = $param_map[ $canonical ] ?? null;
711
712 if ( \is_string( $entry ) && '' !== $entry ) {
713 return $entry;
714 }
715
716 $key = Meta_Entry::key( $entry );
717 if ( \is_array( $entry ) && \is_string( $key ) && '' !== $key ) {
718 return $key;
719 }
720
721 return null;
722 }
723
724 /**
725 * The claimed id-set rules, in declaration order.
726 *
727 * @return array<string, array> Canonical name => filter row.
728 */
729 private function claimed_id_sets(): array {
730 $sets = array();
731 foreach ( $this->rules['filters'] ?? array() as $canonical => $rule ) {
732 if ( isset( $rule['id_set'], $this->claims[ $canonical ] ) && array() !== $this->claims[ $canonical ] ) {
733 $sets[ $canonical ] = $rule;
734 }
735 }
736
737 return $sets;
738 }
739
740 /**
741 * The claimed meta filter rules that apply to this plan's storage, in declaration order.
742 *
743 * @return array<string, array> Canonical name => filter row.
744 */
745 private function claimed_meta_filters(): array {
746 $metas = array();
747 foreach ( $this->rules['filters'] ?? array() as $canonical => $rule ) {
748 if ( ! isset( $rule['meta'], $this->claims[ $canonical ] ) ) {
749 continue;
750 }
751 if ( isset( $rule['meta']['storage'] ) && $rule['meta']['storage'] !== $this->storage ) {
752 continue;
753 }
754 if ( array() === $this->claims[ $canonical ] ) {
755 continue;
756 }
757 $metas[ $canonical ] = $rule;
758 }
759
760 return $metas;
761 }
762
763 /**
764 * Whether this plan's sort is expressed as a legacy `meta_key` sort.
765 *
766 * @return bool
767 */
768 private function has_legacy_meta_sort(): bool {
769 return Collection_Rules::STORAGE_POSTS === $this->storage
770 && null !== $this->sort
771 && isset( $this->rules['sorts'][ $this->sort ]['posts']['meta_key'] );
772 }
773
774 /**
775 * Contribute `meta_query` rows for every claimed meta filter.
776 *
777 * Storage-neutral: `wc_get_orders()` honours `meta_query` on both storages.
778 *
779 * @param array $args WC REST query args.
780 *
781 * @return array
782 */
783 private function apply_meta_filters( array $args ): array {
784 foreach ( $this->claimed_meta_filters() as $canonical => $rule ) {
785 $args['meta_query'][] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The POS cashier/store/channel filters are meta-backed by design.
786 'key' => $rule['meta']['key'],
787 'value' => $this->claims[ $canonical ],
788 );
789 }
790
791 return $args;
792 }
793
794 /**
795 * Map a claimed sort onto legacy storage's `meta_key` / `orderby` query args.
796 *
797 * @param array $args WC REST query args.
798 *
799 * @return array
800 */
801 private function apply_legacy_sort_args( array $args ): array {
802 if ( Collection_Rules::STORAGE_POSTS !== $this->storage || null === $this->sort ) {
803 return $args;
804 }
805
806 $rule = $this->rules['sorts'][ $this->sort ]['posts'] ?? array();
807 if ( ! isset( $rule['meta_key'], $rule['orderby'] ) ) {
808 return $args;
809 }
810
811 $args['meta_key'] = $rule['meta_key']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Meta sorts are the only encoding legacy order storage has.
812 $args['orderby'] = $rule['orderby'];
813
814 return $args;
815 }
816
817 /**
818 * Append claimed id sets to a legacy `WHERE` clause.
819 *
820 * @param string $where The `WHERE` clause so far.
821 *
822 * @return string
823 */
824 private function apply_legacy_id_sets( string $where ): string {
825 global $wpdb;
826
827 if ( Collection_Rules::STORAGE_POSTS !== $this->storage ) {
828 return $where;
829 }
830
831 foreach ( $this->claimed_id_sets() as $canonical => $rule ) {
832 $ids = $this->claims[ $canonical ];
833 $ids_format = implode( ',', array_fill( 0, \count( $ids ), '%d' ) );
834 $operator = $rule['id_set']['operator'];
835 $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID {$operator} ($ids_format) ", $ids ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $operator comes from the declaration table and $ids_format is generated from array_fill with %d placeholders.
836 }
837
838 return $where;
839 }
840
841 /**
842 * Rewrite a legacy `ORDER BY` clause for a sort WP_Query cannot express.
843 *
844 * @param string $orderby The `ORDER BY` clause so far.
845 * @param mixed $query The WP_Query instance.
846 *
847 * @return string
848 */
849 private function apply_legacy_sort_clause( string $orderby, $query ): string {
850 global $wpdb;
851
852 if ( Collection_Rules::STORAGE_POSTS !== $this->storage || null === $this->sort ) {
853 return $orderby;
854 }
855
856 $column = $this->rules['sorts'][ $this->sort ]['posts']['posts_orderby'] ?? null;
857 if ( null === $column ) {
858 return $orderby;
859 }
860
861 $post_type = $query->query_vars['post_type'] ?? null;
862 if ( 'shop_order' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'shop_order', $post_type, true ) ) ) {
863 return $orderby;
864 }
865
866 $order = $this->resolve_order( $query );
867
868 return "{$wpdb->posts}.{$column} {$order}";
869 }
870
871 /**
872 * Sort on a postmeta value without letting the sort decide which rows exist.
873 *
874 * WP_Query's `meta_key` + `orderby => meta_value` pair INNER JOINs `postmeta`, so a
875 * row with no value for the key is DROPPED — a sort silently acting as a filter. On a
876 * default store that made `orderby=barcode` answer with an empty page (the barcode
877 * field defaults to `_global_unique_id`, which most catalogues never populate) and
878 * `orderby=sku` hide every product without a SKU. A cashier sorting a column expects
879 * the same products in a different order, never fewer, so the join is LEFT and the
880 * rows with no value are ordered LAST whichever way the column runs — MySQL would
881 * otherwise float them to the top under ASC.
882 *
883 * The `ID` tiebreak makes the order total, so the rows that share a value (or share
884 * having none) cannot swap places between two pages of the same walk.
885 *
886 * @param array $clauses The query clauses so far.
887 * @param mixed $query The WP_Query instance.
888 *
889 * @return array
890 */
891 private function apply_meta_sort_clauses( array $clauses, $query ): array {
892 global $wpdb;
893
894 if ( ! $this->needs_meta_sort() ) {
895 return $clauses;
896 }
897
898 $rule = $this->rules['sorts'][ $this->sort ]['posts']['meta_sort'];
899 $alias = 'wcpos_sort_meta';
900
901 // One join per query: `posts_clauses` can run more than once for a single
902 // WP_Query when another filter re-enters it.
903 if ( false === strpos( (string) ( $clauses['join'] ?? '' ), $alias ) ) {
904 $clauses['join'] = (string) ( $clauses['join'] ?? '' ) . $wpdb->prepare(
905 " LEFT JOIN {$wpdb->postmeta} AS {$alias} ON ( {$alias}.post_id = {$wpdb->posts}.ID AND {$alias}.meta_key = %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names and the generated alias only; the meta key is bound.
906 (string) $rule['key']
907 );
908 }
909
910 // A duplicate meta row for the same key would otherwise repeat the product.
911 if ( '' === (string) ( $clauses['groupby'] ?? '' ) ) {
912 $clauses['groupby'] = "{$wpdb->posts}.ID";
913 }
914
915 $order = $this->resolve_order( $query );
916 $value = empty( $rule['numeric'] ) ? "{$alias}.meta_value" : "{$alias}.meta_value + 0";
917
918 $clauses['orderby'] = "( {$alias}.meta_value IS NULL OR {$alias}.meta_value = '' ) ASC, {$value} {$order}, {$wpdb->posts}.ID ASC";
919
920 return $clauses;
921 }
922
923 /**
924 * The sort direction a legacy clause body should write.
925 *
926 * Taken from the query WooCommerce built, exactly as the HPOS sort takes it from that
927 * query's args — one derivation for both storages and both Read Lanes.
928 * `WP_Query::get_posts()` normalises `order` (upper-cased, defaulting to DESC) before
929 * the clause filters fire, and it is populated from the same request `order` param v1
930 * used to read directly, so this is byte-identical on the direct lane while giving the
931 * proxy lane the same answer instead of its own hard-coded default. The terminal `ASC`
932 * is v1's own fallback, reached only if nothing at all supplied a direction.
933 *
934 * @param mixed $query The WP_Query instance.
935 *
936 * @return string Either `ASC` or `DESC`.
937 */
938 private function resolve_order( $query ): string {
939 $order = $query->query_vars['order'] ?? $this->request_order ?? 'ASC';
940 $order = \is_scalar( $order ) ? strtoupper( (string) $order ) : 'ASC';
941
942 // $request_order is the RAW request param — it feeds SQL text, so it must never
943 // carry anything but the two legal directions.
944 return \in_array( $order, array( 'ASC', 'DESC' ), true ) ? $order : 'ASC';
945 }
946
947 /**
948 * Append claimed filters to the HPOS clause set.
949 *
950 * @param array $clauses The HPOS query clauses.
951 * @param mixed $query The OrdersTableQuery instance.
952 *
953 * @return array
954 */
955 private function apply_hpos_filters( array $clauses, $query ): array {
956 global $wpdb;
957
958 if ( Collection_Rules::STORAGE_HPOS !== $this->storage || ! \is_object( $query ) || ! method_exists( $query, 'get_table_name' ) ) {
959 return $clauses;
960 }
961
962 $orders = $query->get_table_name( 'orders' );
963
964 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table names come from WooCommerce; placeholder lists are generated per value below.
965 foreach ( $this->rules['filters'] ?? array() as $canonical => $rule ) {
966 if ( ! isset( $this->claims[ $canonical ] ) ) {
967 continue;
968 }
969 $value = $this->claims[ $canonical ];
970
971 if ( isset( $rule['hpos_data'] ) ) {
972 $values = array_values( (array) $value );
973 if ( array() === $values ) {
974 continue;
975 }
976 $table = $query->get_table_name( $rule['hpos_data']['table'] );
977 $column = $rule['hpos_data']['column'];
978 $placeholders = implode( ', ', array_fill( 0, \count( $values ), '%s' ) );
979 $clauses['where'] .= $wpdb->prepare( " AND {$orders}.id IN (SELECT order_id FROM {$table} WHERE {$column} IN ({$placeholders}))", ...$values );
980
981 continue;
982 }
983
984 if ( isset( $rule['id_set'] ) && array() !== $value ) {
985 $clauses['where'] .= ' AND ' . $orders . '.id ' . $rule['id_set']['operator'] . ' (' . implode( ',', array_map( 'intval', $value ) ) . ')';
986 }
987 }
988 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
989
990 return $clauses;
991 }
992
993 /**
994 * Write the claimed sort into the HPOS `ORDER BY` clause.
995 *
996 * Deferential by design — see "the WooCommerce-owned sorts" in the class docblock.
997 *
998 * @param array $clauses The HPOS query clauses.
999 * @param mixed $query The OrdersTableQuery instance.
1000 * @param array $args The query args.
1001 *
1002 * @return array
1003 */
1004 private function apply_hpos_sort( array $clauses, $query, array $args ): array {
1005 if ( Collection_Rules::STORAGE_HPOS !== $this->storage || null === $this->sort ) {
1006 return $clauses;
1007 }
1008
1009 /*
1010 * WooCommerce maps SOME of these names itself (`total` is in
1011 * `OrdersTableQuery::sanitize_order_orderby()`'s table today), and when it does it
1012 * has already written a correct clause with a properly sanitized direction — so we
1013 * defer, exactly as v1's guard did.
1014 *
1015 * The `orderby` conjunct is what makes that guard correct on BOTH lanes. v1 leaves
1016 * the claimed name on the request, so a non-empty clause is always WooCommerce
1017 * mapping OUR sort (the conjunct is redundant there, and v1's SQL is unchanged).
1018 * The proxy must STRIP the claimed name — wc/v3's enum would 400 on it — so the
1019 * inner query carries wc/v3's default `date` instead, and its non-empty clause has
1020 * nothing to do with the sort the client asked for. Testing the bare emptiness
1021 * there would silently drop the sort.
1022 *
1023 * `Test_Collection_Rules_Guard_HPOS` pins which sorts fall on which side.
1024 */
1025 $woocommerce_mapped_our_sort = ( $args['orderby'] ?? null ) === $this->sort;
1026 if ( $woocommerce_mapped_our_sort && isset( $clauses['orderby'] ) && '' !== $clauses['orderby'] ) {
1027 return $clauses;
1028 }
1029 if ( ! \is_object( $query ) || ! method_exists( $query, 'get_table_name' ) ) {
1030 return $clauses;
1031 }
1032
1033 $column = $this->rules['sorts'][ $this->sort ]['hpos']['column'] ?? null;
1034 if ( null === $column ) {
1035 return $clauses;
1036 }
1037
1038 // v1 verbatim: the direction comes from the query args WooCommerce built from the
1039 // request (which carries wc/v3's own `order` default), falling back to ASC.
1040 // Whitelisted before interpolation — same defense as the legacy path. Legal
1041 // values pass through byte-verbatim (the clause goldens pin the casing).
1042 $order = $args['order'] ?? 'ASC';
1043 $order = \is_scalar( $order ) ? (string) $order : 'ASC';
1044 $order = \in_array( strtoupper( $order ), array( 'ASC', 'DESC' ), true ) ? $order : 'ASC';
1045 $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.' . $column . ' ' . $order;
1046
1047 return $clauses;
1048 }
1049 }
1050