PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
← All changes | includes/Sync/Collection_Rules_Plan.php +269 -21 1.10.21.10.18 View file →
@@ -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.