From e810bc2452c5d497f38fe98056b8f795d51b1f96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:38:22 +0000 Subject: [PATCH 1/6] Query sites through WP_Site_Query in `wp site list` The command read $wpdb->blogs directly through a chunked table iterator, with a WHERE clause assembled from a hardcoded list of columns. Everything WP_Site_Query offers beyond those columns - search, site__not_in, network__in, lang__in, domain__in, date_query, orderby, number, offset, meta queries - was silently ignored, and so were the pre_get_sites filters that plugins use to influence which sites are visible. Results were also uncached. Build a WP_Site_Query argument set instead, and hand it everything the command does not consume itself, so those arguments now work. Existing behaviour is kept: - --blog_id, --site__in, --site_id, --network, --site-path and --site_user are this command's own spellings and are mapped onto their WP_Site_Query equivalents. --network still wins over --site_id, and listing by explicit IDs still returns them in the order given. - Listing pages through the query 500 rows at a time, because WP_Site_Query::$number defaults to 100 and a plain get_sites() call would silently truncate a network to its first hundred sites. Paging also keeps the memory profile of the iterator this replaces. An explicit --number is the caller's own limit and is passed straight through. - --registered and --last_updated match the stored value exactly, which WP_Site_Query only expresses through date_query. The SQL WP_Date_Query emits for an exact timestamp is not translated by the SQLite integration - the scenario passes on MySQL and fails on SQLite - so those two are matched while iterating, leaving both backends identical. --date_query is available for the range queries it is actually meant for. - 'count' is withheld from the query arguments, since it makes get_sites() return an integer and --format=count is how this command spells that. Rows are still plain objects carrying the blogs columns plus url, so --fields, --field and the formatters are unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL --- features/site.feature | 132 +++++++++++++++++++++++++++++++++++ src/Site_Command.php | 155 +++++++++++++++++++++++++++++++++--------- 2 files changed, 254 insertions(+), 33 deletions(-) diff --git a/features/site.feature b/features/site.feature index c944642a6..9676e841c 100644 --- a/features/site.feature +++ b/features/site.feature @@ -936,3 +936,135 @@ Feature: Manage sites in a multisite installation """ 1 """ + + 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 + """ + + # registered matches exactly, which WP_Site_Query only supports via date_query. + When I run `wp site list --blog_id={ALPHA_ID} --field=registered` + Then STDOUT should not be empty + And save STDOUT as {REGISTERED} + + When I run `wp site list --registered='{REGISTERED}' --field=blog_id` + Then STDOUT should contain: + """ + {ALPHA_ID} + """ + + 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} + """ diff --git a/src/Site_Command.php b/src/Site_Command.php index f7735435a..4c7cd9d93 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; @@ -1055,8 +1054,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'] ); } @@ -1067,31 +1064,57 @@ 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']; + } + + // 'registered' and 'last_updated' match the stored value exactly. WP_Site_Query + // only filters on those two through a date query, and the SQL that + // WP_Date_Query emits for an exact timestamp is not translated by the SQLite + // integration, so matching them here keeps both backends behaving the same. + // '--date_query' remains available for the range queries it is meant for. + $exact_dates = []; + + foreach ( [ 'registered', 'last_updated' ] as $column ) { + if ( isset( $assoc_args[ $column ] ) ) { + $exact_dates[ $column ] = (string) $assoc_args[ $column ]; + } } 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 ) { /** @@ -1100,35 +1123,52 @@ 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, - ]; + // 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'; + } - $iterator = new TableIterator( $iterator_args ); + $sites = self::get_sites_iterator( $query_args ); + + if ( ! empty( $exact_dates ) ) { + $sites = new CallbackFilterIterator( + $sites, + static function ( $site ) use ( $exact_dates ) { + foreach ( $exact_dates as $column => $value ) { + if ( (string) $site->$column !== $value ) { + return false; + } + } + + return true; + } + ); + } - /** - * @var iterable $iterator - */ $iterator = Utils\iterator_map( - $iterator, - function ( $blog ) { - $blog->url = trailingslashit( get_home_url( $blog->blog_id ) ); - return $blog; + $sites, + function ( $site ) { + $site_data = $site->to_array(); + $site_data['url'] = trailingslashit( get_home_url( $site->blog_id ) ); + + return (object) $site_data; } ); @@ -1143,6 +1183,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. * From ef5bf2ac4c233b8f7453c7dce21562ef278b0bb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:02:22 +0000 Subject: [PATCH 2/6] Use WP_Date_Query for exact registration and update dates Filter --registered and --last_updated through WP_Site_Query's date_query rather than matching them while iterating, so the whole argument set is resolved in one query. The SQL that WP_Date_Query emits for an exact timestamp is not translated by the SQLite integration, so the scenario covering those two is tagged @skip-sqlite until that is fixed upstream. @skip-sqlite rather than @require-mysql, since the behaviour is fine on MariaDB too and @require-mysql would exclude it there. Parsing and formatting both as UTC round-trips the given value unchanged instead of shifting it by the server's timezone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL --- features/site.feature | 20 ++++++++++++++++- src/Site_Command.php | 50 ++++++++++++++++++++----------------------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/features/site.feature b/features/site.feature index 9676e841c..b28a7c4cf 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1042,7 +1042,18 @@ Feature: Manage sites in a multisite installation 1 """ - # registered matches exactly, which WP_Site_Query only supports via date_query. + + # --registered and --last_updated go through WP_Date_Query, whose exact-timestamp + # SQL the SQLite integration does not translate. Reported upstream; skipped on + # SQLite until that is resolved. + @skip-sqlite + Scenario: Filter the site list by an exact 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} @@ -1068,3 +1079,10 @@ Feature: Manage sites in a multisite installation """ {ALPHA_ID} """ + + When I try `wp site list --registered=notadate` + Then STDERR should contain: + """ + Invalid date passed to --registered + """ + And the return code should be 1 diff --git a/src/Site_Command.php b/src/Site_Command.php index 4c7cd9d93..db676a4da 100644 --- a/src/Site_Command.php +++ b/src/Site_Command.php @@ -1099,17 +1099,30 @@ public function list_( $args, $assoc_args ) { $query_args['path'] = $assoc_args['site-path']; } - // 'registered' and 'last_updated' match the stored value exactly. WP_Site_Query - // only filters on those two through a date query, and the SQL that - // WP_Date_Query emits for an exact timestamp is not translated by the SQLite - // integration, so matching them here keeps both backends behaving the same. - // '--date_query' remains available for the range queries it is meant for. - $exact_dates = []; - + // 'registered' and 'last_updated' match the stored value exactly, which + // WP_Site_Query expresses as a date query pinned to every component. Parsing + // and formatting both as UTC round-trips the given value unchanged, rather + // than shifting it by the server's timezone. foreach ( [ 'registered', 'last_updated' ] as $column ) { - if ( isset( $assoc_args[ $column ] ) ) { - $exact_dates[ $column ] = (string) $assoc_args[ $column ]; + if ( ! isset( $assoc_args[ $column ] ) ) { + continue; } + + $timestamp = strtotime( (string) $assoc_args[ $column ] . ' UTC' ); + + if ( false === $timestamp ) { + WP_CLI::error( "Invalid date passed to --{$column}: {$assoc_args[ $column ]}" ); + } + + $query_args['date_query'][] = [ + 'column' => $column, + 'year' => (int) gmdate( 'Y', $timestamp ), + 'month' => (int) gmdate( 'n', $timestamp ), + 'day' => (int) gmdate( 'j', $timestamp ), + 'hour' => (int) gmdate( 'G', $timestamp ), + 'minute' => (int) gmdate( 'i', $timestamp ), + 'second' => (int) gmdate( 's', $timestamp ), + ]; } if ( isset( $assoc_args['site_user'] ) ) { @@ -1145,25 +1158,8 @@ public function list_( $args, $assoc_args ) { $query_args['orderby'] = 'site__in'; } - $sites = self::get_sites_iterator( $query_args ); - - if ( ! empty( $exact_dates ) ) { - $sites = new CallbackFilterIterator( - $sites, - static function ( $site ) use ( $exact_dates ) { - foreach ( $exact_dates as $column => $value ) { - if ( (string) $site->$column !== $value ) { - return false; - } - } - - return true; - } - ); - } - $iterator = Utils\iterator_map( - $sites, + self::get_sites_iterator( $query_args ), function ( $site ) { $site_data = $site->to_array(); $site_data['url'] = trailingslashit( get_home_url( $site->blog_id ) ); From 808df354300a326190da14e9bedcbf8f51a36bb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:23:23 +0000 Subject: [PATCH 3/6] Let WP_Date_Query interpret the site list date filters `--registered` and `--last_updated` were parsed by the command itself, which duplicated validation that belongs to the query. Pass the value through as the bounds of an inclusive date query instead, so WordPress decides what it means. Two things fall out of that. The SQL is now a pair of comparisons rather than a component-wise match, which the SQLite integration translates, so the scenario no longer has to be skipped there. And a value carrying no time of day matches the whole day rather than only midnight. Also document that `wp site list` passes its remaining arguments to WP_Site_Query. --- README.md | 15 +++++++++++-- features/site.feature | 24 +++++++++++--------- src/Site_Command.php | 52 ++++++++++++++++++++++++------------------- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 89763945a..2a0c8ddec 100644 --- a/README.md +++ b/README.md @@ -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. @@ -5412,6 +5414,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 b28a7c4cf..a98fec53f 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1042,12 +1042,7 @@ Feature: Manage sites in a multisite installation 1 """ - - # --registered and --last_updated go through WP_Date_Query, whose exact-timestamp - # SQL the SQLite integration does not translate. Reported upstream; skipped on - # SQLite until that is resolved. - @skip-sqlite - Scenario: Filter the site list by an exact registration or update date + Scenario: Filter the site list by registration or update date Given a WP multisite install When I run `wp site create --slug=alpha --porcelain` @@ -1057,6 +1052,7 @@ Feature: Manage sites in a multisite installation 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: @@ -1064,6 +1060,13 @@ Feature: Manage sites in a multisite installation {ALPHA_ID} """ + # A value carrying no time of day matches every site registered that day. + 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 00:00:00' --format=count` Then STDOUT should be: """ @@ -1080,9 +1083,10 @@ Feature: Manage sites in a multisite installation {ALPHA_ID} """ - When I try `wp site list --registered=notadate` - Then STDERR should contain: + # 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: """ - Invalid date passed to --registered + 0 """ - And the return code should be 1 diff --git a/src/Site_Command.php b/src/Site_Command.php index db676a4da..ccdff0f4b 100644 --- a/src/Site_Command.php +++ b/src/Site_Command.php @@ -986,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. * @@ -1047,6 +1049,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 ) { @@ -1099,30 +1110,25 @@ public function list_( $args, $assoc_args ) { $query_args['path'] = $assoc_args['site-path']; } - // 'registered' and 'last_updated' match the stored value exactly, which - // WP_Site_Query expresses as a date query pinned to every component. Parsing - // and formatting both as UTC round-trips the given value unchanged, rather - // than shifting it by the server's timezone. - foreach ( [ 'registered', 'last_updated' ] as $column ) { - if ( ! isset( $assoc_args[ $column ] ) ) { - continue; - } - - $timestamp = strtotime( (string) $assoc_args[ $column ] . ' UTC' ); + // 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 = []; - if ( false === $timestamp ) { - WP_CLI::error( "Invalid date passed to --{$column}: {$assoc_args[ $column ]}" ); + 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, + ]; } + } - $query_args['date_query'][] = [ - 'column' => $column, - 'year' => (int) gmdate( 'Y', $timestamp ), - 'month' => (int) gmdate( 'n', $timestamp ), - 'day' => (int) gmdate( 'j', $timestamp ), - 'hour' => (int) gmdate( 'G', $timestamp ), - 'minute' => (int) gmdate( 'i', $timestamp ), - 'second' => (int) gmdate( 's', $timestamp ), - ]; + if ( ! empty( $date_query ) ) { + $query_args['date_query'] = $date_query; } if ( isset( $assoc_args['site_user'] ) ) { From dea0396469c13a0e85307077037604eae79eb56d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:35:32 +0000 Subject: [PATCH 4/6] Address review feedback on the date filters Document that --registered and --last_updated take a timestamp or a date, and assert that a date-only value matching nothing comes back empty, so the day-precision step cannot pass on an over-broad result. --- README.md | 2 ++ features/site.feature | 9 ++++++++- src/Site_Command.php | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a0c8ddec..4c3bc9cd9 100644 --- a/README.md +++ b/README.md @@ -5355,6 +5355,8 @@ wp site list [--network=] [--=] [--site__in=] [--site_u 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. + '--registered' and '--last_updated' take a timestamp or a date; a value + carrying no time of day matches that whole day. Note: '--path' conflicts with the global parameter of the same name; use '--site-path' to filter by path instead. diff --git a/features/site.feature b/features/site.feature index a98fec53f..e9cbf7a3b 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1060,13 +1060,20 @@ Feature: Manage sites in a multisite installation {ALPHA_ID} """ - # A value carrying no time of day matches every site registered that day. + # 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: """ diff --git a/src/Site_Command.php b/src/Site_Command.php index ccdff0f4b..9a7eeb787 100644 --- a/src/Site_Command.php +++ b/src/Site_Command.php @@ -990,6 +990,8 @@ private function get_network( $network_id ) { * 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. + * '--registered' and '--last_updated' take a timestamp or a date; a value + * carrying no time of day matches that whole day. * Note: '--path' conflicts with the global parameter of the same name; use * '--site-path' to filter by path instead. * From b59965b4fc4fd1729ed42be7c1b7f5d7db79c68f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 10:26:25 +0000 Subject: [PATCH 5/6] Fold the date filter notes into their own option entries #638 documented --registered and --last_updated as options in their own right, which left them described twice after the merge: once here and once in the --= blurb. Keep the dedicated entries and drop the duplicate. The placeholder those entries inherited also implied a full timestamp was required, which stopped being true once the filters became a date query, so they take now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL --- README.md | 14 +++++++------- src/Site_Command.php | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6db0dee8e..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** @@ -5355,8 +5355,6 @@ wp site list [--network=] [--=] [--site__in=] [--site_u 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. - '--registered' and '--last_updated' take a timestamp or a date; a value - carrying no time of day matches that whole day. Note: '--path' conflicts with the global parameter of the same name; use '--site-path' to filter by path instead. @@ -5379,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. diff --git a/src/Site_Command.php b/src/Site_Command.php index 54298c639..2c9834bb9 100644 --- a/src/Site_Command.php +++ b/src/Site_Command.php @@ -990,8 +990,6 @@ private function get_network( $network_id ) { * 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. - * '--registered' and '--last_updated' take a timestamp or a date; a value - * carrying no time of day matches that whole day. * Note: '--path' conflicts with the global parameter of the same name; use * '--site-path' to filter by path instead. * @@ -1014,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. From b5ea1011a422108d7b698c323e77444376f75597 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 11:10:42 +0000 Subject: [PATCH 6/6] Cover --site_user intersecting with --site__in The scenario exercised --site_user on its own, which cannot tell an intersection from a replacement: a change that overwrote site__in with the user's sites returns the same row and the assertion still passes. Pin both directions instead - constraining to a site the user is not on returns nothing, and constraining to one they are on still returns it - so neither an overwrite nor an always-empty intersection slips through. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL --- features/site.feature | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/features/site.feature b/features/site.feature index 1623dce8a..bebd0e0c2 100644 --- a/features/site.feature +++ b/features/site.feature @@ -1147,6 +1147,20 @@ Feature: Manage sites in a multisite installation 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