PluginProbe
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce / 3.3.0
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce v3.3.0
3.3.0 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 trunk 1.0.4 1.1.0 1.1.0.1 1.1.1 1.1.10 1.1.11 1.1.12 1.1.13 1.1.14 1.1.15 1.1.16 1.1.17 1.1.18 1.1.19 1.1.2 All 162 releases
cartflows / classes / class-cartflows-product-search.php

class-cartflows-product-search.php in CartFlows – Funnel Builder & Checkout Plugin for WooCommerce 3.3.0, at classes/class-cartflows-product-search.php

433 lines 13.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Title and SKU scoped product search for the admin pickers.
4 *
5 * See `architecture.md` in this folder for why this exists rather than calling
6 * `WC_Data_Store::search_products()`.
7 *
8 * @package CartFlows
9 * @since x.x.x
10 */
11
12 // Exit if accessed directly.
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Class Cartflows_Product_Search.
19 *
20 * Stateless helper — every method is static and there is no instance.
21 *
22 * @since x.x.x
23 */
24 class Cartflows_Product_Search {
25
26 /**
27 * Post types searched by the product pickers.
28 *
29 * @since x.x.x
30 * @var array<int, string>
31 */
32 const POST_TYPES = array( 'product', 'product_variation' );
33
34 /**
35 * Search published products by title, SKU and GTIN, best match first.
36 *
37 * Ranked exact code, exact title, prefix, then the rest; within a tier
38 * top-level products come before variations.
39 *
40 * @since x.x.x
41 *
42 * @param string $term Raw search term.
43 * @param int $limit Maximum rows to read from the database.
44 * @return array<int, int> Product and variation IDs, best match first.
45 */
46 public static function search_product_ids( $term, $limit = 20 ) {
47
48 global $wpdb;
49
50 $term = trim( (string) $term );
51 $limit = max( 1, (int) $limit );
52
53 if ( '' === $term ) {
54 return array();
55 }
56
57 // Applied so search integrations that short-circuit WooCommerce's product search keep working here.
58 $custom_results = apply_filters( 'woocommerce_product_pre_search_products', false, $term, '', true, false, $limit );
59
60 if ( is_array( $custom_results ) ) {
61 return array_values( wp_parse_id_list( $custom_results ) );
62 }
63
64 $statuses = self::get_post_statuses();
65
66 if ( empty( $statuses ) ) {
67 return array();
68 }
69
70 // One join per meta key: matching both keys in one join fans a product with two codes into four rows.
71 $own_sku = "COALESCE( sku_meta.meta_value, '' )";
72 $own_gtin = "COALESCE( gtin_meta.meta_value, '' )";
73 $parent_sku = "COALESCE( parent_sku_meta.meta_value, '' )";
74 $parent_gtin = "COALESCE( parent_gtin_meta.meta_value, '' )";
75
76 $exact_code = "( {$own_sku} = %s OR {$own_gtin} = %s )";
77 $inherit_code = "( ( {$own_sku} = '' AND {$parent_sku} = %s ) OR ( {$own_gtin} = '' AND {$parent_gtin} = %s ) )";
78
79 $match_sql = array();
80 $match_params = array();
81
82 // Terms are parsed the way WooCommerce parses them, so stopwords and quoted phrases behave as merchants expect.
83 foreach ( self::get_term_groups( $term ) as $words ) {
84
85 $word_sql = array();
86
87 foreach ( $words as $word ) {
88 $word_sql[] = 'posts.post_title LIKE %s';
89 $match_params[] = '%' . $wpdb->esc_like( (string) $word ) . '%';
90 }
91
92 if ( ! empty( $word_sql ) ) {
93 $match_sql[] = '( ' . implode( ' AND ', $word_sql ) . ' )';
94 }
95 }
96
97 $where_sql = ! empty( $match_sql ) ? '( ' . implode( ' OR ', $match_sql ) . ' )' : '( 1 = 0 )';
98
99 // Exact code matches always run, so a SKU or GTIN containing a space stays reachable by typing it in full.
100 $where_sql .= ' OR ' . $exact_code;
101 $match_params[] = $term;
102 $match_params[] = $term;
103
104 // An inherited parent code matches exactly only: a partial one drags in every code-less variation and fills the row budget.
105 $where_sql .= ' OR ' . $inherit_code;
106 $match_params[] = $term;
107 $match_params[] = $term;
108
109 // Only the partial match is skipped for a phrase: it is a leading wildcard LIKE, and codes do not contain spaces.
110 if ( ! preg_match( '/\s/', $term ) ) {
111 $where_sql .= " OR {$own_sku} LIKE %s OR {$own_gtin} LIKE %s";
112 $match_params[] = '%' . $wpdb->esc_like( $term ) . '%';
113 $match_params[] = '%' . $wpdb->esc_like( $term ) . '%';
114 }
115
116 $type_placeholders = implode( ', ', array_fill( 0, count( self::POST_TYPES ), '%s' ) );
117 $status_placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
118
119 // Argument order follows the placeholders as they appear in the statement: ranking CASE, post types, statuses, match clause, limit.
120 $query_params = array_merge(
121 array( $term, $term, $term, $term, $term, $wpdb->esc_like( $term ) . '%' ),
122 self::POST_TYPES,
123 $statuses,
124 $match_params,
125 array( $limit )
126 );
127
128 // Grouped to one row per product: duplicate meta rows would otherwise let one product hold several LIMIT slots.
129 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Placeholders and table names only; every value is passed to prepare().
130 $sql = "SELECT posts.ID, posts.post_parent, posts.post_title,
131 MIN(
132 CASE
133 WHEN {$exact_code} THEN 0
134 WHEN {$inherit_code} THEN 0
135 WHEN posts.post_title = %s THEN 1
136 WHEN posts.post_title LIKE %s THEN 2
137 ELSE 3
138 END
139 ) AS search_rank
140 FROM {$wpdb->posts} posts
141 LEFT JOIN {$wpdb->postmeta} sku_meta ON sku_meta.post_id = posts.ID AND sku_meta.meta_key = '_sku'
142 LEFT JOIN {$wpdb->postmeta} gtin_meta ON gtin_meta.post_id = posts.ID AND gtin_meta.meta_key = '_global_unique_id'
143 LEFT JOIN {$wpdb->postmeta} parent_sku_meta ON posts.post_type = 'product_variation' AND parent_sku_meta.post_id = posts.post_parent AND parent_sku_meta.meta_key = '_sku'
144 LEFT JOIN {$wpdb->postmeta} parent_gtin_meta ON posts.post_type = 'product_variation' AND parent_gtin_meta.post_id = posts.post_parent AND parent_gtin_meta.meta_key = '_global_unique_id'
145 WHERE posts.post_type IN ( {$type_placeholders} )
146 AND posts.post_status IN ( {$status_placeholders} )
147 AND ( {$where_sql} )
148 GROUP BY posts.ID, posts.post_parent, posts.post_title
149 ORDER BY search_rank ASC, ( posts.post_parent > 0 ) ASC, posts.post_title ASC, posts.ID ASC
150 LIMIT %d";
151 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
152
153 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin-only picker search; prepared above with an argument array.
154 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $query_params ) );
155
156 return self::collect_ids( is_array( $rows ) ? $rows : array(), $term, $statuses );
157 }
158
159 /**
160 * Hydrate ranked IDs into supported products, stopping once the cap is met.
161 *
162 * Products are loaded one at a time so a wide candidate set does not cost a
163 * `WC_Product` per candidate, and a variation is never split from its parent.
164 *
165 * @since x.x.x
166 *
167 * @param array<int, int> $ids Ranked product and variation IDs.
168 * @param array<int, string> $supported_types Product types the picker accepts.
169 * @param int $limit Maximum products to return.
170 * @param array<int, int> $exclude Product IDs to skip before the cap applies.
171 * @return array<int, \WC_Product> Supported products, best match first.
172 */
173 public static function collect_supported_products( $ids, $supported_types, $limit, $exclude = array() ) {
174
175 $ids = array_values( array_map( 'intval', $ids ) );
176 $limit = max( 1, (int) $limit );
177 $exclude = array_flip( array_map( 'intval', $exclude ) );
178 $total = count( $ids );
179 $products = array();
180
181 for ( $index = 0; $index < $total; $index++ ) {
182
183 $product = self::load_supported_product( $ids[ $index ], $supported_types, $exclude );
184
185 if ( ! $product ) {
186 continue;
187 }
188
189 $pair = array( $product );
190 $parent_id = (int) $product->get_parent_id();
191
192 // The parent always follows its variation, so the two are capped as one unit.
193 if ( $parent_id && isset( $ids[ $index + 1 ] ) && $parent_id === $ids[ $index + 1 ] ) {
194
195 $parent = self::load_supported_product( $parent_id, $supported_types, $exclude );
196
197 if ( $parent ) {
198 $pair[] = $parent;
199 }
200
201 $index++;
202 }
203
204 if ( count( $products ) + count( $pair ) > $limit ) {
205
206 // A pair wider than the whole cap still has to return the matched row itself.
207 if ( empty( $products ) ) {
208 $products[] = $pair[0];
209 }
210
211 break;
212 }
213
214 $products = array_merge( $products, $pair );
215
216 if ( count( $products ) >= $limit ) {
217 break;
218 }
219 }
220
221 return $products;
222 }
223
224 /**
225 * Load one product if the picker may show it.
226 *
227 * @since x.x.x
228 *
229 * @param int $product_id Product or variation ID.
230 * @param array<int, string> $supported_types Product types the picker accepts.
231 * @param array<int, int> $exclude Excluded IDs, flipped for lookup.
232 * @return \WC_Product|false The product, or false when it may not be shown.
233 */
234 private static function load_supported_product( $product_id, $supported_types, $exclude ) {
235
236 if ( isset( $exclude[ $product_id ] ) ) {
237 return false;
238 }
239
240 $product = wc_get_product( $product_id );
241
242 if ( ! $product instanceof WC_Product || ! wc_products_array_filter_readable( $product ) ) {
243 return false;
244 }
245
246 if ( ! in_array( $product->get_type(), $supported_types, true ) ) {
247 return false;
248 }
249
250 return $product;
251 }
252
253 /**
254 * Post statuses the current user may search.
255 *
256 * @since x.x.x
257 *
258 * @return array<int, string> Post statuses.
259 */
260 private static function get_post_statuses() {
261
262 $statuses = apply_filters(
263 'woocommerce_search_products_post_statuses',
264 current_user_can( 'edit_private_products' ) ? array( 'private', 'publish' ) : array( 'publish' )
265 );
266
267 $statuses = is_array( $statuses ) ? $statuses : array( 'publish' );
268
269 return array_values( array_map( 'strval', $statuses ) );
270 }
271
272 /**
273 * Split the term into OR groups of searchable words.
274 *
275 * Mirrors WooCommerce: groups are OR'd, the words inside one are AND'd.
276 *
277 * @since x.x.x
278 *
279 * @param string $term Raw search term.
280 * @return array<int, array<int, string>> Groups of words.
281 */
282 private static function get_term_groups( $term ) {
283
284 $groups = stristr( $term, ' or ' ) ? preg_split( '/\s+or\s+/i', $term ) : array( $term );
285 $groups = is_array( $groups ) ? $groups : array( $term );
286 $parsed = array();
287
288 foreach ( $groups as $group ) {
289
290 $words = array();
291
292 if ( preg_match_all( '/".*?("|$)|((?<=[\t ",+])|^)[^\t ",+]+/', $group, $matches ) ) {
293 $words = self::get_valid_search_terms( $matches[0] );
294 }
295
296 $count = count( $words );
297
298 // Match the group as one phrase when it is all stopwords, or long enough that word-by-word matching returns noise.
299 if ( 9 < $count || 0 === $count ) {
300 $words = array( $group );
301 }
302
303 $parsed[] = $words;
304 }
305
306 return $parsed;
307 }
308
309 /**
310 * Drop stopwords and single characters from parsed search words.
311 *
312 * @since x.x.x
313 *
314 * @param array<int, string> $words Parsed words.
315 * @return array<int, string> Words worth searching on.
316 */
317 private static function get_valid_search_terms( $words ) {
318
319 $valid = array();
320 $stopwords = self::get_search_stopwords();
321
322 foreach ( $words as $word ) {
323
324 $word = preg_match( '/^".+"$/', $word ) ? trim( $word, "\"'" ) : trim( $word, "\"' " );
325
326 if ( '' === $word || ( 1 === strlen( $word ) && preg_match( '/^[a-z\-]$/i', $word ) ) ) {
327 continue;
328 }
329
330 if ( in_array( strtolower( $word ), $stopwords, true ) ) {
331 continue;
332 }
333
334 $valid[] = $word;
335 }
336
337 return $valid;
338 }
339
340 /**
341 * Very common words excluded from word-by-word matching.
342 *
343 * @since x.x.x
344 *
345 * @return array<int, string> Stopwords.
346 */
347 private static function get_search_stopwords() {
348
349 $stopwords = explode(
350 ',',
351 /* translators: This is a comma-separated list of very common words that should be excluded from a search, like a, an and the. These are usually called "stopwords". Do not translate these individual words literally — provide the commonly accepted stopwords in your language. */
352 _x( 'about,an,are,as,at,be,by,com,for,from,how,in,is,it,of,on,or,that,the,this,to,was,what,when,where,who,will,with,www', 'Comma-separated list of search stopwords in your language', 'cartflows' )
353 );
354
355 $stopwords = array_map( 'strtolower', array_map( 'trim', $stopwords ) );
356
357 // The same filter WooCommerce and WordPress core run their stopword lists through.
358 $stopwords = apply_filters( 'wp_search_stopwords', $stopwords );
359
360 return is_array( $stopwords ) ? array_values( array_map( 'strval', $stopwords ) ) : array();
361 }
362
363 /**
364 * Flatten the result rows into a de-duplicated list of IDs.
365 *
366 * Each matched variation is followed immediately by its parent.
367 *
368 * @since x.x.x
369 *
370 * @param array<int, object> $rows Result rows carrying ID and post_parent.
371 * @param string $term Raw search term.
372 * @param array<int, string> $statuses Post statuses the current user may search.
373 * @return array<int, int> Product and variation IDs.
374 */
375 private static function collect_ids( $rows, $term, $statuses ) {
376
377 $ids = self::get_id_match( $term, $statuses );
378
379 foreach ( $rows as $row ) {
380
381 if ( ! isset( $row->ID ) ) {
382 continue;
383 }
384
385 $ids[] = (int) $row->ID;
386
387 if ( ! empty( $row->post_parent ) ) {
388 $ids[] = (int) $row->post_parent;
389 }
390 }
391
392 return array_values( array_unique( array_filter( $ids ) ) );
393 }
394
395 /**
396 * Resolve a numeric term as a product or variation ID.
397 *
398 * WooCommerce does this, so merchants who paste an ID keep finding it.
399 *
400 * @since x.x.x
401 *
402 * @param string $term Raw search term.
403 * @param array<int, string> $statuses Post statuses the current user may search.
404 * @return array<int, int> The product ID and its parent, or an empty array.
405 */
406 private static function get_id_match( $term, $statuses ) {
407
408 if ( ! is_numeric( $term ) ) {
409 return array();
410 }
411
412 $post_id = absint( $term );
413
414 if ( ! in_array( (string) get_post_type( $post_id ), self::POST_TYPES, true ) ) {
415 return array();
416 }
417
418 // Without this a pasted ID surfaces a trashed or draft product above every ranked row.
419 if ( ! in_array( (string) get_post_status( $post_id ), $statuses, true ) ) {
420 return array();
421 }
422
423 $ids = array( $post_id );
424 $parent = (int) wp_get_post_parent_id( $post_id );
425
426 if ( $parent ) {
427 $ids[] = $parent;
428 }
429
430 return $ids;
431 }
432 }
433