| 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 |
|