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