diff --git a/modules/object-cache/user-query/class-wp-user-query-cache.php b/modules/object-cache/user-query/class-wp-user-query-cache.php new file mode 100644 index 0000000000..ce0582c66c --- /dev/null +++ b/modules/object-cache/user-query/class-wp-user-query-cache.php @@ -0,0 +1,398 @@ +get_user_site_ids( $user_id ); + array_map( array( $this, 'clear_site' ), $site_ids ); + $this->update_last_change( 'last_changed' ); + } + + /** + * Clear site level cache salt. + * + * @param int|WP_Site $site Site to clear, with object or id. + */ + public function clear_site( $site ) { + if ( $site instanceof WP_Site ) { + $site_id = $site->id; + } elseif ( is_numeric( $site ) ) { + $site_id = $site; + } else { + return; + } + + $cache_key = $this->site_cache_key( $site_id ); + $this->update_last_change( $cache_key ); + } + + /** + * Helper to get last updated value. + * + * @param string $cache_key Cache key. + * + * @return string $result of wp_cache_set. + */ + private function update_last_change( $cache_key = 'last_changed' ) { + return wp_cache_set( $cache_key, microtime(), 'users' ); + } + + /** + * Helper method to generate site cache key. + * + * @param int $site_id Blog id for cache key generation. + * + * @return string + */ + private function site_cache_key( $site_id ) { + return 'site-' . $site_id . '-last_changed'; + } + + /** + * When a user is added to a site, clear site and user level changes. + * + * @param int $user_id User ID. + * @param string $role Current user role. + * @param int $blog_id Blog ID. + */ + public function add_user_to_blog( $user_id, $role, $blog_id ) { + $this->clear_user( $user_id ); + $this->clear_site( $blog_id ); + } + + /** + * When a user is removed to a site, clear site and user level changes. + * + * @param int $user_id User ID. + * @param int $blog_id Blog ID. + */ + public function remove_user_from_blog( $user_id, $blog_id ) { + $this->clear_user( $user_id ); + $this->clear_site( $blog_id ); + } + + /** + * Clear cache after password is changed + * + * @param WP_User $user Current user of cleared password. + */ + public function after_password_reset( $user ) { + $this->clear_user( $user->ID ); + } + + /** + * Clear cache after password is changed + * + * @param String $user_login Username string. + */ + public function retrieve_password_key( $user_login ) { + $user = get_user_by( 'login', $user_login ); + $this->clear_user( $user->ID ); + } + + /** + * On update / delete user meta, clear user cache + * + * @param int $unused Meta id, unused. + * @param int $user_id User ID, used to clear caches. + */ + public function updated_user_meta( $unused, $user_id ) { + $this->clear_user( $user_id ); + } + + /** + * Hook into pre user results, to high jack results. + * + * @param null $results Pre Value. + * @param WP_User_Query $wp_user_query WP User query object. + * + * @return array + */ + public function users_pre_query( $results, $wp_user_query ) { + global $wpdb; + + $query_vars =& $wp_user_query->query_vars; + + $request = "SELECT $wp_user_query->query_fields $wp_user_query->query_from $wp_user_query->query_where $wp_user_query->query_orderby $wp_user_query->query_limit"; + + $request = $this->users_request( $request, $wp_user_query ); + + if ( ! $request ) { + $results = (array) $this->cache['users']; + $wp_user_query->total_users = (int) $this->cache['total_users']; + } else { + if ( is_array( $query_vars['fields'] ) || 'all' === $query_vars['fields'] ) { + $results = $wpdb->get_results( $request ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } else { + $results = $wpdb->get_col( $request ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + } + + if ( isset( $query_vars['count_total'] ) && $query_vars['count_total'] ) { + $wp_user_query->total_users = $wpdb->get_var( 'SELECT FOUND_ROWS()' ); + } else { + $wp_user_query->total_users = 0; + } + + $data = array( + 'users' => (array) $results, + 'total_users' => (int) $wp_user_query->total_users, + ); + wp_cache_set( $this->cache_key, $data, 'users' ); + } + $this->cache = false; + $this->cache_key = false; + + return $results; + } + + /** + * If cached, the do not run count query + * + * @param string $query String of SQL query. + * @param WP_User_Query $wp_user_query WP User query object. + * + * @return string + */ + public function found_users_query( $query, $wp_user_query ) { + if ( false !== $this->cache ) { + $query = ''; + } + + return $query; + } + + /** + * If cached, then dont run query. + * + * @param string $query String of SQL query. + * @param WP_User_Query $wp_user_query WP User query object. + * + * @return string + */ + public function users_request( $query, $wp_user_query ) { + global $wpdb; + + $sql = $wpdb->remove_placeholder_escape( $query ); + $cache_key = md5( $sql ); + $cache_salt = $this->get_cache_salt( $wp_user_query ); + $this->cache_key = $cache_key . $cache_salt; + $this->cache = wp_cache_get( $this->cache_key, 'users' ); + if ( false !== $this->cache ) { + $query = ''; + // This is a hack to stop a count notice error. + $wpdb->last_result = array(); + } + + return $query; + } + + /** + * Hook into user count and force the lookup through wp_user_query to which is cached. + * + * @param null $output Unused variable. + * @param string $strategy Unused variable. + * @param null $site_id Site ID to get list of count of users. + * + * @return array Array of user counts. + */ + public function pre_count_users( $output = null, $strategy = 'time', $site_id = null ) { + $cache_key_site = $this->site_cache_key( $site_id ); + $salt = wp_cache_get( $cache_key_site, 'users' ); + if ( ! $salt ) { + $salt = microtime(); + wp_cache_set( $cache_key_site, microtime(), 'users' ); + } + $cache_key = 'count_users_' . $site_id . '_' . $salt; + $cache = wp_cache_get( $cache_key, 'users' ); + if ( ! $cache ) { + remove_filter( 'pre_count_users', array( $this, 'pre_count_users' ), 8, 3 ); + $output = count_users( $strategy, $site_id ); + wp_cache_set( $cache_key, $output, 'users' ); + add_filter( 'pre_count_users', array( $this, 'pre_count_users' ), 8, 3 ); + } else { + $output = $cache; + } + + return $output; + } + + /** + * Helper function to get count of users by sites and role + * + * @param int $site_id (Default null). + * @param string $role (Default empty string). + * + * @return int + */ + protected function get_user_count( $site_id = null, $role = '' ) { + $args = array( + 'count_total' => true, + 'number' => 1, + 'fields' => 'ids', + 'blog_id' => $site_id, + 'role' => $role, + ); + $user_search = new WP_User_Query( $args ); + + return $user_search->total_users; + } + + /** + * Get list of site ids by user id. + * + * @param int $user_id User ID. + * + * @return array + */ + public function get_user_site_ids( $user_id ) { + global $wpdb; + + $site_ids = array(); + $user_id = (int) $user_id; + if ( empty( $user_id ) || ! is_user_logged_in() ) { + return $site_ids; + } + + // Logged out users can't have sites. + $keys = get_user_meta( $user_id ); + + if ( empty( $keys ) ) { + return $site_ids; + } + if ( ! is_multisite() ) { + $site_ids[] = get_current_blog_id(); + + return $site_ids; + } + + if ( isset( $keys[ $wpdb->base_prefix . 'capabilities' ] ) && defined( 'MULTISITE' ) ) { + $site_ids[] = 1; + unset( $keys[ $wpdb->base_prefix . 'capabilities' ] ); + } + + $keys = array_keys( $keys ); + + /** + * We could optimize this loop/logic. + */ + foreach ( $keys as $key ) { + if ( 'capabilities' !== substr( $key, - 12 ) ) { + continue; + } + if ( $wpdb->base_prefix && 0 !== strpos( $key, $wpdb->base_prefix ) ) { + continue; + } + $site_id = str_replace( array( $wpdb->base_prefix, '_capabilities' ), '', $key ); + if ( ! is_numeric( $site_id ) ) { + continue; + } + + $site_ids[] = (int) $site_id; + } + + return $site_ids; + } + + /** + * Generate different salts. + * If a global user query, use global salt + * If a site level query, use site level cache. Also change salt if post is modified + * + * @param WP_User_Query $wp_user_query User query object. + * + * @return bool|mixed|string + */ + private function get_cache_salt( $wp_user_query ) { + $query_vars = $wp_user_query->query_vars; + $group = 'users'; + + if ( isset( $query_vars['blog_id'] ) && $query_vars['blog_id'] ) { + $cache_key_site = $this->site_cache_key( $query_vars['blog_id'] ); + $salt = wp_cache_get( $cache_key_site, $group ); + if ( ! $salt ) { + $salt = microtime(); + wp_cache_set( $cache_key_site, microtime(), $group ); + } + if ( isset( $query_vars['has_published_posts'] ) && $query_vars['has_published_posts'] ) { + switch_to_blog( $query_vars['blog_id'] ); + $salt .= wp_cache_get_last_changed( 'posts' ); + restore_current_blog(); + } + } else { + $salt = wp_cache_get_last_changed( $group ); + } + + return $salt; + } +} diff --git a/modules/object-cache/user-query/load.php b/modules/object-cache/user-query/load.php new file mode 100644 index 0000000000..fbd54597ca --- /dev/null +++ b/modules/object-cache/user-query/load.php @@ -0,0 +1,16 @@ +wp_user_query_cache = new WP_User_Query_Cache(); + + } + + public function test_construct() { + + $this->assertInstanceOf( 'WP_User_Query_Cache', $this->wp_user_query_cache ); + + // Single site filters. + $this->assertTrue( add_action( 'user_register', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + $this->assertTrue( add_action( 'profile_update', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + $this->assertTrue( add_action( 'register_new_user', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + $this->assertTrue( add_action( 'delete_user', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + $this->assertTrue( add_action( 'edit_user_created_user', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + + // Most important filter. + $this->assertTrue( add_action( 'clean_user_cache', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + + // Multisite User filters. + $this->assertTrue( add_action( 'wpmu_delete_user', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + $this->assertTrue( add_action( 'make_spam_user', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + $this->assertTrue( add_action( 'add_user_to_blog', array( $this->wp_user_query_cache, 'add_user_to_blog' ), 8, 3 ) ); + $this->assertTrue( add_action( 'remove_user_from_blog', array( $this->wp_user_query_cache, 'remove_user_from_blog' ), 8, 2 ) ); + + // Multisite Site filters. + $this->assertTrue( add_action( 'wp_insert_site', array( $this->wp_user_query_cache, 'clear_site' ), 8, 1 ) ); + $this->assertTrue( add_action( 'wp_delete_site', array( $this->wp_user_query_cache, 'clear_site' ), 8, 1 ) ); + + // Different params. + $this->assertTrue( add_action( 'after_password_reset', array( $this->wp_user_query_cache, 'after_password_reset' ), 8, 1 ) ); + $this->assertTrue( add_action( 'retrieve_password_key', array( $this->wp_user_query_cache, 'retrieve_password_key' ), 8, 1 ) ); + + // Meta api. + $this->assertTrue( add_action( 'add_user_meta', array( $this->wp_user_query_cache, 'clear_user' ), 8, 1 ) ); + $this->assertTrue( add_action( 'updated_user_meta', array( $this->wp_user_query_cache, 'updated_user_meta' ), 8, 2 ) ); + $this->assertTrue( add_action( 'deleted_user_meta', array( $this->wp_user_query_cache, 'updated_user_meta' ), 8, 2 ) ); + + // User query filters.. + $this->assertTrue( add_filter( 'users_pre_query', array( $this->wp_user_query_cache, 'users_pre_query' ), 8, 2 ) ); + $this->assertTrue( add_filter( 'found_users_query', array( $this->wp_user_query_cache, 'found_users_query' ), 8, 2 ) ); + + // User query count. + $this->assertTrue( add_filter( 'pre_count_users', array( $this->wp_user_query_cache, 'pre_count_users' ), 8, 3 ) ); + + } + + /** + * A method to call private methods. + * + * @param [type] $obj A class object. + * @param string $name A method name. + * @param array $args An array of arguments. + * + * @return mixed + */ + public static function call_method( $obj, $name, array $args ) { + $class = new \ReflectionClass( $obj ); + $method = $class->getMethod( $name ); + $method->setAccessible( true ); + return $method->invokeArgs( $obj, $args ); + } + + /** + * @covers site_cache_key + * @ticket 40613 + */ + public function test_site_cache_key() { + $this->assertEquals( + 'site-' . self::SITE_ID . '-last_changed', + self::call_method( + $this->wp_user_query_cache, + 'site_cache_key', + array( self::SITE_ID ) + ) + ); + } + + /** + * For invalid user_id. + * + * @covers get_user_site_ids + * @ticket 40613 + */ + public function test_get_user_site_ids_non_numeric_user() { + // when the user_id is not a number. + $this->assertEquals( + array(), + $this->wp_user_query_cache->get_user_site_ids( 'wrong_user_id_data_type' ) + ); + } + + /** + * For non-logged-in user. + * + * @covers get_user_site_ids + * @ticket 40613 + */ + public function test_get_user_site_ids_no_login() { + $this->assertEquals( + array(), + $this->wp_user_query_cache->get_user_site_ids( 1 ) + ); + } + + /** + * For single site. + * + * @covers get_user_site_ids + * @ticket 40613 + * @group ms-excluded + */ + public function test_get_user_site_ids_single_site() { + $user_id = $this->factory->user->create(); + wp_set_current_user( $user_id ); + + $this->assertEquals( + array( 1 ), + $this->wp_user_query_cache->get_user_site_ids( $user_id ) + ); + } + + /** + * For multisite. + * + * @covers get_user_site_ids + * @ticket 40613 + * @group ms-required + */ + public function test_get_user_site_ids_multisite() { + $user_id = $this->factory->user->create(); + wp_set_current_user( $user_id ); + + // Create sample subsites. + $subsite_id_2 = $this->factory->blog->create( array( 'user_id' => $user_id ) ); + $subsite_id_3 = $this->factory->blog->create( array( 'user_id' => $user_id ) ); + + $this->assertEquals( + array( get_current_blog_id(), $subsite_id_2, $subsite_id_3 ), + $this->wp_user_query_cache->get_user_site_ids( $user_id ) + ); + } + + /** + * @covers update_last_change + * @ticket 40613 + */ + public function test_update_last_change() { + $this->assertTrue( self::call_method( $this->wp_user_query_cache, 'update_last_change', array( 'random_cache_key' ) ) ); + } + + /** + * @covers clear_user + * @ticket 40613 + */ + public function test_clear_user() { + $user_id = $this->factory->user->create(); + wp_set_current_user( $user_id ); + + $site_ids = $this->wp_user_query_cache->get_user_site_ids( $user_id ); + $this->assertTrue( self::call_method( $this->wp_user_query_cache, 'update_last_change', array( 'last_changed' ) ) ); + } + + /** + * @covers clear_site + * @ticket 40613 + * @group ms-required + */ + public function test_clear_site_multisite() { + $wp_site = get_blog_details(); + $cache_key = self::call_method( $this->wp_user_query_cache, 'site_cache_key', array( $wp_site->id ) ); + + $this->assertEquals( + 'site-' . $wp_site->id . '-last_changed', + $cache_key + ); + + $this->assertTrue( self::call_method( $this->wp_user_query_cache, 'update_last_change', array( $cache_key ) ) ); + $this->assertNull( $this->wp_user_query_cache->clear_site( $wp_site ) ); + } + + /** + * @covers clear_site + * @ticket 40613 + * @group ms-excluded + */ + public function test_clear_site_single_site() { + + $cache_key = self::call_method( $this->wp_user_query_cache, 'site_cache_key', array( self::SITE_ID ) ); + + $this->assertEquals( + 'site-' . self::SITE_ID . '-last_changed', + $cache_key + ); + + $this->assertTrue( self::call_method( $this->wp_user_query_cache, 'update_last_change', array( $cache_key ) ) ); + $this->assertNull( $this->wp_user_query_cache->clear_site( self::SITE_ID ) ); + } + + /** + * @covers clear_user + * @covers after_password_reset + * @covers retrieve_password_key + * @covers updated_user_meta + * + * @ticket 40613 + * + * @group ms-excluded + */ + public function test_clear_user_hooks_single_site() { + $user_id = $this->factory->user->create(); + wp_set_current_user( $user_id ); + $user = wp_get_current_user(); + + $actions = array( + 'user_register' => array( + $user_id, + ), + 'profile_update' => array( + $user_id, + $user, + ), + 'register_new_user' => array( + $user_id, + ), + 'edit_user_created_user' => array( + $user_id, + ), + 'clean_user_cache' => array( + $user_id, + ), + 'after_password_reset' => array( + $user, + 'new_password', + ), + ); + + $this->execute_hooks( get_current_blog_id(), $actions ); + + /* + * + * Password reset. + * + */ + $pwd_reset_key = get_password_reset_key( $user ); + + $actions = array( + 'retrieve_password_key' => array( + $user->data->user_login, + $pwd_reset_key, + ), + ); + + $this->execute_hooks( get_current_blog_id(), $actions ); + + /* + * + * Update user meta. + * + */ + $meta_id = update_user_meta( $user_id, 'test_meta_key', 'test_meta_value' ); + + $actions = array( + 'add_user_meta' => array( + $user_id, + ), + 'updated_user_meta' => array( + $meta_id, + $user_id, + ), + 'deleted_user_meta' => array( + $meta_id, + $user_id, + ), + ); + + $this->execute_hooks( get_current_blog_id(), $actions ); + + /* + * + * User deleted. + * + */ + $actions = array( + 'delete_user' => array( + $user_id, + ), + ); + + $this->execute_hooks( get_current_blog_id(), $actions ); + } + + /** + * @covers clear_user + * @covers add_user_to_blog + * @covers remove_user_from_blog + * @group ms-required + * + * @ticket 40613 + */ + public function test_clear_user_hooks_multi_site() { + $user_id = $this->factory->user->create(); + wp_set_current_user( $user_id ); + $user = wp_get_current_user(); + + $actions = array( + 'make_spam_user' => array( + $user_id, + ), + 'wpmu_delete_user' => array( + $user_id, + $user, + ), + ); + + $this->execute_hooks( get_current_blog_id(), $actions ); + + // The following actions are needs to be run after adding a new sub-site. + $subsite_id_2 = $this->factory->blog->create( array( 'user_id' => $user_id ) ); + + $actions = array( + 'wp_insert_site' => array( + $subsite_id_2, + ), + 'add_user_to_blog' => array( + $user_id, + 'subscriber', + $subsite_id_2, + ), + 'remove_user_from_blog' => array( + $user_id, + $subsite_id_2, + ), + 'wp_delete_site' => array( + $subsite_id_2, + ), + ); + $this->execute_hooks( $subsite_id_2, $actions ); + } + + /** + * @coversNothing + * + * A common code for testing clear_user for single and multi-sites. + * + * @param int $site_id A blog ID. + * @param array $actions An array of actions to test. + * + * @return void + */ + public function execute_hooks( $site_id, $actions ) { + $cache_key = self::call_method( $this->wp_user_query_cache, 'site_cache_key', array( $site_id ) ); + + foreach ( $actions as $action => $args ) { + $prev_cache = wp_cache_get( $cache_key, 'users' ); + do_action( $action, ...$args ); + $this->assertFalse( wp_cache_get( $cache_key, 'users' ) === $prev_cache ); // we want to assert that the cache has changed. + } + } + + /** + * @covers users_pre_query + * @ticket 40613 + */ + public function test_users_pre_query() { + + global $wpdb; + + // user query args. + $args = array( + 'fields' => 'ID', + 'number' => 1, + ); + + $result = new WP_User_Query( $args ); + + $num_queries = $wpdb->num_queries; + $q_result = $result->results; + + // Re-execute the same query. + $result = new WP_User_Query( $args ); + + // Make sure no queries were executed. + $this->assertSame( $num_queries, $wpdb->num_queries ); + + // Make sure we get the same result for the same query. + $this->assertSame( $q_result, $result->results ); + } + + /** + * @covers pre_count_users + * @ticket 40613 + */ + public function test_pre_count_users() { + global $wpdb; + + count_users( 'time', self::SITE_ID ); + $num_queries = $wpdb->num_queries; + count_users( 'time', self::SITE_ID ); + + // Make sure no queries were executed. + $this->assertSame( $num_queries, $wpdb->num_queries ); + } +}