| @@ -27,13 +27,12 @@ | ||
| 27 | 27 | * set this plan has taken ownership of. |
| 28 | 28 | * |
| 29 | 29 | * # Two application modes |
| 30 | 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. | |
| 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. | |
| 34 | 33 | * |
| 35 | - * `around()` is the proxy lane and the ONLY path that installs anything. Bindings are | |
| 34 | + * `around()` is the scoped read path and the ONLY path that installs anything. Bindings are | |
| 36 | 35 | * captured as closures — never re-derived tuples — installed, and unwound in reverse |
| 37 | 36 | * inside a `finally`, so a throwing forward leaves `$wp_filter` exactly as it found it. |
| 38 | 37 | * |
| 39 | 38 | * # The WooCommerce-owned sorts |
| @@ -84,8 +83,15 @@ | ||
| 84 | 83 | */ |
| 85 | 84 | public const HOOK_POSTS_ORDERBY = 'posts_orderby'; |
| 86 | 85 | |
| 87 | 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 | + /** | |
| 88 | 94 | * HPOS storage — filter rules, appended to the `WHERE` clause. |
| 89 | 95 | * |
| 90 | 96 | * The `woocommerce_orders_table_query_clauses` hook carries two unrelated roles and |
| 91 | 97 | * v1 registers a separate callback for each, so the keys are suffixed by role; a |
| @@ -151,8 +157,29 @@ | ||
| 151 | 157 | */ |
| 152 | 158 | private $request_order; |
| 153 | 159 | |
| 154 | 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 | + /** | |
| 155 | 182 | * Build a plan. Use `Collection_Rules::for_request()`. |
| 156 | 183 | * |
| 157 | 184 | * @internal |
| 158 | 185 | * |
| @@ -164,8 +191,10 @@ | ||
| 164 | 191 | */ |
| 165 | 192 | public function __construct( string $collection, array $rules, string $storage, WP_REST_Request $request, array $param_map ) { |
| 166 | 193 | $this->collection = $collection; |
| 167 | 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() ); | |
| 168 | 197 | $this->storage = $storage; |
| 169 | 198 | |
| 170 | 199 | $order_key = $this->request_key( $param_map, 'order' ); |
| 171 | 200 | $raw_order = null === $order_key ? null : $request->get_param( $order_key ); |
| @@ -172,8 +201,29 @@ | ||
| 172 | 201 | $this->request_order = \is_string( $raw_order ) && '' !== $raw_order ? $raw_order : null; |
| 173 | 202 | |
| 174 | 203 | $this->claim_sort( $request, $param_map ); |
| 175 | 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; | |
| 176 | 226 | } |
| 177 | 227 | |
| 178 | 228 | /** |
| 179 | 229 | * The collection this plan was built for. |
| @@ -198,9 +248,9 @@ | ||
| 198 | 248 | * |
| 199 | 249 | * @return bool |
| 200 | 250 | */ |
| 201 | 251 | public function is_empty(): bool { |
| 202 | - return null === $this->sort && array() === $this->claims; | |
| 252 | + return null === $this->sort && array() === $this->claims && null === $this->visibility_type; | |
| 203 | 253 | } |
| 204 | 254 | |
| 205 | 255 | /** |
| 206 | 256 | * The canonical sort this plan owns, or null. |
| @@ -255,9 +305,15 @@ | ||
| 255 | 305 | * |
| 256 | 306 | * @return mixed |
| 257 | 307 | */ |
| 258 | 308 | public function filter( string $hook, $value, ...$context ) { |
| 309 | + $value = $this->apply_read_rule( $hook, $value, $context[0] ?? null ); | |
| 259 | 310 | switch ( $hook ) { |
| 311 | + case 'posts_search': | |
| 312 | + case 'posts_join': | |
| 313 | + case 'posts_groupby': | |
| 314 | + case 'search_orderby': | |
| 315 | + return $value; | |
| 260 | 316 | case self::HOOK_QUERY_ARGS: |
| 261 | 317 | return \is_array( $value ) ? $this->apply_meta_filters( $value ) : $value; |
| 262 | 318 | |
| 263 | 319 | case self::HOOK_PREPARE_ARGS: |
| @@ -268,8 +324,11 @@ | ||
| 268 | 324 | |
| 269 | 325 | case self::HOOK_POSTS_ORDERBY: |
| 270 | 326 | return \is_string( $value ) ? $this->apply_legacy_sort_clause( $value, $context[0] ?? null ) : $value; |
| 271 | 327 | |
| 328 | + case self::HOOK_POSTS_CLAUSES: | |
| 329 | + return \is_array( $value ) ? $this->apply_meta_sort_clauses( $value, $context[0] ?? null ) : $value; | |
| 330 | + | |
| 272 | 331 | case self::HOOK_HPOS_FILTERS: |
| 273 | 332 | return \is_array( $value ) ? $this->apply_hpos_filters( $value, $context[0] ?? null ) : $value; |
| 274 | 333 | |
| 275 | 334 | case self::HOOK_HPOS_ORDERBY: |
| @@ -293,9 +352,9 @@ | ||
| 293 | 352 | |
| 294 | 353 | /** |
| 295 | 354 | * Install this plan's callbacks, run `$run`, then unwind every binding in reverse. |
| 296 | 355 | * |
| 297 | - * The proxy lane's ONLY install path. Bindings are closures captured here, so the | |
| 356 | + * The scoped read lanes' ONLY install path. Bindings are closures captured here, so the | |
| 298 | 357 | * unwind removes the exact callables that were added — never a re-derived tuple that |
| 299 | 358 | * could miss. An exception from `$run` propagates AFTER the unwind. |
| 300 | 359 | * |
| 301 | 360 | * @param callable $run The forward to wrap. |
| @@ -347,8 +406,22 @@ | ||
| 347 | 406 | return null !== $this->sort && isset( $this->rules['sorts'][ $this->sort ]['posts']['posts_orderby'] ); |
| 348 | 407 | } |
| 349 | 408 | |
| 350 | 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 | + /** | |
| 351 | 424 | * Attach every callback this plan needs for a proxied forward. |
| 352 | 425 | * |
| 353 | 426 | * @return array<int, array{0: string, 1: callable, 2: int}> Bindings, in install order. |
| 354 | 427 | */ |
| @@ -358,8 +431,49 @@ | ||
| 358 | 431 | if ( $this->is_empty() ) { |
| 359 | 432 | return $bindings; |
| 360 | 433 | } |
| 361 | 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 | + | |
| 362 | 476 | // `meta_query` rows are storage-neutral (`wc_get_orders()` honours them on both), |
| 363 | 477 | // and the legacy sort args are a no-op under HPOS, so one binding covers both. |
| 364 | 478 | if ( array() !== $this->claimed_meta_filters() || $this->has_legacy_meta_sort() ) { |
| 365 | 479 | $args_callback = function ( $args ) { |
| @@ -393,9 +507,9 @@ | ||
| 393 | 507 | add_filter( 'posts_orderby', $orderby_callback, 10, 2 ); |
| 394 | 508 | $bindings[] = array( 'posts_orderby', $orderby_callback, 10 ); |
| 395 | 509 | } |
| 396 | 510 | |
| 397 | - if ( $this->claims_id_sets() ) { | |
| 511 | + if ( $this->claims_id_sets() || '' !== $this->search ) { | |
| 398 | 512 | /* |
| 399 | 513 | * `posts_where` fires for EVERY WP_Query, and `wcpos/v1` leaves its callback |
| 400 | 514 | * installed for the remainder of the request without a post-type guard (frozen |
| 401 | 515 | * behaviour, reproduced verbatim in the clause body). The proxy lane scopes the |
| @@ -420,8 +534,80 @@ | ||
| 420 | 534 | return $bindings; |
| 421 | 535 | } |
| 422 | 536 | |
| 423 | 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 | + /** | |
| 424 | 610 | * Claim the `orderby` param when its value names a sort this collection declares. |
| 425 | 611 | * |
| 426 | 612 | * @param WP_REST_Request $request Request to read. |
| 427 | 613 | * @param array $param_map Canonical name => request key. |
| @@ -676,25 +862,87 @@ | ||
| 676 | 862 | if ( 'shop_order' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'shop_order', $post_type, true ) ) ) { |
| 677 | 863 | return $orderby; |
| 678 | 864 | } |
| 679 | 865 | |
| 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 | - */ | |
| 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 { | |
| 690 | 939 | $order = $query->query_vars['order'] ?? $this->request_order ?? 'ASC'; |
| 691 | 940 | $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 | 941 | |
| 696 | - return "{$wpdb->posts}.{$column} {$order}"; | |
| 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'; | |
| 697 | 945 | } |
| 698 | 946 | |
| 699 | 947 | /** |
| 700 | 948 | * Append claimed filters to the HPOS clause set. |