PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.12 1.9.11 All 160 releases
woocommerce-pos / includes / Sync / Collection_Rules_Plan.php

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

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