1 <?php
2
3 /**
4 * Vanilla enhancement to the WordPress frontend search in order to take
5 * into account post meta and taxonomy terms.
6 *
7 * @author Nevma, http://www.nevma.gr, info@nevma.gr
8 *
9 * @license http://www.gnu.org/licenses/gpl-3.0.en.html GPLv3
10 */
11
12
13
14 /**
15 * Sets all the necessary filters and hooks so that the default WordPress
16 * search is extended in order to search inside extended data associated
17 * with each post.
18 *
19 * @return void
20 */
21
22 function vanilla_frontend_search_setup ( $query ) {
23
24 // Bail if frontend search enhancement is turned off, or this isn't a main front-end search query (is_search returns true in the admin).
25
26 if ( apply_filters( 'vanilla_frontend_search_setup', false ) &&
27 $query->is_main_query() &&
28 is_search() &&
29 ! is_admin() ) {
30
31 // Hook the required functions.
32
33 add_filter( 'posts_join', 'vanilla_frontend_search_join' );
34 add_filter( 'posts_where', 'vanilla_frontend_search_where_inject_placeholder', 1 );
35 add_filter( 'posts_where', 'vanilla_frontend_search_where_replace_placeholder', 80 );
36 add_filter( 'posts_distinct', 'vanilla_frontend_search_distinct' );
37
38 // Hook the cleanup function so that any subsequent queries are unaffected.
39
40 add_action( 'wp', 'vanilla_frontend_search_remove_filters' );
41
42 }
43
44 }
45
46
47
48 /**
49 * Unhooks all the functions that are used by this module in order to
50 * enhance the main query in front-end search requests, so that any
51 * subsequent queries (other than the main one, that is) remain
52 * untouched.
53 *
54 * @return void
55 */
56
57 function vanilla_frontend_search_remove_filters () {
58
59 remove_filter( 'posts_join', 'vanilla_frontend_search_join' );
60 remove_filter( 'posts_where', 'vanilla_frontend_search_where_inject_placeholder', 1 );
61 remove_filter( 'posts_where', 'vanilla_frontend_search_where_replace_placeholder', 80 );
62 remove_filter( 'posts_distinct', 'vanilla_frontend_search_distinct' );
63
64 }
65
66
67
68 /**
69 * Joins the posts table with extra tables (e.g. postmeta or taxonomy-related
70 * tables), as required. Meant to be used as a filter callback in order to
71 * extend the original database seach SQL query.
72 *
73 * @param string $join The original join part of the database search SQL
74 * query.
75 *
76 * @return string The join part of the database search SQL query, having
77 * joined the posts table with the appropriate extra tables.
78 */
79
80 function vanilla_frontend_search_join ( $join ) {
81
82 global $wpdb;
83
84 include_once( ABSPATH . 'wp-admin/includes/plugin.php' );
85
86
87
88 // WooCommerce joins postmeta on posts itself, so skip this join if it is active.
89
90 if ( ! empty( vanilla_frontend_search_postmeta_keys() ) && ! is_plugin_active( 'woocommerce/woocommerce.php') ) {
91 $join .= ' LEFT JOIN ' . $wpdb->postmeta . ' ON '. $wpdb->posts . '.ID = ' . $wpdb->postmeta . '.post_id ';
92 }
93
94 if ( ! empty( vanilla_frontend_search_taxonomies() ) ) {
95
96 $join .= ' LEFT JOIN ' . $wpdb->term_relationships . ' ON ( ' . $wpdb->posts . '.ID = ' . $wpdb->term_relationships . '.object_id ) ';
97 $join .= ' LEFT JOIN ' . $wpdb->term_taxonomy . ' ON ( ' . $wpdb->term_relationships . '.term_taxonomy_id = ' . $wpdb->term_taxonomy . '.term_taxonomy_id ) ';
98 $join .= ' LEFT JOIN ' . $wpdb->terms . ' ON ( ' . $wpdb->terms . '.term_id = ' . $wpdb->term_taxonomy . '.term_id ) ';
99
100 }
101
102 return $join;
103
104 }
105
106
107
108 /**
109 * Extends the original database search SQL query in order to take into
110 * account extended data associated with each post. The extended data can
111 * be post meta values and/or term names of taxonomies associated with each
112 * post. Meant to be used as a filter callback in order to extend the
113 * original database seach SQL query.
114 *
115 * A placeholder has earlier on in the code been injected by the function
116 * `vanilla_frontend_search_where_inject_placeholder()`. This function is
117 * supposed to run at a reasonably high priority (late), so that the
118 * conditions that it injects are not likely to be matched by regexp lookups
119 * of third party code, as a false-positive.
120 *
121 * @param string $where The original WHERE clause of the database search
122 * SQL query.
123 *
124 * @return string The WHERE clause of the database search SQL query,
125 * having been extended to take into account the values of
126 * each post's meta or associated taxonomy terms, as
127 * required.
128 */
129
130 function vanilla_frontend_search_where_replace_placeholder ( $where ) {
131
132 global $wpdb;
133 global $wp;
134
135 $search_term = esc_sql( $wp->query_vars['s'] );
136 $search_term_words = mb_split( '\s+', trim( $search_term ) );
137
138 // Bail on empty input (e.g. whitespace-only)
139
140 if ( empty( $search_term_words ) ) {
141 return $where;
142 }
143
144 // The logical SQL conditions that will be merged into the original ones.
145
146 $extra_conditions = array();
147
148
149
150 // The postmeta keys, the values of which the extended search should take into account.
151
152 $postmeta = vanilla_frontend_search_postmeta_keys();
153
154 if ( ! empty( $postmeta ) ) {
155
156 $meta_value_matches = array();
157
158 foreach ( $search_term_words as $word ) {
159 $meta_value_matches[] = "meta_value LIKE '%$word%'";
160 }
161
162 // If a "*" is given, then search in all post meta.
163
164 $search_all_postmeta = in_array( "*", $postmeta );
165
166 // This query returns the IDs of posts with postmeta that matches the searched term.
167
168 $post_IDs_matching_postmeta =
169 "SELECT post_id
170 FROM {$wpdb->postmeta}
171 WHERE " .
172 ( $search_all_postmeta ? '' : "meta_key IN ( '" . implode( "', '", $postmeta ) . "' ) AND " ) .
173 "(" . implode( " OR ", $meta_value_matches ) . ")";
174
175 $extra_conditions[] = "{$wpdb->posts}.ID IN ( $post_IDs_matching_postmeta )";
176
177 }
178
179
180
181 // The taxonomy names in which the extended search should look for matching names of terms associated with each post.
182
183 $taxonomies = vanilla_frontend_search_taxonomies();
184
185 if ( ! empty( $taxonomies ) ) {
186
187 $term_name_matches = array();
188
189 foreach ( $search_term_words as $word ) {
190 $term_name_matches[] = "{$wpdb->terms}.name LIKE '%$word%'";
191 }
192
193 $extra_conditions[] = "{$wpdb->term_taxonomy}.taxonomy IN( '" . implode( "', '", $taxonomies ) . "' ) AND " . implode( " AND ", $term_name_matches );
194
195 }
196
197
198
199 // If any extra conditions were created, inject them into the original WHERE clause.
200
201 if ( $extra_conditions ) {
202
203 /**
204 * There is no positive way to determine the correct injection point, due to the
205 * lack of a specific filter for that purpose, so we have to attempt to guess it.
206 * The best guess is to look for the condition that searches in post titles, and
207 * append the 'OR'ed extra conditions right after that.
208 *
209 * The search pattern matches a string like:
210 * ( wp_posts.post_title LIKE ( 'the_search_term' ) )
211 */
212
213 $where = str_replace( '/*vanilla_search_placeholder*/', '( ' . implode( ') OR (', $extra_conditions ) . ' ) OR', $where );
214
215 }
216
217 return $where;
218
219 }
220
221
222
223 /**
224 * Injects a custom placeholder comment in the original database search
225 * SQL query. Is meant to do so in an extremely low priority (early),
226 * assuming that the query has not been manipulated by third party code so
227 * far, in order to do a best guess and mark the point where the extra
228 * custom conditions that will follow should be placed.
229 *
230 * @param string $where The original WHERE clause of the database search
231 * SQL query.
232 *
233 * @return string The WHERE clause of the database search SQL query,
234 * having been extended to take into account the values of
235 * each post's meta or associated taxonomy terms, as
236 * required.
237 */
238
239 function vanilla_frontend_search_where_inject_placeholder( $where ) {
240
241 global $wpdb, $wp;
242
243 // Inject the placeholder without checking any further criteria. It is an inline comment, so it should not ruin anything even if not removed.
244
245 $where = preg_replace(
246 "/^(\s*AND\s*\()/",
247 "$1 /*vanilla_search_placeholder*/ ",
248 $where
249 );
250
251 return $where;
252
253 }
254
255
256
257 /**
258 * Returns the names of the taxonomies whose terms' names will be included
259 * in the enhanced front-end search.
260 *
261 * @return array The names of the taxonomies whose terms' names will be
262 * included in the enhanced front-end search.
263 */
264
265 function vanilla_frontend_search_taxonomies() {
266
267 return apply_filters( 'vanilla_frontend_search_taxonomies', array() );
268
269 }
270
271
272
273 /**
274 * Returns the meta_keys of postmeta that will be included in the enhanced
275 * front-end search.
276 *
277 * @return array The meta_keys for which the corresponding meta_value will
278 * be compared against the search term.
279 */
280
281 function vanilla_frontend_search_postmeta_keys() {
282
283 return apply_filters( 'vanilla_frontend_search_postmeta_keys', array() );
284
285 }
286
287
288
289 /**
290 * Extends the original database seach SQL query with a distinct clause to
291 * prevent duplicates. Meant to be used as a filter callback in order to
292 * extend the original database seach SQL query.
293 *
294 * @return string The where part of the database search SQL query, having
295 * been extended with a distinct clause to prevent the
296 * appearance of duplicates.
297 */
298
299 function vanilla_frontend_search_distinct () {
300
301 return 'DISTINCT';
302
303 }
304
305 ?>