Searching custom posts and metadata with WordPress

Most of the time WordPress makes it pretty easy to do things.  And if you get stuck a two minute search with Google usually turns up a simple, elegant solution. Most of the time…but I found an exception to that: custom searches, in this case searching custom posts and meta data.

I was recently using custom post types and custom fields to add structure to a website’s content. My application required a search function to match custom posts only, and to match not only the post content but also content contained in custom fields – ‘metadata’.

After more than two minutes on Google I couldn’t find a simple answer. I didn’t want to use a plugin, so I created a solution of my own. In my functions.php file I hooked into three WordPress filters:

add_filter('posts_join', 'websmart_search_join' );
add_filter('posts_groupby', 'websmart_search_groupby' );
add_filter('posts_where', 'websmart_search_where' );

I used the posts_join filter to extend the search query to the wp_postmeta table, where custom field data is stored.

function websmart_search_join( $join ) {
        global $wpdb;
        if( is_search() && !is_admin()) {
                $join .= "LEFT JOIN $wpdb->postmeta AS m ON ($wpdb->posts.ID = m.post_id) ";
        }
        return $join;
}

Having joined to the wp_postmeta table (giving it the alias m) I needed to be careful to group the results by post id, to avoid returning duplicates.

function websmart_search_groupby( $groupby ) {
        global $wpdb;
        if( is_search() && !is_admin()) {
                $groupby = "$wpdb->posts.ID";
        }
        return $groupby;
}

With the query join and group by set up I used the posts_where filter to create a where clause to do the meta data matching.

function websmart_search_where( $where ) {
        global $wpdb, $wp_query;
        if( is_search() && !is_admin()) {
                $where = "";
                $search_terms = se_get_search_terms();
                $n = !empty($wp_query->query_vars['exact']) ? '' : '%';
                $searchand = '';
                if (count($search_terms) < 1) {
                        // no search term provided: so return no results
                        $search = "1=0";
                } else {
                        foreach( $search_terms as $term ) {
                                $term = esc_sql( like_escape( $term ) );
                                $search .= "{$searchand}(($wpdb->posts.post_title LIKE '{$n}{$term}{$n}') OR ($wpdb->posts.post_content LIKE '{$n}{$term}{$n}') OR (m.meta_value LIKE '{$n}{$term}{$n}'))";
                                $searchand = ' AND ';
                        }
                }
                $where .= " AND ${search} ";
                $where .= " AND (m.meta_key IN ('custom_field1', 'custom_field2')) ";
                $where .= " AND ($wpdb->posts.post_password = '') ";
                $where .= " AND ($wpdb->posts.post_type IN (/* 'post', 'page', */ 'custom_post')) ";
                $where .= " AND ($wpdb->posts.post_status = 'publish') ";
        }
        return $where;
}

The meat and potatoes is the second line inside the foreach loop.  It creates a search string which matches against post_title, post_content and m.meta_value.  The m.meta_value term  matches against all custom fields.  I didn’t want that so I added the m.meta_key clause to restrict custom field search to two fields, custom_field1 and custom_field2.  The wp_post.post_type clause also restricts to searching posts of type custom_post, the name of the custom post type I created (elsewhere in my functions.php file) using register_post_type().  I could have added ‘post’, ‘page’ or some other custom post type into this part of the query.

I got a lot of help in understanding how these filters work by looking at the Search Everywhere plugin.  It’s a very nice plugin!  They have a utility function to build search terms from WordPress query parameters, which I borrowed.

// Code from Search Everywhere plugin
function se_get_search_terms()
{
        global $wpdb, $wp_query;
        $s = isset($wp_query->query_vars['s']) ? $wp_query->query_vars['s'] : '';
        $sentence = isset($wp_query->query_vars['sentence']) ? $wp_query->query_vars['sentence'] : false;
        $search_terms = array();

        if ( !empty($s) )
        {
                // added slashes screw with quote grouping when done early, so done later
                $s = stripslashes($s);
                if ($sentence)
                {
                        $search_terms = array($s);
                } else {
                        preg_match_all('/".*?("|$)|((?<=[\\s",+])|^)[^\\s",+]+/', $s, $matches);
                        $search_terms = array_map(create_function('$a', 'return trim($a, "\\"\'\\n\\r ");'), $matches[0]);
                }
        }
        return $search_terms;
}

Thanks!

By the way, if you are having trouble working out what WordPress is doing with all your filter results, add this debugging statement somewhere near the top of your search results template file, e.g. search.php:

<?php global $wp_query; print_r($wp_query->request); ?>

Reference

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s