Skip to content

Query sites through WP_Site_Query in wp site list - #639

Merged
swissspidy merged 7 commits into
mainfrom
claude/wp-cli-issue-5286-n50evd-site-list-query
Aug 17, 2026
Merged

Query sites through WP_Site_Query in wp site list#639
swissspidy merged 7 commits into
mainfrom
claude/wp-cli-issue-5286-n50evd-site-list-query

Conversation

@swissspidy

@swissspidy swissspidy commented Aug 17, 2026

Copy link
Copy Markdown
Member

Draft, because this changes how a widely used command reaches the database.

Came out of #638: site list was the odd one out among the list commands, and the reason turned out to be that it never used WP_Site_Query at all.

What it did

It read $wpdb->blogs directly through a chunked table iterator, with a WHERE clause assembled from a hardcoded column list. Consequences:

  • Every WP_Site_Query argument 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_sites and the other query filters never ran, so a plugin controlling which sites are visible had no effect here.
  • Results were uncached.

This builds a WP_Site_Query argument set instead and hands it everything the command does not consume itself, so those arguments work now:

$ wp site list --search=alpha --field=blog_id
$ wp site list --site__not_in=2 --format=count
$ wp site list --number=1 --offset=1 --field=blog_id
$ wp site list --orderby=id --order=desc --field=blog_id

Behaviour that is deliberately preserved

This command's own argument names. --blog_id, --site__in, --site_id, --network, --site-path and --site_user are mapped onto their WP_Site_Query equivalents. --network still 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::$number defaults to 100, so a plain get_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 --number is the caller's own limit and is passed straight through without paging.

count is withheld from the query arguments, since it makes get_sites() return an integer rather than a list, 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.

Dates

--registered and --last_updated have no direct WP_Site_Query equivalent, so they become a date_query clause bounded by the given value on both ends (before and after with inclusive). Interpreting the value is left to WP_Date_Query, which means:

  • A full timestamp matches that second, exactly as before.
  • A value carrying no time of day — 2026, 2026-08, 2026-08-17 — matches that whole year, month or day.
  • The SQL is a pair of plain comparisons rather than a component-wise YEAR()/MONTH()/DATE_FORMAT() match, so it works on SQLite as well. Nothing in this PR is skipped on any backend.
  • A value WordPress cannot parse resolves to a date no site can carry, so the list comes back empty rather than erroring.

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_Query arguments; the existing filters still behaving — including --site__in ordering, --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:

Backend Result
MariaDB 10.11 38 scenarios, 498 steps, all passed
SQLite 3.45 38 scenarios, 498 steps, all passed

Widened to every feature that exercises wp site listsite, site-generate, site-empty, site-create, signup — and diffed the failures against origin/main with src/ and features/ checked out at the base:

origin/main : 4 failures   (Empty a site, Empty a site and its uploads directory,
                            Create new site with custom $super_admins global,
                            Respect defined $base in wp-config)
this branch : the same 4
NEW: none    FIXED: none

Those four are pre-existing and unrelated.

The --site_user plus --site__in coverage was checked against the regression it is meant to catch: replacing the array_intersect in list_() 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 in Site_Command.php are present on both. The dynamic argument array cannot be narrowed to the shape get_sites() documents, since it is whatever the user passed and WP_Site_Query validates it itself, so those two calls carry an argument.type ignore.

🤖 Generated with Claude Code

https://claude.ai/code/session_014SSZzqMJRDTiLiDxQEPYcL

Summary by CodeRabbit

  • New Features

    • Enhanced wp site list with broader filtering and ordering options, including search, exclusions, network, path, user, and registration or last-updated date filters.
    • Added support for pagination, result counts, and preserving explicitly requested site order.
    • Improved handling of combined filters and date-only matching.
  • Documentation

    • Expanded command usage guidance with supported filters, date syntax, and practical examples.
  • Tests

    • Added comprehensive coverage for filtering, pagination, ordering, counts, and invalid or nonmatching dates.

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
@coderabbitai

This comment was marked as resolved.

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.27586% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Site_Command.php 98.27% 1 Missing ⚠️

📢 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
@swissspidy
swissspidy marked this pull request as ready for review August 17, 2026 07:13
@swissspidy
swissspidy requested a review from a team as a code owner August 17, 2026 07:13
Copilot AI lite review requested due to automatic review settings August 17, 2026 07:13

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

`--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.
coderabbitai[bot]

This comment was marked as resolved.

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.

@swissspidy swissspidy added the command:site-list Related to 'site list' command label Aug 17, 2026
@swissspidy swissspidy added this to the 3.0.3 milestone Aug 17, 2026
@github-actions github-actions Bot added scope:documentation Related to documentation scope:testing Related to testing command:site Related to 'site' command labels Aug 17, 2026
claude added 2 commits August 17, 2026 10:22
…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
coderabbitai[bot]

This comment was marked as resolved.

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

Copy link
Copy Markdown
Member Author

On the out-of-diff note asking for --site_user combined with --site__in — added in b5ea101, in both directions so the new assertion cannot pass vacuously either:

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 --site__in plus --site_user always return nothing would satisfy the first step on its own.

Verified the pair catches the regression it is meant to. Replacing the intersection in list_() with a plain overwrite:

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 1 instead of 0 — while the 24 steps ahead of it in the scenario still pass, which is exactly the gap identified.


Generated by Claude Code

@swissspidy
swissspidy merged commit b521058 into main Aug 17, 2026
58 of 59 checks passed
@swissspidy
swissspidy deleted the claude/wp-cli-issue-5286-n50evd-site-list-query branch August 17, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

command:site Related to 'site' command command:site-list Related to 'site list' command scope:documentation Related to documentation scope:testing Related to testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants