1<?php
2/**
3 * User API: WP_User_Query class
4 *
5 * @package WordPress
6 * @subpackage Users
7 * @since 4.4.0
8 */
9
10/**
11 * Core class used for querying users.
12 *
13 * @since 3.1.0
14 *
15 * @see WP_User_Query::prepare_query() for information on accepted arguments.
16 */
17#[AllowDynamicProperties]
18class WP_User_Query {
19
20 /**
21 * Query vars, after parsing
22 *
23 * @since 3.5.0
24 * @var array
25 */
26 public $query_vars = array();
27
28 /**
29 * List of found user IDs.
30 *
31 * @since 3.1.0
32 * @var array
33 */
34 private $results;
35
36 /**
37 * Total number of found users for the current query
38 *
39 * @since 3.1.0
40 * @var int
41 */
42 private $total_users = 0;
43
44 /**
45 * Metadata query container.
46 *
47 * @since 4.2.0
48 * @var WP_Meta_Query
49 */
50 public $meta_query = false;
51
52 /**
53 * The SQL query used to fetch matching users.
54 *
55 * @since 4.4.0
56 * @var string
57 */
58 public $request;
59
60 private $compat_fields = array( 'results', 'total_users' );
61
62 // SQL clauses.
63 public $query_fields;
64 public $query_from;
65 public $query_where;
66 public $query_orderby;
67 public $query_limit;
68
69 /**
70 * Constructor.
71 *
72 * @since 3.1.0
73 *
74 * @param null|string|array $query Optional. The query variables.
75 * See WP_User_Query::prepare_query() for information on accepted arguments.
76 */
77 public function __construct( $query = null ) {
78 if ( ! empty( $query ) ) {
79 $this->prepare_query( $query );
80 $this->query();
81 }
82 }
83
84 /**
85 * Fills in missing query variables with default values.
86 *
87 * @since 4.4.0
88 *
89 * @param string|array $args Query vars, as passed to `WP_User_Query`.
90 * @return array Complete query variables with undefined ones filled in with defaults.
91 */
92 public static function fill_query_vars( $args ) {
93 $defaults = array(
94 'blog_id' => get_current_blog_id(),
95 'role' => '',
96 'role__in' => array(),
97 'role__not_in' => array(),
98 'capability' => '',
99 'capability__in' => array(),
100 'capability__not_in' => array(),
101 'meta_key' => '',
102 'meta_value' => '',
103 'meta_compare' => '',
104 'include' => array(),
105 'exclude' => array(),
106 'search' => '',
107 'search_columns' => array(),
108 'orderby' => 'login',
109 'order' => 'ASC',
110 'offset' => '',
111 'number' => '',
112 'paged' => 1,
113 'count_total' => true,
114 'fields' => 'all',
115 'who' => '',
116 'has_published_posts' => null,
117 'nicename' => '',
118 'nicename__in' => array(),
119 'nicename__not_in' => array(),
120 'login' => '',
121 'login__in' => array(),
122 'login__not_in' => array(),
123 'cache_results' => true,
124 );
125
126 return wp_parse_args( $args, $defaults );
127 }
128
129 /**
130 * Prepares the query variables.
131 *
132 * @since 3.1.0
133 * @since 4.1.0 Added the ability to order by the `include` value.
134 * @since 4.2.0 Added 'meta_value_num' support for `$orderby` parameter. Added multi-dimensional array syntax
135 * for `$orderby` parameter.
136 * @since 4.3.0 Added 'has_published_posts' parameter.
137 * @since 4.4.0 Added 'paged', 'role__in', and 'role__not_in' parameters. The 'role' parameter was updated to
138 * permit an array or comma-separated list of values. The 'number' parameter was updated to support
139 * querying for all users with using -1.
140 * @since 4.7.0 Added 'nicename', 'nicename__in', 'nicename__not_in', 'login', 'login__in',
141 * and 'login__not_in' parameters.
142 * @since 5.1.0 Introduced the 'meta_compare_key' parameter.
143 * @since 5.3.0 Introduced the 'meta_type_key' parameter.
144 * @since 5.9.0 Added 'capability', 'capability__in', and 'capability__not_in' parameters.
145 * Deprecated the 'who' parameter.
146 * @since 6.3.0 Added 'cache_results' parameter.
147 *
148 * @global wpdb $wpdb WordPress database abstraction object.
149 * @global WP_Roles $wp_roles WordPress role management object.
150 *
151 * @param string|array $query {
152 * Optional. Array or string of query parameters.
153 *
154 * @type int $blog_id The site ID. Default is the current site.
155 * @type string|string[] $role An array or a comma-separated list of role names that users
156 * must match to be included in results. Note that this is
157 * an inclusive list: users must match *each* role. Default empty.
158 * @type string[] $role__in An array of role names. Matched users must have at least one
159 * of these roles. Default empty array.
160 * @type string[] $role__not_in An array of role names to exclude. Users matching one or more
161 * of these roles will not be included in results. Default empty array.
162 * @type string|string[] $meta_key Meta key or keys to filter by.
163 * @type string|string[] $meta_value Meta value or values to filter by.
164 * @type string $meta_compare MySQL operator used for comparing the meta value.
165 * See WP_Meta_Query::__construct() for accepted values and default value.
166 * @type string $meta_compare_key MySQL operator used for comparing the meta key.
167 * See WP_Meta_Query::__construct() for accepted values and default value.
168 * @type string $meta_type MySQL data type that the meta_value column will be CAST to for comparisons.
169 * See WP_Meta_Query::__construct() for accepted values and default value.
170 * @type string $meta_type_key MySQL data type that the meta_key column will be CAST to for comparisons.
171 * See WP_Meta_Query::__construct() for accepted values and default value.
172 * @type array $meta_query An associative array of WP_Meta_Query arguments.
173 * See WP_Meta_Query::__construct() for accepted values.
174 * @type string|string[] $capability An array or a comma-separated list of capability names that users
175 * must match to be included in results. Note that this is
176 * an inclusive list: users must match *each* capability.
177 * Does NOT work for capabilities not in the database or filtered
178 * via {@see 'map_meta_cap'}. Default empty.
179 * @type string[] $capability__in An array of capability names. Matched users must have at least one
180 * of these capabilities.
181 * Does NOT work for capabilities not in the database or filtered
182 * via {@see 'map_meta_cap'}. Default empty array.
183 * @type string[] $capability__not_in An array of capability names to exclude. Users matching one or more
184 * of these capabilities will not be included in results.
185 * Does NOT work for capabilities not in the database or filtered
186 * via {@see 'map_meta_cap'}. Default empty array.
187 * @type int[] $include An array of user IDs to include. Default empty array.
188 * @type int[] $exclude An array of user IDs to exclude. Default empty array.
189 * @type string $search Search keyword. Searches for possible string matches on columns.
190 * When `$search_columns` is left empty, it tries to determine which
191 * column to search in based on search string. Default empty.
192 * @type string[] $search_columns Array of column names to be searched. Accepts 'ID', 'user_login',
193 * 'user_email', 'user_url', 'user_nicename', 'display_name'.
194 * Default empty array.
195 * @type string|array $orderby Field(s) to sort the retrieved users by. May be a single value,
196 * an array of values, or a multi-dimensional array with fields as
197 * keys and orders ('ASC' or 'DESC') as values. Accepted values are:
198 * - 'ID'
199 * - 'display_name' (or 'name')
200 * - 'include'
201 * - 'user_login' (or 'login')
202 * - 'login__in'
203 * - 'user_nicename' (or 'nicename')
204 * - 'nicename__in'
205 * - 'user_email' (or 'email')
206 * - 'user_url' (or 'url')
207 * - 'user_registered' (or 'registered')
208 * - 'post_count'
209 * - 'meta_value'
210 * - 'meta_value_num'
211 * - The value of `$meta_key`
212 * - An array key of `$meta_query`
213 * To use 'meta_value' or 'meta_value_num', `$meta_key`
214 * must be also be defined. Default 'user_login'.
215 * @type string $order Designates ascending or descending order of users. Order values
216 * passed as part of an `$orderby` array take precedence over this
217 * parameter. Accepts 'ASC', 'DESC'. Default 'ASC'.
218 * @type int $offset Number of users to offset in retrieved results. Can be used in
219 * conjunction with pagination. Default 0.
220 * @type int $number Number of users to limit the query for. Can be used in
221 * conjunction with pagination. Value -1 (all) is supported, but
222 * should be used with caution on larger sites.
223 * Default -1 (all users).
224 * @type int $paged When used with number, defines the page of results to return.
225 * Default 1.
226 * @type bool $count_total Whether to count the total number of users found. If pagination
227 * is not needed, setting this to false can improve performance.
228 * Default true.
229 * @type string|string[] $fields Which fields to return. Single or all fields (string), or array
230 * of fields. Accepts:
231 * - 'ID'
232 * - 'display_name'
233 * - 'user_login'
234 * - 'user_nicename'
235 * - 'user_email'
236 * - 'user_url'
237 * - 'user_registered'
238 * - 'user_pass'
239 * - 'user_activation_key'
240 * - 'user_status'
241 * - 'spam' (only available on multisite installs)
242 * - 'deleted' (only available on multisite installs)
243 * - 'all' for all fields and loads user meta.
244 * - 'all_with_meta' Deprecated. Use 'all'.
245 * Default 'all'.
246 * @type string $who Deprecated, use `$capability` instead.
247 * Type of users to query. Accepts 'authors'.
248 * Default empty (all users).
249 * @type bool|string[] $has_published_posts Pass an array of post types to filter results to users who have
250 * published posts in those post types. `true` is an alias for all
251 * public post types.
252 * @type string $nicename The user nicename. Default empty.
253 * @type string[] $nicename__in An array of nicenames to include. Users matching one of these
254 * nicenames will be included in results. Default empty array.
255 * @type string[] $nicename__not_in An array of nicenames to exclude. Users matching one of these
256 * nicenames will not be included in results. Default empty array.
257 * @type string $login The user login. Default empty.
258 * @type string[] $login__in An array of logins to include. Users matching one of these
259 * logins will be included in results. Default empty array.
260 * @type string[] $login__not_in An array of logins to exclude. Users matching one of these
261 * logins will not be included in results. Default empty array.
262 * @type bool $cache_results Whether to cache user information. Default true.
263 * }
264 */
265 public function prepare_query( $query = array() ) {
266 global $wpdb, $wp_roles;
267
268 if ( empty( $this->query_vars ) || ! empty( $query ) ) {
269 $this->query_limit = null;
270 $this->query_vars = $this->fill_query_vars( $query );
271 }
272
273 /**
274 * Fires before the WP_User_Query has been parsed.
275 *
276 * The passed WP_User_Query object contains the query variables,
277 * not yet passed into SQL.
278 *
279 * @since 4.0.0
280 *
281 * @param WP_User_Query $query Current instance of WP_User_Query (passed by reference).
282 */
283 do_action_ref_array( 'pre_get_users', array( &$this ) );
284
285 // Ensure that query vars are filled after 'pre_get_users'.
286 $qv =& $this->query_vars;
287 $qv = $this->fill_query_vars( $qv );
288
289 $allowed_fields = array(
290 'id',
291 'user_login',
292 'user_pass',
293 'user_nicename',
294 'user_email',
295 'user_url',
296 'user_registered',
297 'user_activation_key',
298 'user_status',
299 'display_name',
300 );
301 if ( is_multisite() ) {
302 $allowed_fields[] = 'spam';
303 $allowed_fields[] = 'deleted';
304 }
305
306 if ( is_array( $qv['fields'] ) ) {
307 $qv['fields'] = array_map( 'strtolower', $qv['fields'] );
308 $qv['fields'] = array_intersect( array_unique( $qv['fields'] ), $allowed_fields );
309
310 if ( empty( $qv['fields'] ) ) {
311 $qv['fields'] = array( 'id' );
312 }
313
314 $this->query_fields = array();
315 foreach ( $qv['fields'] as $field ) {
316 $field = 'id' === $field ? 'ID' : sanitize_key( $field );
317 $this->query_fields[] = "$wpdb->users.$field";
318 }
319 $this->query_fields = implode( ',', $this->query_fields );
320 } elseif ( 'all_with_meta' === $qv['fields'] || 'all' === $qv['fields'] || ! in_array( $qv['fields'], $allowed_fields, true ) ) {
321 $this->query_fields = "$wpdb->users.ID";
322 } else {
323 $field = 'id' === strtolower( $qv['fields'] ) ? 'ID' : sanitize_key( $qv['fields'] );
324 $this->query_fields = "$wpdb->users.$field";
325 }
326
327 if ( isset( $qv['count_total'] ) && $qv['count_total'] ) {
328 $this->query_fields = 'SQL_CALC_FOUND_ROWS ' . $this->query_fields;
329 }
330
331 $this->query_from = "FROM $wpdb->users";
332 $this->query_where = 'WHERE 1=1';
333
334 // Parse and sanitize 'include', for use by 'orderby' as well as 'include' below.
335 if ( ! empty( $qv['include'] ) ) {
336 $include = wp_parse_id_list( $qv['include'] );
337 } else {
338 $include = false;
339 }
340
341 $blog_id = 0;
342 if ( isset( $qv['blog_id'] ) ) {
343 $blog_id = absint( $qv['blog_id'] );
344 }
345
346 if ( $qv['has_published_posts'] && $blog_id ) {
347 if ( true === $qv['has_published_posts'] ) {
348 $post_types = get_post_types( array( 'public' => true ) );
349 } else {
350 $post_types = (array) $qv['has_published_posts'];
351 }
352
353 foreach ( $post_types as &$post_type ) {
354 $post_type = $wpdb->prepare( '%s', $post_type );
355 }
356
357 $posts_table = $wpdb->get_blog_prefix( $blog_id ) . 'posts';
358 $this->query_where .= " AND $wpdb->users.ID IN ( SELECT DISTINCT $posts_table.post_author FROM $posts_table WHERE $posts_table.post_status = 'publish' AND $posts_table.post_type IN ( " . implode( ', ', $post_types ) . ' ) )';
359 }
360
361 // nicename
362 if ( '' !== $qv['nicename'] ) {
363 $this->query_where .= $wpdb->prepare( ' AND user_nicename = %s', $qv['nicename'] );
364 }
365
366 if ( ! empty( $qv['nicename__in'] ) ) {
367 $sanitized_nicename__in = array_map( 'esc_sql', $qv['nicename__in'] );
368 $nicename__in = implode( "','", $sanitized_nicename__in );
369 $this->query_where .= " AND user_nicename IN ( '$nicename__in' )";
370 }
371
372 if ( ! empty( $qv['nicename__not_in'] ) ) {
373 $sanitized_nicename__not_in = array_map( 'esc_sql', $qv['nicename__not_in'] );
374 $nicename__not_in = implode( "','", $sanitized_nicename__not_in );
375 $this->query_where .= " AND user_nicename NOT IN ( '$nicename__not_in' )";
376 }
377
378 // login
379 if ( '' !== $qv['login'] ) {
380 $this->query_where .= $wpdb->prepare( ' AND user_login = %s', $qv['login'] );
381 }
382
383 if ( ! empty( $qv['login__in'] ) ) {
384 $sanitized_login__in = array_map( 'esc_sql', $qv['login__in'] );
385 $login__in = implode( "','", $sanitized_login__in );
386 $this->query_where .= " AND user_login IN ( '$login__in' )";
387 }
388
389 if ( ! empty( $qv['login__not_in'] ) ) {
390 $sanitized_login__not_in = array_map( 'esc_sql', $qv['login__not_in'] );
391 $login__not_in = implode( "','", $sanitized_login__not_in );
392 $this->query_where .= " AND user_login NOT IN ( '$login__not_in' )";
393 }
394
395 // Meta query.
396 $this->meta_query = new WP_Meta_Query();
397 $this->meta_query->parse_query_vars( $qv );
398
399 if ( isset( $qv['who'] ) && 'authors' === $qv['who'] && $blog_id ) {
400 _deprecated_argument(
401 'WP_User_Query',
402 '5.9.0',
403 sprintf(
404 /* translators: 1: who, 2: capability */
405 __( '%1$s is deprecated. Use %2$s instead.' ),
406 '<code>who</code>',
407 '<code>capability</code>'
408 )
409 );
410
411 $who_query = array(
412 'key' => $wpdb->get_blog_prefix( $blog_id ) . 'user_level',
413 'value' => 0,
414 'compare' => '!=',
415 );
416
417 // Prevent extra meta query.
418 $qv['blog_id'] = 0;
419 $blog_id = 0;
420
421 if ( empty( $this->meta_query->queries ) ) {
422 $this->meta_query->queries = array( $who_query );
423 } else {
424 // Append the cap query to the original queries and reparse the query.
425 $this->meta_query->queries = array(
426 'relation' => 'AND',
427 array( $this->meta_query->queries, $who_query ),
428 );
429 }
430
431 $this->meta_query->parse_query_vars( $this->meta_query->queries );
432 }
433
434 // Roles.
435 $roles = array();
436 if ( isset( $qv['role'] ) ) {
437 if ( is_array( $qv['role'] ) ) {
438 $roles = $qv['role'];
439 } elseif ( is_string( $qv['role'] ) && ! empty( $qv['role'] ) ) {
440 $roles = array_map( 'trim', explode( ',', $qv['role'] ) );
441 }
442 }
443
444 $role__in = array();
445 if ( isset( $qv['role__in'] ) ) {
446 $role__in = (array) $qv['role__in'];
447 }
448
449 $role__not_in = array();
450 if ( isset( $qv['role__not_in'] ) ) {
451 $role__not_in = (array) $qv['role__not_in'];
452 }
453
454 // Capabilities.
455 $available_roles = array();
456
457 if ( ! empty( $qv['capability'] ) || ! empty( $qv['capability__in'] ) || ! empty( $qv['capability__not_in'] ) ) {
458 $wp_roles->for_site( $blog_id );
459 $available_roles = $wp_roles->roles;
460 }
461
462 $capabilities = array();
463 if ( ! empty( $qv['capability'] ) ) {
464 if ( is_array( $qv['capability'] ) ) {
465 $capabilities = $qv['capability'];
466 } elseif ( is_string( $qv['capability'] ) ) {
467 $capabilities = array_map( 'trim', explode( ',', $qv['capability'] ) );
468 }
469 }
470
471 $capability__in = array();
472 if ( ! empty( $qv['capability__in'] ) ) {
473 $capability__in = (array) $qv['capability__in'];
474 }
475
476 $capability__not_in = array();
477 if ( ! empty( $qv['capability__not_in'] ) ) {
478 $capability__not_in = (array) $qv['capability__not_in'];
479 }
480
481 // Keep track of all capabilities and the roles they're added on.
482 $caps_with_roles = array();
483
484 foreach ( $available_roles as $role => $role_data ) {
485 $role_caps = array_keys( array_filter( $role_data['capabilities'] ) );
486
487 foreach ( $capabilities as $cap ) {
488 if ( in_array( $cap, $role_caps, true ) ) {
489 $caps_with_roles[ $cap ][] = $role;
490 break;
491 }
492 }
493
494 foreach ( $capability__in as $cap ) {
495 if ( in_array( $cap, $role_caps, true ) ) {
496 $role__in[] = $role;
497 break;
498 }
499 }
500
501 foreach ( $capability__not_in as $cap ) {
502 if ( in_array( $cap, $role_caps, true ) ) {
503 $role__not_in[] = $role;
504 break;
505 }
506 }
507 }
508
509 $role__in = array_merge( $role__in, $capability__in );
510 $role__not_in = array_merge( $role__not_in, $capability__not_in );
511
512 $roles = array_unique( $roles );
513 $role__in = array_unique( $role__in );
514 $role__not_in = array_unique( $role__not_in );
515
516 // Support querying by capabilities added directly to users.
517 if ( $blog_id && ! empty( $capabilities ) ) {
518 $capabilities_clauses = array( 'relation' => 'AND' );
519
520 foreach ( $capabilities as $cap ) {
521 $clause = array( 'relation' => 'OR' );
522
523 $clause[] = array(
524 'key' => $wpdb->get_blog_prefix( $blog_id ) . 'capabilities',
525 'value' => '"' . $cap . '"',
526 'compare' => 'LIKE',
527 );
528
529 if ( ! empty( $caps_with_roles[ $cap ] ) ) {
530 foreach ( $caps_with_roles[ $cap ] as $role ) {
531 $clause[] = array(
532 'key' => $wpdb->get_blog_prefix( $blog_id ) . 'capabilities',
533 'value' => '"' . $role . '"',
534 'compare' => 'LIKE',
535 );
536 }
537 }
538
539 $capabilities_clauses[] = $clause;
540 }
541
542 $role_queries[] = $capabilities_clauses;
543
544 if ( empty( $this->meta_query->queries ) ) {
545 $this->meta_query->queries[] = $capabilities_clauses;
546 } else {
547 // Append the cap query to the original queries and reparse the query.
548 $this->meta_query->queries = array(
549 'relation' => 'AND',
550 array( $this->meta_query->queries, array( $capabilities_clauses ) ),
551 );
552 }
553
554 $this->meta_query->parse_query_vars( $this->meta_query->queries );
555 }
556
557 if ( $blog_id && ( ! empty( $roles ) || ! empty( $role__in ) || ! empty( $role__not_in ) || is_multisite() ) ) {
558 $role_queries = array();
559
560 $roles_clauses = array( 'relation' => 'AND' );
561 if ( ! empty( $roles ) ) {
562 foreach ( $roles as $role ) {
563 $roles_clauses[] = array(
564 'key' => $wpdb->get_blog_prefix( $blog_id ) . 'capabilities',
565 'value' => '"' . $role . '"',
566 'compare' => 'LIKE',
567 );
568 }
569
570 $role_queries[] = $roles_clauses;
571 }
572
573 $role__in_clauses = array( 'relation' => 'OR' );
574 if ( ! empty( $role__in ) ) {
575 foreach ( $role__in as $role ) {
576 $role__in_clauses[] = array(
577 'key' => $wpdb->get_blog_prefix( $blog_id ) . 'capabilities',
578 'value' => '"' . $role . '"',
579 'compare' => 'LIKE',
580 );
581 }
582
583 $role_queries[] = $role__in_clauses;
584 }
585
586 $role__not_in_clauses = array( 'relation' => 'AND' );
587 if ( ! empty( $role__not_in ) ) {
588 foreach ( $role__not_in as $role ) {
589 $role__not_in_clauses[] = array(
590 'key' => $wpdb->get_blog_prefix( $blog_id ) . 'capabilities',
591 'value' => '"' . $role . '"',
592 'compare' => 'NOT LIKE',
593 );
594 }
595
596 $role_queries[] = $role__not_in_clauses;
597 }
598
599 // If there are no specific roles named, make sure the user is a member of the site.
600 if ( empty( $role_queries ) ) {
601 $role_queries[] = array(
602 'key' => $wpdb->get_blog_prefix( $blog_id ) . 'capabilities',
603 'compare' => 'EXISTS',
604 );
605 }
606
607 // Specify that role queries should be joined with AND.
608 $role_queries['relation'] = 'AND';
609
610 if ( empty( $this->meta_query->queries ) ) {
611 $this->meta_query->queries = $role_queries;
612 } else {
613 // Append the cap query to the original queries and reparse the query.
614 $this->meta_query->queries = array(
615 'relation' => 'AND',
616 array( $this->meta_query->queries, $role_queries ),
617 );
618 }
619
620 $this->meta_query->parse_query_vars( $this->meta_query->queries );
621 }
622
623 if ( ! empty( $this->meta_query->queries ) ) {
624 $clauses = $this->meta_query->get_sql( 'user', $wpdb->users, 'ID', $this );
625 $this->query_from .= $clauses['join'];
626 $this->query_where .= $clauses['where'];
627
628 if ( $this->meta_query->has_or_relation() ) {
629 $this->query_fields = 'DISTINCT ' . $this->query_fields;
630 }
631 }
632
633 // Sorting.
634 $qv['order'] = isset( $qv['order'] ) ? strtoupper( $qv['order'] ) : '';
635 $order = $this->parse_order( $qv['order'] );
636
637 if ( empty( $qv['orderby'] ) ) {
638 // Default order is by 'user_login'.
639 $ordersby = array( 'user_login' => $order );
640 } elseif ( is_array( $qv['orderby'] ) ) {
641 $ordersby = $qv['orderby'];
642 } else {
643 // 'orderby' values may be a comma- or space-separated list.
644 $ordersby = preg_split( '/[,\s]+/', $qv['orderby'] );
645 }
646
647 $orderby_array = array();
648 foreach ( $ordersby as $_key => $_value ) {
649 if ( ! $_value ) {
650 continue;
651 }
652
653 if ( is_int( $_key ) ) {
654 // Integer key means this is a flat array of 'orderby' fields.
655 $_orderby = $_value;
656 $_order = $order;
657 } else {
658 // Non-integer key means this the key is the field and the value is ASC/DESC.
659 $_orderby = $_key;
660 $_order = $_value;
661 }
662
663 $parsed = $this->parse_orderby( $_orderby );
664
665 if ( ! $parsed ) {
666 continue;
667 }
668
669 if ( 'nicename__in' === $_orderby || 'login__in' === $_orderby ) {
670 $orderby_array[] = $parsed;
671 } else {
672 $orderby_array[] = $parsed . ' ' . $this->parse_order( $_order );
673 }
674 }
675
676 // If no valid clauses were found, order by user_login.
677 if ( empty( $orderby_array ) ) {
678 $orderby_array[] = "user_login $order";
679 }
680
681 $this->query_orderby = 'ORDER BY ' . implode( ', ', $orderby_array );
682
683 // Limit.
684 if ( isset( $qv['number'] ) && $qv['number'] > 0 ) {
685 if ( $qv['offset'] ) {
686 $this->query_limit = $wpdb->prepare( 'LIMIT %d, %d', $qv['offset'], $qv['number'] );
687 } else {
688 $this->query_limit = $wpdb->prepare( 'LIMIT %d, %d', $qv['number'] * ( $qv['paged'] - 1 ), $qv['number'] );
689 }
690 }
691
692 $search = '';
693 if ( isset( $qv['search'] ) ) {
694 $search = trim( $qv['search'] );
695 }
696
697 if ( $search ) {
698 $leading_wild = ( ltrim( $search, '*' ) !== $search );
699 $trailing_wild = ( rtrim( $search, '*' ) !== $search );
700 if ( $leading_wild && $trailing_wild ) {
701 $wild = 'both';
702 } elseif ( $leading_wild ) {
703 $wild = 'leading';
704 } elseif ( $trailing_wild ) {
705 $wild = 'trailing';
706 } else {
707 $wild = false;
708 }
709 if ( $wild ) {
710 $search = trim( $search, '*' );
711 }
712
713 $search_columns = array();
714 if ( $qv['search_columns'] ) {
715 $search_columns = array_intersect( $qv['search_columns'], array( 'ID', 'user_login', 'user_email', 'user_url', 'user_nicename', 'display_name' ) );
716 }
717 if ( ! $search_columns ) {
718 if ( str_contains( $search, '@' ) ) {
719 $search_columns = array( 'user_email' );
720 } elseif ( is_numeric( $search ) ) {
721 $search_columns = array( 'user_login', 'ID' );
722 } elseif ( preg_match( '|^https?://|', $search ) && ! ( is_multisite() && wp_is_large_network( 'users' ) ) ) {
723 $search_columns = array( 'user_url' );
724 } else {
725 $search_columns = array( 'user_login', 'user_url', 'user_email', 'user_nicename', 'display_name' );
726 }
727 }
728
729 /**
730 * Filters the columns to search in a WP_User_Query search.
731 *
732 * The default columns depend on the search term, and include 'ID', 'user_login',
733 * 'user_email', 'user_url', 'user_nicename', and 'display_name'.
734 *
735 * @since 3.6.0
736 *
737 * @param string[] $search_columns Array of column names to be searched.
738 * @param string $search Text being searched.
739 * @param WP_User_Query $query The current WP_User_Query instance.
740 */
741 $search_columns = apply_filters( 'user_search_columns', $search_columns, $search, $this );
742
743 $this->query_where .= $this->get_search_sql( $search, $search_columns, $wild );
744 }
745
746 if ( ! empty( $include ) ) {
747 // Sanitized earlier.
748 $ids = implode( ',', $include );
749 $this->query_where .= " AND $wpdb->users.ID IN ($ids)";
750 } elseif ( ! empty( $qv['exclude'] ) ) {
751 $ids = implode( ',', wp_parse_id_list( $qv['exclude'] ) );
752 $this->query_where .= " AND $wpdb->users.ID NOT IN ($ids)";
753 }
754
755 // Date queries are allowed for the user_registered field.
756 if ( ! empty( $qv['date_query'] ) && is_array( $qv['date_query'] ) ) {
757 $date_query = new WP_Date_Query( $qv['date_query'], 'user_registered' );
758 $this->query_where .= $date_query->get_sql();
759 }
760
761 /**
762 * Fires after the WP_User_Query has been parsed, and before
763 * the query is executed.
764 *
765 * The passed WP_User_Query object contains SQL parts formed
766 * from parsing the given query.
767 *
768 * @since 3.1.0
769 *
770 * @param WP_User_Query $query Current instance of WP_User_Query (passed by reference).
771 */
772 do_action_ref_array( 'pre_user_query', array( &$this ) );
773 }
774
775 /**
776 * Executes the query, with the current variables.
777 *
778 * @since 3.1.0
779 *
780 * @global wpdb $wpdb WordPress database abstraction object.
781 */
782 public function query() {
783 global $wpdb;
784
785 if ( ! did_action( 'plugins_loaded' ) ) {
786 _doing_it_wrong(
787 'WP_User_Query::query',
788 sprintf(
789 /* translators: %s: plugins_loaded */
790 __( 'User queries should not be run before the %s hook.' ),
791 '<code>plugins_loaded</code>'
792 ),
793 '6.1.1'
794 );
795 }
796
797 $qv =& $this->query_vars;
798
799 // Do not cache results if more than 3 fields are requested.
800 if ( is_array( $qv['fields'] ) && count( $qv['fields'] ) > 3 ) {
801 $qv['cache_results'] = false;
802 }
803
804 /**
805 * Filters the users array before the query takes place.
806 *
807 * Return a non-null value to bypass WordPress' default user queries.
808 *
809 * Filtering functions that require pagination information are encouraged to set
810 * the `total_users` property of the WP_User_Query object, passed to the filter
811 * by reference. If WP_User_Query does not perform a database query, it will not
812 * have enough information to generate these values itself.
813 *
814 * @since 5.1.0
815 *
816 * @param array|null $results Return an array of user data to short-circuit WP's user query
817 * or null to allow WP to run its normal queries.
818 * @param WP_User_Query $query The WP_User_Query instance (passed by reference).
819 */
820 $this->results = apply_filters_ref_array( 'users_pre_query', array( null, &$this ) );
821
822 if ( null === $this->results ) {
823 // Beginning of the string is on a new line to prevent leading whitespace. See https://core.trac.wordpress.org/ticket/56841.
824 $this->request =
825 "SELECT {$this->query_fields}
826 {$this->query_from}
827 {$this->query_where}
828 {$this->query_orderby}
829 {$this->query_limit}";
830 $cache_value = false;
831 $cache_key = $this->generate_cache_key( $qv, $this->request );
832 $cache_group = 'user-queries';
833 $last_changed = $this->get_cache_last_changed( $qv );
834
835 if ( $qv['cache_results'] ) {
836 $cache_value = wp_cache_get_salted( $cache_key, $cache_group, $last_changed );
837 }
838 if ( false !== $cache_value ) {
839 $this->results = $cache_value['user_data'];
840 $this->total_users = $cache_value['total_users'];
841 } else {
842
843 if ( is_array( $qv['fields'] ) ) {
844 $this->results = $wpdb->get_results( $this->request );
845 } else {
846 $this->results = $wpdb->get_col( $this->request );
847 }
848
849 if ( isset( $qv['count_total'] ) && $qv['count_total'] ) {
850 /**
851 * Filters SELECT FOUND_ROWS() query for the current WP_User_Query instance.
852 *
853 * @since 3.2.0
854 * @since 5.1.0 Added the `$this` parameter.
855 *
856 * @global wpdb $wpdb WordPress database abstraction object.
857 *
858 * @param string $sql The SELECT FOUND_ROWS() query for the current WP_User_Query.
859 * @param WP_User_Query $query The current WP_User_Query instance.
860 */
861 $found_users_query = apply_filters( 'found_users_query', 'SELECT FOUND_ROWS()', $this );
862
863 $this->total_users = (int) $wpdb->get_var( $found_users_query );
864 }
865
866 if ( $qv['cache_results'] ) {
867 $cache_value = array(
868 'user_data' => $this->results,
869 'total_users' => $this->total_users,
870 );
871 wp_cache_set_salted( $cache_key, $cache_value, $cache_group, $last_changed );
872 }
873 }
874 }
875
876 if ( ! $this->results ) {
877 return;
878 }
879 if (
880 is_array( $qv['fields'] ) &&
881 isset( $this->results[0]->ID )
882 ) {
883 foreach ( $this->results as $result ) {
884 $result->id = $result->ID;
885 }
886 } elseif ( 'all_with_meta' === $qv['fields'] || 'all' === $qv['fields'] ) {
887 if ( function_exists( 'cache_users' ) ) {
888 cache_users( $this->results );
889 }
890
891 $r = array();
892 foreach ( $this->results as $userid ) {
893 if ( 'all_with_meta' === $qv['fields'] ) {
894 $r[ $userid ] = new WP_User( $userid, '', $qv['blog_id'] );
895 } else {
896 $r[] = new WP_User( $userid, '', $qv['blog_id'] );
897 }
898 }
899
900 $this->results = $r;
901 }
902 }
903
904 /**
905 * Retrieves query variable.
906 *
907 * @since 3.5.0
908 *
909 * @param string $query_var Query variable key.
910 * @return mixed
911 */
912 public function get( $query_var ) {
913 if ( isset( $this->query_vars[ $query_var ] ) ) {
914 return $this->query_vars[ $query_var ];
915 }
916
917 return null;
918 }
919
920 /**
921 * Sets query variable.
922 *
923 * @since 3.5.0
924 *
925 * @param string $query_var Query variable key.
926 * @param mixed $value Query variable value.
927 */
928 public function set( $query_var, $value ) {
929 $this->query_vars[ $query_var ] = $value;
930 }
931
932 /**
933 * Used internally to generate an SQL string for searching across multiple columns.
934 *
935 * @since 3.1.0
936 *
937 * @global wpdb $wpdb WordPress database abstraction object.
938 *
939 * @param string $search Search string.
940 * @param string[] $columns Array of columns to search.
941 * @param bool $wild Whether to allow wildcard searches. Default is false for Network Admin, true for single site.
942 * Single site allows leading and trailing wildcards, Network Admin only trailing.
943 * @return string
944 */
945 protected function get_search_sql( $search, $columns, $wild = false ) {
946 global $wpdb;
947
948 $searches = array();
949 $leading_wild = ( 'leading' === $wild || 'both' === $wild ) ? '%' : '';
950 $trailing_wild = ( 'trailing' === $wild || 'both' === $wild ) ? '%' : '';
951 $like = $leading_wild . $wpdb->esc_like( $search ) . $trailing_wild;
952
953 foreach ( $columns as $column ) {
954 if ( 'ID' === $column ) {
955 $searches[] = $wpdb->prepare( "$column = %s", $search );
956 } else {
957 $searches[] = $wpdb->prepare( "$column LIKE %s", $like );
958 }
959 }
960
961 return ' AND (' . implode( ' OR ', $searches ) . ')';
962 }
963
964 /**
965 * Returns the list of users.
966 *
967 * @since 3.1.0
968 *
969 * @return array Array of results.
970 */
971 public function get_results() {
972 return $this->results;
973 }
974
975 /**
976 * Returns the total number of users for the current query.
977 *
978 * @since 3.1.0
979 *
980 * @return int Number of total users.
981 */
982 public function get_total() {
983 return $this->total_users;
984 }
985
986 /**
987 * Parses and sanitizes 'orderby' keys passed to the user query.
988 *
989 * @since 4.2.0
990 *
991 * @global wpdb $wpdb WordPress database abstraction object.
992 *
993 * @param string $orderby Alias for the field to order by.
994 * @return string Value to used in the ORDER clause, if `$orderby` is valid.
995 */
996 protected function parse_orderby( $orderby ) {
997 global $wpdb;
998
999 $meta_query_clauses = $this->meta_query->get_clauses();
1000
1001 $_orderby = '';
1002 if ( in_array( $orderby, array( 'login', 'nicename', 'email', 'url', 'registered' ), true ) ) {
1003 $_orderby = 'user_' . $orderby;
1004 } elseif ( in_array( $orderby, array( 'user_login', 'user_nicename', 'user_email', 'user_url', 'user_registered' ), true ) ) {
1005 $_orderby = $orderby;
1006 } elseif ( 'name' === $orderby || 'display_name' === $orderby ) {
1007 $_orderby = 'display_name';
1008 } elseif ( 'post_count' === $orderby ) {
1009 // @todo Avoid the JOIN.
1010 $where = get_posts_by_author_sql( 'post' );
1011 $this->query_from .= " LEFT OUTER JOIN (
1012 SELECT post_author, COUNT(*) as post_count
1013 FROM $wpdb->posts
1014 $where
1015 GROUP BY post_author
1016 ) p ON ({$wpdb->users}.ID = p.post_author)";
1017 $_orderby = 'post_count';
1018 } elseif ( 'ID' === $orderby || 'id' === $orderby ) {
1019 $_orderby = 'ID';
1020 } elseif ( 'meta_value' === $orderby || $this->get( 'meta_key' ) === $orderby ) {
1021 $_orderby = "$wpdb->usermeta.meta_value";
1022 } elseif ( 'meta_value_num' === $orderby ) {
1023 $_orderby = "$wpdb->usermeta.meta_value+0";
1024 } elseif ( 'include' === $orderby && ! empty( $this->query_vars['include'] ) ) {
1025 $include = wp_parse_id_list( $this->query_vars['include'] );
1026 $include_sql = implode( ',', $include );
1027 $_orderby = "FIELD( $wpdb->users.ID, $include_sql )";
1028 } elseif ( 'nicename__in' === $orderby ) {
1029 $sanitized_nicename__in = array_map( 'esc_sql', $this->query_vars['nicename__in'] );
1030 $nicename__in = implode( "','", $sanitized_nicename__in );
1031 $_orderby = "FIELD( user_nicename, '$nicename__in' )";
1032 } elseif ( 'login__in' === $orderby ) {
1033 $sanitized_login__in = array_map( 'esc_sql', $this->query_vars['login__in'] );
1034 $login__in = implode( "','", $sanitized_login__in );
1035 $_orderby = "FIELD( user_login, '$login__in' )";
1036 } elseif ( isset( $meta_query_clauses[ $orderby ] ) ) {
1037 $meta_clause = $meta_query_clauses[ $orderby ];
1038 $_orderby = sprintf( 'CAST(%s.meta_value AS %s)', esc_sql( $meta_clause['alias'] ), esc_sql( $meta_clause['cast'] ) );
1039 }
1040
1041 return $_orderby;
1042 }
1043
1044 /**
1045 * Generate cache key.
1046 *
1047 * @since 6.3.0
1048 * @since 6.9.0 The `$args` parameter was deprecated and renamed to `$deprecated`.
1049 *
1050 * @global wpdb $wpdb WordPress database abstraction object.
1051 *
1052 * @param array $deprecated Unused.
1053 * @param string $sql SQL statement.
1054 * @return string Cache key.
1055 */
1056 protected function generate_cache_key( array $deprecated, $sql ) {
1057 global $wpdb;
1058
1059 // Replace wpdb placeholder in the SQL statement used by the cache key.
1060 $sql = $wpdb->remove_placeholder_escape( $sql );
1061
1062 $key = md5( $sql );
1063
1064 return "get_users:$key";
1065 }
1066
1067 /**
1068 * Retrieves the last changed cache timestamp for users and optionally posts.
1069 *
1070 * @since 6.9.0
1071 *
1072 * @param array $args Query arguments.
1073 * @return string[] The last changed timestamp string for the relevant cache groups.
1074 */
1075 protected function get_cache_last_changed( array $args ) {
1076 $last_changed = (array) wp_cache_get_last_changed( 'users' );
1077
1078 if ( empty( $args['orderby'] ) ) {
1079 // Default order is by 'user_login'.
1080 $ordersby = array( 'user_login' => '' );
1081 } elseif ( is_array( $args['orderby'] ) ) {
1082 $ordersby = $args['orderby'];
1083 } else {
1084 // 'orderby' values may be a comma- or space-separated list.
1085 $ordersby = preg_split( '/[,\s]+/', $args['orderby'] );
1086 }
1087
1088 $blog_id = 0;
1089 if ( isset( $args['blog_id'] ) ) {
1090 $blog_id = absint( $args['blog_id'] );
1091 }
1092
1093 if ( $args['has_published_posts'] || in_array( 'post_count', $ordersby, true ) ) {
1094 $switch = $blog_id && get_current_blog_id() !== $blog_id;
1095 if ( $switch ) {
1096 switch_to_blog( $blog_id );
1097 }
1098
1099 $last_changed[] = wp_cache_get_last_changed( 'posts' );
1100
1101 if ( $switch ) {
1102 restore_current_blog();
1103 }
1104 }
1105
1106 return $last_changed;
1107 }
1108
1109 /**
1110 * Parses an 'order' query variable and casts it to ASC or DESC as necessary.
1111 *
1112 * @since 4.2.0
1113 *
1114 * @param string $order The 'order' query variable.
1115 * @return string The sanitized 'order' query variable.
1116 */
1117 protected function parse_order( $order ) {
1118 if ( ! is_string( $order ) || empty( $order ) ) {
1119 return 'DESC';
1120 }
1121
1122 if ( 'ASC' === strtoupper( $order ) ) {
1123 return 'ASC';
1124 } else {
1125 return 'DESC';
1126 }
1127 }
1128
1129 /**
1130 * Makes private properties readable for backward compatibility.
1131 *
1132 * @since 4.0.0
1133 * @since 6.4.0 Getting a dynamic property is deprecated.
1134 *
1135 * @param string $name Property to get.
1136 * @return mixed Property.
1137 */
1138 public function __get( $name ) {
1139 if ( in_array( $name, $this->compat_fields, true ) ) {
1140 return $this->$name;
1141 }
1142
1143 wp_trigger_error(
1144 __METHOD__,
1145 "The property `{$name}` is not declared. Getting a dynamic property is " .
1146 'deprecated since version 6.4.0! Instead, declare the property on the class.',
1147 E_USER_DEPRECATED
1148 );
1149 return null;
1150 }
1151
1152 /**
1153 * Makes private properties settable for backward compatibility.
1154 *
1155 * @since 4.0.0
1156 * @since 6.4.0 Setting a dynamic property is deprecated.
1157 *
1158 * @param string $name Property to check if set.
1159 * @param mixed $value Property value.
1160 */
1161 public function __set( $name, $value ) {
1162 if ( in_array( $name, $this->compat_fields, true ) ) {
1163 $this->$name = $value;
1164 return;
1165 }
1166
1167 wp_trigger_error(
1168 __METHOD__,
1169 "The property `{$name}` is not declared. Setting a dynamic property is " .
1170 'deprecated since version 6.4.0! Instead, declare the property on the class.',
1171 E_USER_DEPRECATED
1172 );
1173 }
1174
1175 /**
1176 * Makes private properties checkable for backward compatibility.
1177 *
1178 * @since 4.0.0
1179 * @since 6.4.0 Checking a dynamic property is deprecated.
1180 *
1181 * @param string $name Property to check if set.
1182 * @return bool Whether the property is set.
1183 */
1184 public function __isset( $name ) {
1185 if ( in_array( $name, $this->compat_fields, true ) ) {
1186 return isset( $this->$name );
1187 }
1188
1189 wp_trigger_error(
1190 __METHOD__,
1191 "The property `{$name}` is not declared. Checking `isset()` on a dynamic property " .
1192 'is deprecated since version 6.4.0! Instead, declare the property on the class.',
1193 E_USER_DEPRECATED
1194 );
1195 return false;
1196 }
1197
1198 /**
1199 * Makes private properties un-settable for backward compatibility.
1200 *
1201 * @since 4.0.0
1202 * @since 6.4.0 Unsetting a dynamic property is deprecated.
1203 *
1204 * @param string $name Property to unset.
1205 */
1206 public function __unset( $name ) {
1207 if ( in_array( $name, $this->compat_fields, true ) ) {
1208 unset( $this->$name );
1209 return;
1210 }
1211
1212 wp_trigger_error(
1213 __METHOD__,
1214 "A property `{$name}` is not declared. Unsetting a dynamic property is " .
1215 'deprecated since version 6.4.0! Instead, declare the property on the class.',
1216 E_USER_DEPRECATED
1217 );
1218 }
1219
1220 /**
1221 * Makes private/protected methods readable for backward compatibility.
1222 *
1223 * @since 4.0.0
1224 *
1225 * @param string $name Method to call.
1226 * @param array $arguments Arguments to pass when calling.
1227 * @return mixed Return value of the callback, false otherwise.
1228 */
1229 public function __call( $name, $arguments ) {
1230 if ( 'get_search_sql' === $name ) {
1231 return $this->get_search_sql( ...$arguments );
1232 }
1233 return false;
1234 }
1235}
1236