Query sites through WP_Site_Query in wp site list - #639
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL
This comment was marked as resolved.
This comment was marked as resolved.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL
`--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.
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.
This comment was marked as resolved.
This comment was marked as resolved.
…286-n50evd-site-list-query # Conflicts: # features/site.feature
#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 --<field>=<value> blurb. Keep the dedicated entries and drop the duplicate. The <yyyy-mm-dd-hh-ii-ss> 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 <date> now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL
|
On the out-of-diff note asking for 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
"""The first pins the exclusion described; the second pins that the intersection still keeps what it should, since a change making Verified the pair catches the regression it is meant to. Replacing the intersection in if ( isset( $query_args['site__in'] ) ) {
$user_ids = array_intersect( array_map( 'intval', $query_args['site__in'] ), $user_ids );
}makes the first new step fail with Generated by Claude Code |
Draft, because this changes how a widely used command reaches the database.
Came out of #638:
site listwas the odd one out among the list commands, and the reason turned out to be that it never usedWP_Site_Queryat all.What it did
It read
$wpdb->blogsdirectly through a chunked table iterator, with aWHEREclause assembled from a hardcoded column list. Consequences:WP_Site_Queryargument beyond those columns was silently ignored —search,site__not_in,network__in,lang__in,domain__in,date_query,orderby,number,offset, meta queries.pre_get_sitesand the other query filters never ran, so a plugin controlling which sites are visible had no effect here.This builds a
WP_Site_Queryargument set instead and hands it everything the command does not consume itself, so those arguments work now:Behaviour that is deliberately preserved
This command's own argument names.
--blog_id,--site__in,--site_id,--network,--site-pathand--site_userare mapped onto theirWP_Site_Queryequivalents.--networkstill takes precedence over--site_id, and listing by explicit IDs still returns them in the order given (orderby => site__in).Paging, for two separate reasons.
WP_Site_Query::$numberdefaults to 100, so a plainget_sites()call would silently truncate a network to its first hundred sites — a data-correctness bug, not a performance one. Paging 500 at a time also preserves the memory profile of the chunked iterator this replaces. An explicit--numberis the caller's own limit and is passed straight through without paging.countis withheld from the query arguments, since it makesget_sites()return an integer rather than a list, and--format=countis how this command spells that.Rows are still plain objects carrying the blogs columns plus
url, so--fields,--fieldand the formatters are unaffected.Dates
--registeredand--last_updatedhave no directWP_Site_Queryequivalent, so they become adate_queryclause bounded by the given value on both ends (beforeandafterwithinclusive). Interpreting the value is left toWP_Date_Query, which means:2026,2026-08,2026-08-17— matches that whole year, month or day.YEAR()/MONTH()/DATE_FORMAT()match, so it works on SQLite as well. Nothing in this PR is skipped on any backend.Since #638 landed, these two also have option entries of their own rather than being described inside the
--<field>=<value>blurb. The<yyyy-mm-dd-hh-ii-ss>placeholder they arrived with implied a full timestamp was required, which is no longer true, so they take<date>.Testing
Three scenarios added: the newly available
WP_Site_Queryarguments; the existing filters still behaving — including--site__inordering,--site_user, and the two of them intersecting rather than one replacing the other; and the date filters.Run with the same tag filtering CI applies, on the merge of
main(#636, #638 and the README regeneration are all in the base now). Nothing is skipped on either backend, so the two runs cover the same 38 scenarios:Widened to every feature that exercises
wp site list—site,site-generate,site-empty,site-create,signup— and diffed the failures againstorigin/mainwithsrc/andfeatures/checked out at the base:Those four are pre-existing and unrelated.
The
--site_userplus--site__incoverage was checked against the regression it is meant to catch: replacing thearray_intersectinlist_()with a plain overwrite makes it fail, while the 24 steps ahead of it in the scenario still pass.PHPCS clean. PHPStan reports no new errors against
origin/main— the same total on both sides, and the two findings inSite_Command.phpare present on both. The dynamic argument array cannot be narrowed to the shapeget_sites()documents, since it is whatever the user passed andWP_Site_Queryvalidates it itself, so those two calls carry anargument.typeignore.🤖 Generated with Claude Code
https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL
Summary by CodeRabbit
New Features
wp site listwith broader filtering and ordering options, including search, exclusions, network, path, user, and registration or last-updated date filters.Documentation
Tests