diff --git a/README.md b/README.md index 102ae6dff..b411a83ef 100644 --- a/README.md +++ b/README.md @@ -5342,7 +5342,7 @@ These fields are optionally available: Lists all sites in a multisite installation. ~~~ -wp site list [--network=] [--=] [--site__in=] [--site_user=] [--site-path=] [--blog_id=] [--site_id=] [--domain=] [--registered=] [--last_updated=] [--public=] [--archived=] [--mature=] [--spam=] [--deleted=] [--lang_id=] [--field=] [--fields=] [--format=] +wp site list [--network=] [--=] [--site__in=] [--site_user=] [--site-path=] [--blog_id=] [--site_id=] [--domain=] [--registered=] [--last_updated=] [--public=] [--archived=] [--mature=] [--spam=] [--deleted=] [--lang_id=] [--field=] [--fields=] [--format=] ~~~ **OPTIONS** @@ -5351,8 +5351,10 @@ wp site list [--network=] [--=] [--site__in=] [--site_u The network to which the sites belong. [--=] - Filter by one or more fields (see "Available Fields" section). However, - 'url' isn't an available filter, as it comes from 'home' in wp_options. + Filter by one or more fields (see "Available Fields" section), or pass any + other argument accepted by WP_Site_Query, such as 'search', 'site__not_in', + 'number', 'offset', 'orderby' or 'order'. However, 'url' isn't an available + filter, as it comes from 'home' in wp_options. Note: '--path' conflicts with the global parameter of the same name; use '--site-path' to filter by path instead. @@ -5375,11 +5377,13 @@ wp site list [--network=] [--=] [--site__in=] [--site_u [--domain=] Filter by domain. - [--registered=] - Filter by the date the site was registered. + [--registered=] + Filter by the date the site was registered. Accepts a timestamp or a + date; a value carrying no time of day matches that whole day. - [--last_updated=] - Filter by the date the site was last updated. + [--last_updated=] + Filter by the date the site was last updated. Accepts a timestamp or a + date; a value carrying no time of day matches that whole day. [--public=] Filter by whether the site is public. Accepts 1 or 0. @@ -5446,6 +5450,15 @@ These fields are optionally available: http://www.example.com/ http://www.example.com/subdir/ + # Output site URLs, most recently registered first + $ wp site list --orderby=registered --order=desc --field=url + http://www.example.com/subdir/ + http://www.example.com/ + + # Search for sites by domain or path + $ wp site list --search=subdir --field=url + http://www.example.com/subdir/ + ### wp site mature diff --git a/features/site.feature b/features/site.feature index 804e6ccbc..bebd0e0c2 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1041,3 +1041,178 @@ Feature: Manage sites in a multisite installation """ 3 """ + + Scenario: List sites using WP_Site_Query arguments + Given a WP multisite install + + When I run `wp site create --slug=alpha --porcelain` + Then STDOUT should be a number + And save STDOUT as {ALPHA_ID} + + When I run `wp site create --slug=beta --porcelain` + Then STDOUT should be a number + And save STDOUT as {BETA_ID} + + # --search, --site__not_in, --number, --offset and --orderby all come from + # WP_Site_Query and were silently ignored before. + When I run `wp site list --search=alpha --field=blog_id` + Then STDOUT should be: + """ + {ALPHA_ID} + """ + + When I run `wp site list --site__not_in={ALPHA_ID} --format=count` + Then STDOUT should be: + """ + 2 + """ + + When I run `wp site list --number=1 --format=count` + Then STDOUT should be: + """ + 1 + """ + + When I run `wp site list --number=1 --offset=1 --field=blog_id` + Then STDOUT should be: + """ + {ALPHA_ID} + """ + + When I run `wp site list --orderby=id --order=desc --field=blog_id` + Then STDOUT should be: + """ + {BETA_ID} + {ALPHA_ID} + 1 + """ + + Scenario: Existing site list filters keep working against WP_Site_Query + Given a WP multisite install + + When I run `wp site create --slug=alpha --porcelain` + Then STDOUT should be a number + And save STDOUT as {ALPHA_ID} + + When I run `wp site create --slug=beta --porcelain` + Then STDOUT should be a number + And save STDOUT as {BETA_ID} + + # --site__in returns rows in the order the IDs were given. + When I run `wp site list --site__in={BETA_ID},{ALPHA_ID} --field=blog_id` + Then STDOUT should be: + """ + {BETA_ID} + {ALPHA_ID} + """ + + When I run `wp site list --blog_id={ALPHA_ID} --field=blog_id` + Then STDOUT should be: + """ + {ALPHA_ID} + """ + + When I run `wp site list --site_id=1 --format=count` + Then STDOUT should be: + """ + 3 + """ + + When I run `wp site list --site_id=2 --format=count` + Then STDOUT should be: + """ + 0 + """ + + When I run `wp site list --site-path=/alpha/ --field=blog_id` + Then STDOUT should be: + """ + {ALPHA_ID} + """ + + # admin belongs to every site, bobby only to the one they were created on, so the + # two counts differ and neither can pass by accident. + When I run `wp site list --site_user=admin --format=count` + Then STDOUT should be: + """ + 3 + """ + + When I run `wp user create bobby bobby@example.com --role=author --porcelain` + Then STDOUT should be a number + + When I run `wp site list --site_user=bobby --field=blog_id` + Then STDOUT should be: + """ + 1 + """ + + # --site__in and --site_user narrow each other rather than one replacing the + # other, so the pair keeps only the sites satisfying both. + When I run `wp site list --site__in={ALPHA_ID} --site_user=bobby --format=count` + Then STDOUT should be: + """ + 0 + """ + + When I run `wp site list --site__in=1,{ALPHA_ID} --site_user=bobby --field=blog_id` + Then STDOUT should be: + """ + 1 + """ + + Scenario: Filter the site list by registration or update date + Given a WP multisite install + + When I run `wp site create --slug=alpha --porcelain` + Then STDOUT should be a number + And save STDOUT as {ALPHA_ID} + + When I run `wp site list --blog_id={ALPHA_ID} --field=registered` + Then STDOUT should not be empty + And save STDOUT as {REGISTERED} + And save STDOUT '(\d{4}-\d{2}-\d{2})' as {REGISTERED_DAY} + + When I run `wp site list --registered='{REGISTERED}' --field=blog_id` + Then STDOUT should contain: + """ + {ALPHA_ID} + """ + + # A value carrying no time of day matches every site registered that day, and + # only those - a day nothing was registered on comes back empty. + When I run `wp site list --registered={REGISTERED_DAY} --field=blog_id` + Then STDOUT should contain: + """ + {ALPHA_ID} + """ + + When I run `wp site list --registered=1999-01-01 --format=count` + Then STDOUT should be: + """ + 0 + """ + + When I run `wp site list --registered='1999-01-01 00:00:00' --format=count` + Then STDOUT should be: + """ + 0 + """ + + When I run `wp site list --blog_id={ALPHA_ID} --field=last_updated` + Then STDOUT should not be empty + And save STDOUT as {LAST_UPDATED} + + When I run `wp site list --last_updated='{LAST_UPDATED}' --field=blog_id` + Then STDOUT should contain: + """ + {ALPHA_ID} + """ + + # Interpreting the value is left to WP_Date_Query, which resolves anything it + # cannot parse to a date no site can have registered on. + When I run `wp site list --registered=notadate --format=count` + Then STDOUT should be: + """ + 0 + """ diff --git a/src/Site_Command.php b/src/Site_Command.php index a109b76e6..2c9834bb9 100644 --- a/src/Site_Command.php +++ b/src/Site_Command.php @@ -3,7 +3,6 @@ use WP_CLI\CommandWithDBObject; use WP_CLI\ExitException; use WP_CLI\Fetchers\Site as SiteFetcher; -use WP_CLI\Iterators\Table as TableIterator; use WP_CLI\Utils; use WP_CLI\Formatter; use WP_CLI\Fetchers\User as UserFetcher; @@ -987,8 +986,10 @@ private function get_network( $network_id ) { * : The network to which the sites belong. * * [--=] - * : Filter by one or more fields (see "Available Fields" section). However, - * 'url' isn't an available filter, as it comes from 'home' in wp_options. + * : Filter by one or more fields (see "Available Fields" section), or pass any + * other argument accepted by WP_Site_Query, such as 'search', 'site__not_in', + * 'number', 'offset', 'orderby' or 'order'. However, 'url' isn't an available + * filter, as it comes from 'home' in wp_options. * Note: '--path' conflicts with the global parameter of the same name; use * '--site-path' to filter by path instead. * @@ -1011,11 +1012,13 @@ private function get_network( $network_id ) { * [--domain=] * : Filter by domain. * - * [--registered=] - * : Filter by the date the site was registered. + * [--registered=] + * : Filter by the date the site was registered. Accepts a timestamp or a + * date; a value carrying no time of day matches that whole day. * - * [--last_updated=] - * : Filter by the date the site was last updated. + * [--last_updated=] + * : Filter by the date the site was last updated. Accepts a timestamp or a + * date; a value carrying no time of day matches that whole day. * * [--public=] * : Filter by whether the site is public. Accepts 1 or 0. @@ -1082,6 +1085,15 @@ private function get_network( $network_id ) { * http://www.example.com/ * http://www.example.com/subdir/ * + * # Output site URLs, most recently registered first + * $ wp site list --orderby=registered --order=desc --field=url + * http://www.example.com/subdir/ + * http://www.example.com/ + * + * # Search for sites by domain or path + * $ wp site list --search=subdir --field=url + * http://www.example.com/subdir/ + * * @subcommand list */ public function list_( $args, $assoc_args ) { @@ -1089,8 +1101,6 @@ public function list_( $args, $assoc_args ) { WP_CLI::error( 'This is not a multisite installation.' ); } - global $wpdb; - if ( isset( $assoc_args['fields'] ) ) { $assoc_args['fields'] = preg_split( '/,[ \t]*/', $assoc_args['fields'] ); } @@ -1101,31 +1111,65 @@ public function list_( $args, $assoc_args ) { ]; $assoc_args = array_merge( $defaults, $assoc_args ); - $where = []; - $append = ''; + // Anything the command does not consume itself is handed to WP_Site_Query, + // which is what makes its own arguments - search, site__not_in, date_query, + // lang__in and the rest - usable here. + // + // 'count' is withheld deliberately: it makes get_sites() return an integer + // rather than a list, and '--format=count' is how this command spells it. + $query_args = array_diff_key( + $assoc_args, + array_flip( + [ 'format', 'fields', 'field', 'count', 'blog_id', 'site_id', 'site_user', 'site-path', 'network', 'registered', 'last_updated' ] + ) + ); - $site_cols = [ 'blog_id', 'last_updated', 'registered', 'site_id', 'domain', 'path', 'public', 'archived', 'mature', 'spam', 'deleted', 'lang_id' ]; - foreach ( $site_cols as $col ) { - if ( isset( $assoc_args[ $col ] ) ) { - $where[ $col ] = $assoc_args[ $col ]; - } + // Arguments this command spells differently to WP_Site_Query. + if ( isset( $assoc_args['blog_id'] ) ) { + $query_args['site__in'] = [ $assoc_args['blog_id'] ]; } - if ( isset( $assoc_args['site-path'] ) ) { - $where['path'] = $assoc_args['site-path']; + if ( isset( $assoc_args['site__in'] ) ) { + $query_args['site__in'] = array_map( 'trim', explode( ',', $assoc_args['site__in'] ) ); } - if ( isset( $assoc_args['site__in'] ) ) { - $where['blog_id'] = explode( ',', $assoc_args['site__in'] ); - $append = 'ORDER BY FIELD( blog_id, ' . implode( ',', array_map( 'intval', $where['blog_id'] ) ) . ' )'; + if ( isset( $assoc_args['site_id'] ) ) { + $query_args['network_id'] = $assoc_args['site_id']; } + // '--network' has always taken precedence over '--site_id'. if ( isset( $assoc_args['network'] ) ) { - $where['site_id'] = $assoc_args['network']; + $query_args['network_id'] = $assoc_args['network']; + } + + if ( isset( $assoc_args['site-path'] ) ) { + $query_args['path'] = $assoc_args['site-path']; + } + + // WP_Site_Query only reaches the date columns through a date query. Bounding + // the range by the given value on both ends matches it as precisely as it was + // written: a full timestamp matches that second, a date matches that day. + // Interpreting the value is WP_Date_Query's job, so it is passed on as given. + $date_query = []; + + foreach ( [ 'registered', 'last_updated' ] as $column ) { + if ( isset( $assoc_args[ $column ] ) ) { + $date_query[] = [ + 'column' => $column, + 'after' => $assoc_args[ $column ], + 'before' => $assoc_args[ $column ], + 'inclusive' => true, + ]; + } + } + + if ( ! empty( $date_query ) ) { + $query_args['date_query'] = $date_query; } if ( isset( $assoc_args['site_user'] ) ) { - $user = ( new UserFetcher() )->get_check( $assoc_args['site_user'] ); + $user = ( new UserFetcher() )->get_check( $assoc_args['site_user'] ); + $user_ids = []; if ( $user ) { /** @@ -1134,35 +1178,35 @@ public function list_( $args, $assoc_args ) { $blogs = get_blogs_of_user( $user->ID ); foreach ( $blogs as $blog ) { - $where['blog_id'][] = $blog->userblog_id; + $user_ids[] = $blog->userblog_id; } } - if ( ! isset( $where['blog_id'] ) || empty( $where['blog_id'] ) ) { + if ( isset( $query_args['site__in'] ) ) { + $user_ids = array_intersect( array_map( 'intval', $query_args['site__in'] ), $user_ids ); + } + + if ( empty( $user_ids ) ) { $formatter = new Formatter( $assoc_args, [], 'site' ); $formatter->display_items( [] ); return; } - $append = 'ORDER BY FIELD( blog_id, ' . implode( ',', array_map( 'intval', $where['blog_id'] ) ) . ' )'; + $query_args['site__in'] = array_values( $user_ids ); } - $iterator_args = [ - 'table' => $wpdb->blogs, - 'where' => $where, - 'append' => $append, - ]; - - $iterator = new TableIterator( $iterator_args ); + // Listing by explicit IDs has always come back in the order they were given. + if ( isset( $query_args['site__in'] ) && ! isset( $assoc_args['orderby'] ) ) { + $query_args['orderby'] = 'site__in'; + } - /** - * @var iterable $iterator - */ $iterator = Utils\iterator_map( - $iterator, - function ( $blog ) { - $blog->url = trailingslashit( get_home_url( $blog->blog_id ) ); - return $blog; + self::get_sites_iterator( $query_args ), + function ( $site ) { + $site_data = $site->to_array(); + $site_data['url'] = trailingslashit( get_home_url( $site->blog_id ) ); + + return (object) $site_data; } ); @@ -1177,6 +1221,55 @@ function ( $blog ) { } } + /** + * Yields sites a page at a time. + * + * The previous implementation read $wpdb->blogs through a chunked iterator, so + * listing a large network never held every row in memory at once. Page through + * WP_Site_Query the same way, unless an explicit --number was given, in which + * case that is the caller's own limit and is passed straight through. + * + * @param array $query_args Arguments for WP_Site_Query. + * @return \Generator + */ + private static function get_sites_iterator( $query_args ) { + if ( isset( $query_args['number'] ) ) { + // The arguments are whatever the user passed, so they cannot be narrowed + // to the shape get_sites() documents. WP_Site_Query validates them itself. + // @phpstan-ignore argument.type + foreach ( get_sites( $query_args ) as $site ) { + yield $site; + } + + return; + } + + $chunk_size = 500; + $offset = isset( $query_args['offset'] ) && is_numeric( $query_args['offset'] ) + ? (int) $query_args['offset'] + : 0; + + do { + $page_args = array_merge( + $query_args, + [ + 'number' => $chunk_size, + 'offset' => $offset, + ] + ); + + // @phpstan-ignore argument.type + $sites = get_sites( $page_args ); + + foreach ( $sites as $site ) { + yield $site; + } + + $fetched = count( $sites ); + $offset += $chunk_size; + } while ( $fetched === $chunk_size ); + } + /** * Archives one or more sites. *