PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
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.0, at includes/Sync/Collection_Rules_Plan.php

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