| @@ -84,8 +84,15 @@ | ||
| 84 | 84 | */ |
| 85 | 85 | public const HOOK_POSTS_ORDERBY = 'posts_orderby'; |
| 86 | 86 | |
| 87 | 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 | + /** | |
| 88 | 95 | * HPOS storage — filter rules, appended to the `WHERE` clause. |
| 89 | 96 | * |
| 90 | 97 | * The `woocommerce_orders_table_query_clauses` hook carries two unrelated roles and |
| 91 | 98 | * v1 registers a separate callback for each, so the keys are suffixed by role; a |
| @@ -268,8 +275,11 @@ | ||
| 268 | 275 | |
| 269 | 276 | case self::HOOK_POSTS_ORDERBY: |
| 270 | 277 | return \is_string( $value ) ? $this->apply_legacy_sort_clause( $value, $context[0] ?? null ) : $value; |
| 271 | 278 | |
| 279 | + case self::HOOK_POSTS_CLAUSES: | |
| 280 | + return \is_array( $value ) ? $this->apply_meta_sort_clauses( $value, $context[0] ?? null ) : $value; | |
| 281 | + | |
| 272 | 282 | case self::HOOK_HPOS_FILTERS: |
| 273 | 283 | return \is_array( $value ) ? $this->apply_hpos_filters( $value, $context[0] ?? null ) : $value; |
| 274 | 284 | |
| 275 | 285 | case self::HOOK_HPOS_ORDERBY: |
| @@ -347,8 +357,22 @@ | ||
| 347 | 357 | return null !== $this->sort && isset( $this->rules['sorts'][ $this->sort ]['posts']['posts_orderby'] ); |
| 348 | 358 | } |
| 349 | 359 | |
| 350 | 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 | + /** | |
| 351 | 375 | * Attach every callback this plan needs for a proxied forward. |
| 352 | 376 | * |
| 353 | 377 | * @return array<int, array{0: string, 1: callable, 2: int}> Bindings, in install order. |
| 354 | 378 | */ |
| @@ -676,25 +700,87 @@ | ||
| 676 | 700 | if ( 'shop_order' !== $post_type && ( ! \is_array( $post_type ) || ! \in_array( 'shop_order', $post_type, true ) ) ) { |
| 677 | 701 | return $orderby; |
| 678 | 702 | } |
| 679 | 703 | |
| 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 | - */ | |
| 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 { | |
| 690 | 777 | $order = $query->query_vars['order'] ?? $this->request_order ?? 'ASC'; |
| 691 | 778 | $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 | 779 | |
| 696 | - return "{$wpdb->posts}.{$column} {$order}"; | |
| 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'; | |
| 697 | 783 | } |
| 698 | 784 | |
| 699 | 785 | /** |
| 700 | 786 | * Append claimed filters to the HPOS clause set. |