Expanded User Search in WordPress
For a recent client, we found ourselves with 11,000+ registered WordPress users with a boatload of meta data that we wanted to be searchable in the Admin backend (All Users screen). Additionally, we wanted to be able to search on a user’s email address as well, to help the client’s Customer Support folk locate a user record quickly.
Now, out of the box, WordPress only searches the following fields:
- user_login
- user_nicename
This short post walks through the process of extending the WordPress Admin’s user search without mucking about in any core files.
Hooking in
Unfortunately, unlike your main query, or anything generated with query_posts(), we don’t have a lot of options to inject our own business logic into the application flow, because the admin’s user search is pretty much hardwired to a new instance of WP_User_Query() straight out of the WP_Users_List_Table instance that is responsible for generating that fancy, sortable list table of users you see on the All Users page. You could maybe get in there early, on ‘init’, for example and manipulate the $_REQUEST superglobal (more on why you might want to take that approach later), but otherwise, following the thread, we only really encounter one useful action: pre_user_query.
Here’s the basic flow:
- We enter on wp-admin/users.php
- This invokes a WP_Users_List_Table instance (found in wp-admin/includes/class-wp-users-list-table.php
- The list table is eventually initialized with its prepare_items() method, that pulls the search argument out of the $_REQUEST['s'] query var (you’ll note that you can’t get at this argument with get_query_var( 's' ) unfortunately).
- The items from the search are generated through a new instance of WP_User_Query(), created with only the ‘number’, ‘offset’, ‘role’, ‘fields’, and ‘search’ arguments set, and no opportunity to manipulate those before the instance is created.
- The constructor for WP_User_Query jumps straight into prepare_query(), which then parses the query variables and generates the SQL.
- At the very end of this method, we find the pre_user_query action. And this is where we end up.
The Good News: We get the Full Monty
So here we are inside our own action callback for pre_user_query and we’re passed the entire WP_User_Query object, by reference, for us to mangle at will. If this stuff is new to you, you’ll want to get into your favourite WordPress sandbox environment and hook into the pre_user_query action (see below for a simple example), and just dump out the WP_User_Query object with print_r() (or equivalent) and take a look at the cornucopia that is available to you.
Example using functions.php: add_action( 'pre_user_query', 'extended_user_search' ); function extended_user_search( $user_query ){ print_r( $user_query ); } |
And get yourself over to All Users and execute a user search. Ooooh, all the pretty properties!
Cover Your Assets
I can’t emphasize this enough when mucking with query-related low-level actions and filters: make sure you’re only affecting the queries you want to affect! This is perhaps more important with big hooks like pre_get_posts or posts_clauses but you never can be too sure. Because pre_user_query is called EVERY time a new WP_User_Query instance is created, you need to be sure you’re only modifying it at the right time. Chances are, extending the user search will slow down the user query, so to keep things hopping along smoothly, let’s make sure we only affect the actual search query.
Oh, if only is_search() worked here! But that’s only for querying posts, so we’re out of luck. Here’s my take:
- make sure we only run this action when there’s an actual search request, and
- make sure that it’s a user_search and not a post search.
the logic of that last one being that it’s conceivable there may be a plugin or widget that used get_users() or WP_User_Query() while coexisting on a posts search page.
if ( $user_query->query_vars['search'] ) |
is the best way, since it is set as the Users list table is being created, so you can be pretty sure it’s legit. If you wanted additional confirmation, you can compare $_REQUEST['s'] to $user_query->search, but (and here’s a strange one), you have to strip out some asterisks that the list table method sticks in there in what is one of the weirder coding design decisions I’ve seen around the WordPress codebase.
Extending search fields
So, if you’ve output your $user_query object and studied it yet, you’ll note that the actual SQL that gets sent to the database is stored in properties like query_from and query_where. Shiny. These properties are what we’ll want to modify in order to extend our search. Pay special attention to query_where – the 1=1 is a silly shortcut that saves us from having to guess whether we need to add an ‘AND’ to our clause, but after that is this juicy list of fields that will be searched, populated with our search term, and surrounded by percent signs, which are the mySQL equivalent of the ‘*’ wildcard (and is, in fact, related to the asterisks I was talking about earlier).
There’s this nice little method called get_search_sql() that actually generates this clause for you. All you have to do is provide it with a list of fields in an array. The signature is get_search_sql( $search_term, $fields, $use_wildcards = false ). So, for example, to add the user’s email to our search, just do the following:
$user_query->query_where = "WHERE 1=1 " . $user_query->get_search_sql( $search_term, array( 'user_login', 'user_nicename', 'user_email' ), 'both' ) |
Joining user meta
One of the frustrating things with the User list table is that WP_User_Query actually uses WP_Meta_Query, just like WP_Query does. So you get this really nice, and reasonably powerful syntax for bringing meta data into your user query, certainly powerful enough for a user search. And you can’t get to it, because all the arguments for the WP_User_Query constructor are hard-coded into the list table (oh, yeah, I suppose you could extend the WP_Users_List_Table with your own custom list table, and override the constructor – talk about atom bombs and barbecues!)
Moreover, there’s no query_join like there are in the WP_Query clauses you can hang your joins off of. So we’re going to graft our joins onto the end of query_where and just hope no one is looking.
One note before I just open the kimono and show your the whole code: it’s really important to alias your tables (especially your meta tables!) when doing this sort of thing, especially if you get into multiple joins on the same table, otherwise your SQL will break.
So, without further ado:
Extended user search full example
Here we (very anally) check that we’re applying this action only to user search, and then we add user_email and the two meta joins for first_name and last_name, leveraging get_search_sql()
public function extended_user_search( $user_query ){ // Make sure this is only applied to user search if ( $user_query->query_vars['search'] ){ $search = trim( $user_query->query_vars['search'], '*' ); if ( $_REQUEST['s'] == $search ){ global $wpdb; $user_query->query_from .= " JOIN wp_usermeta MF ON MF.user_id = {$wpdb->users}.ID AND MF.meta_key = 'first_name'"; $user_query->query_from .= " JOIN wp_usermeta ML ON ML.user_id = {$wpdb->users}.ID AND ML.meta_key = 'last_name'"; $user_query->query_where = 'WHERE 1=1' . $user_query->get_search_sql( $search, array( 'user_login', 'user_email', 'user_nicename', 'MF.meta_value', 'ML.meta_value' ), 'both' ); } } } |
