PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
1.10.25 1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 All 169 releases
← All changes | includes/Sync/Collection_Rules_Plan.php +100 -14 1.10.3 → 1.10.17 View file →
@@ -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.