From dbb4d80fdc3771ed927b60c240e738f34566850a Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Thu, 15 Oct 2026 15:26:45 +0200 Subject: [PATCH] feat: add SiteQuery and NetworkQuery for WP_Site_Query and WP_Network_Query - SiteQuery/NetworkQuery reusing the meta, date, order, limit and field concerns - RetrievesRecords concern shared by CommentQuery, SiteQuery and NetworkQuery (first, ids, count, exists) - SiteQuery removes the silent 100 sites cap of WP_Site_Query (number => 0) - shared WP_*_Query test doubles --- README.md | 32 +++ src/CommentQuery.php | 4 +- src/NetworkQuery.php | 56 +++++ src/SiteQuery.php | 62 ++++++ src/Traits/Comments/Caching.php | 15 +- src/Traits/Comments/Order.php | 13 +- src/Traits/Comments/Retrieval.php | 74 +------ src/Traits/Concerns/DisablesOrder.php | 18 ++ .../Concerns/FiltersByDomainAndPath.php | 86 ++++++++ src/Traits/Concerns/RetrievesRecords.php | 100 +++++++++ .../Fields.php => Concerns/SelectsIds.php} | 7 +- src/Traits/Concerns/SkipsFoundRows.php | 21 ++ src/Traits/Networks/Caching.php | 24 ++ src/Traits/Networks/Filters.php | 54 +++++ src/Traits/Networks/Order.php | 41 ++++ src/Traits/Networks/Retrieval.php | 18 ++ src/Traits/Sites/Caching.php | 33 +++ src/Traits/Sites/Filters.php | 206 ++++++++++++++++++ src/Traits/Sites/Order.php | 67 ++++++ src/Traits/Sites/Retrieval.php | 18 ++ tests/Feature/CommentQueryTest.php | 8 +- tests/Feature/NetworkQueryTest.php | 118 ++++++++++ tests/Feature/SiteQueryTest.php | 172 +++++++++++++++ tests/helpers/wp.php | 67 +++--- 24 files changed, 1187 insertions(+), 127 deletions(-) create mode 100644 src/NetworkQuery.php create mode 100644 src/SiteQuery.php create mode 100644 src/Traits/Concerns/DisablesOrder.php create mode 100644 src/Traits/Concerns/FiltersByDomainAndPath.php create mode 100644 src/Traits/Concerns/RetrievesRecords.php rename src/Traits/{Comments/Fields.php => Concerns/SelectsIds.php} (69%) create mode 100644 src/Traits/Concerns/SkipsFoundRows.php create mode 100644 src/Traits/Networks/Caching.php create mode 100644 src/Traits/Networks/Filters.php create mode 100644 src/Traits/Networks/Order.php create mode 100644 src/Traits/Networks/Retrieval.php create mode 100644 src/Traits/Sites/Caching.php create mode 100644 src/Traits/Sites/Filters.php create mode 100644 src/Traits/Sites/Order.php create mode 100644 src/Traits/Sites/Retrieval.php create mode 100644 tests/Feature/NetworkQueryTest.php create mode 100644 tests/Feature/SiteQueryTest.php diff --git a/README.md b/README.md index 7af7431..78b1d81 100644 --- a/README.md +++ b/README.md @@ -1895,6 +1895,38 @@ CommentQuery::select() > WP's default status is `all`, which is `hold` and `approve`: **spam and trash comments are left out** unless you ask for them with `status()`. `CommentQuery` does not change that. WP also skips counting the total rows by default (`no_found_rows`), call `noFoundRows(false)` if you need it. +## SiteQuery and NetworkQuery + +Multisite queries: `SiteQuery` builds `WP_Site_Query` arguments, `NetworkQuery` builds `WP_Network_Query` arguments. + +```php +use Pollora\Query\NetworkQuery; +use Pollora\Query\SiteQuery; + +SiteQuery::select() + ->networkId(1) + ->wherePublic()->whereDeleted(false) + ->search('*shop*')->searchColumns(['domain']) + ->latest() + ->get(); // WP_Site_Query + +NetworkQuery::select()->domain('example.com')->first(); +``` + +**SiteQuery** +- **Sites**: `whereIn()` (`site__in`), `whereNotIn()`, `networkId()`, `networkIn()`, `networkNotIn()`, `domain*()`, `path*()`, `langId()`, `langIn()`, `langNotIn()`. `SiteQuery::find(3)` or `find([1, 2])` is `site__in`; WP's `ID` argument is the same thing. +- **Status**: `wherePublic()`, `whereArchived()`, `whereMature()`, `whereSpam()`, `whereDeleted()`, each taking a bool (sent as 1 or 0). Without them WP applies no filter, so archived, spam and deleted sites are returned. +- **Search**: `search()` (a leading/trailing `*` is a wildcard) and `searchColumns()`, which only accepts `domain` and `path`. +- **Meta and dates**: `metaKey()`, `metaValue()`, `metaQuery()` as for posts; `dateQuery()` filters on the `registered` column. +- **Order**: `orderBy($field, $direction)` can be chained (sent as a map); `orderByDomain()`, `orderByPath()`, `orderByDomainLength()`, `orderByPathLength()`, `orderByLastUpdated()`, `orderByInclude()`, `orderByNetworkIn()`, `latest()`, `oldest()`, `withoutOrder()`. +- **Cache**: `noFoundRows()`, `updateCache()`, `updateMetaCache()`. + +**NetworkQuery**: `whereIn()` (`network__in`), `whereNotIn()`, `domain*()`, `path*()`, `search()`, `orderByDomain()`, `orderByPath()`, `orderByDomainLength()`, `orderByPathLength()`, `orderByInclude()`, `withoutOrder()`, `noFoundRows()`, `updateCache()`. `NetworkQuery::find(2)` is `network__in`. + +**Both**: `limit()` / `take()` (`number`), `offset()` / `skip()`, `select('ids')` or `fields('ids')`, and the terminals `get()`, `first()`, `ids()`, `count()` and `exists()`, which run on a copy of the builder. + +> `WP_Site_Query` returns at most 100 sites unless told otherwise, which silently truncates large networks. `SiteQuery` sends `number => 0` (no cap) by default: call `limit()` if you want one. WP only applies `offset` together with a `number`, so use `skip()` with `limit()`. + ## Testing ```bash diff --git a/src/CommentQuery.php b/src/CommentQuery.php index 2f43186..70c8028 100644 --- a/src/CommentQuery.php +++ b/src/CommentQuery.php @@ -5,7 +5,6 @@ namespace Pollora\Query; use Pollora\Query\Traits\Comments\Caching; -use Pollora\Query\Traits\Comments\Fields; use Pollora\Query\Traits\Comments\Filters; use Pollora\Query\Traits\Comments\Order; use Pollora\Query\Traits\Comments\PostFilters; @@ -13,6 +12,7 @@ use Pollora\Query\Traits\Concerns\BuildsArguments; use Pollora\Query\Traits\Concerns\LimitsResults; use Pollora\Query\Traits\Concerns\PagedResults; +use Pollora\Query\Traits\Concerns\SelectsIds; use Pollora\Query\Traits\DateQuery; use Pollora\Query\Traits\MetaField; use Pollora\Query\Traits\MetaQuery; @@ -20,7 +20,7 @@ class CommentQuery { - use BuildsArguments, Caching, DateQuery, Fields, Filters, LimitsResults, MetaField, MetaQuery, Order, PagedResults, PostFilters, Retrieval; + use BuildsArguments, Caching, DateQuery, Filters, LimitsResults, MetaField, MetaQuery, Order, PagedResults, PostFilters, Retrieval, SelectsIds; /** * @param array|int|null $commentId A comment ID, or a list of comment IDs. diff --git a/src/NetworkQuery.php b/src/NetworkQuery.php new file mode 100644 index 0000000..83f747f --- /dev/null +++ b/src/NetworkQuery.php @@ -0,0 +1,56 @@ +|int|null $networkId A network ID, or a list of network IDs. + */ + public function __construct(array|int|null $networkId = null, ?string $fields = null) + { + if (is_int($networkId)) { + $this->whereIn([$networkId]); + } elseif (is_array($networkId)) { + $this->whereIn($networkId); + } + + if ($fields) { + $this->fields($fields); + } + } + + /** + * @param array|int|null $networkId + */ + public static function find(array|int|null $networkId = null): self + { + return new static($networkId); + } + + public static function select(?string $fields = 'all'): self + { + return new static(null, $fields); + } + + public function get(): WP_Network_Query + { + return new WP_Network_Query($this->getArguments()); + } +} diff --git a/src/SiteQuery.php b/src/SiteQuery.php new file mode 100644 index 0000000..53cc02c --- /dev/null +++ b/src/SiteQuery.php @@ -0,0 +1,62 @@ +|int|null $siteId A site ID, or a list of site IDs. + */ + public function __construct(array|int|null $siteId = null, ?string $fields = null) + { + // WP_Site_Query returns at most 100 sites unless told otherwise: no cap is applied here (0 is "all"). + $this->number = 0; + + if (is_int($siteId)) { + $this->whereIn([$siteId]); + } elseif (is_array($siteId)) { + $this->whereIn($siteId); + } + + if ($fields) { + $this->fields($fields); + } + } + + /** + * @param array|int|null $siteId + */ + public static function find(array|int|null $siteId = null): self + { + return new static($siteId); + } + + public static function select(?string $fields = 'all'): self + { + return new static(null, $fields); + } + + public function get(): WP_Site_Query + { + return new WP_Site_Query($this->getArguments()); + } +} diff --git a/src/Traits/Comments/Caching.php b/src/Traits/Comments/Caching.php index 7a74893..7c5d51a 100644 --- a/src/Traits/Comments/Caching.php +++ b/src/Traits/Comments/Caching.php @@ -4,9 +4,11 @@ namespace Pollora\Query\Traits\Comments; +use Pollora\Query\Traits\Concerns\SkipsFoundRows; + trait Caching { - protected ?bool $noFoundRows = null; + use SkipsFoundRows; protected ?bool $updateCommentMetaCache = null; @@ -14,17 +16,6 @@ trait Caching protected ?string $cacheDomain = null; - /** - * Whether the total number of comments is skipped. WP skips it by default, call `noFoundRows(false)` - * if you need the total or the number of pages. - */ - public function noFoundRows(bool $value = true): self - { - $this->noFoundRows = $value; - - return $this; - } - public function updateMetaCache(bool $value = true): self { $this->updateCommentMetaCache = $value; diff --git a/src/Traits/Comments/Order.php b/src/Traits/Comments/Order.php index 56749c4..da230f5 100644 --- a/src/Traits/Comments/Order.php +++ b/src/Traits/Comments/Order.php @@ -4,11 +4,12 @@ namespace Pollora\Query\Traits\Comments; +use Pollora\Query\Traits\Concerns\DisablesOrder; use Pollora\Query\Traits\Concerns\OrdersByMap; trait Order { - use OrdersByMap; + use DisablesOrder, OrdersByMap; public function orderByDate(string $order = 'DESC'): self { @@ -40,14 +41,4 @@ public function oldest(): self { return $this->orderByDate('ASC'); } - - /** - * Disables the ORDER BY clause. - */ - public function withoutOrder(): self - { - $this->orderby = 'none'; - - return $this; - } } diff --git a/src/Traits/Comments/Retrieval.php b/src/Traits/Comments/Retrieval.php index f1e7e70..af1dd1f 100644 --- a/src/Traits/Comments/Retrieval.php +++ b/src/Traits/Comments/Retrieval.php @@ -4,83 +4,23 @@ namespace Pollora\Query\Traits\Comments; -use Pollora\Query\Traits\Concerns\CopiesBuilder; +use Pollora\Query\Traits\Concerns\RetrievesRecords; use WP_Comment_Query; -/** - * Terminal methods that run the query with the arguments that fit their purpose. - * - * They work on a copy: the builder keeps its own arguments, so it can still be reused afterwards. - */ trait Retrieval { - use CopiesBuilder; + use RetrievesRecords; - /** - * Set by count() on a copy only. - */ - protected ?bool $count = null; - - /** - * The first matching comment (a WP_Comment), or null. A comment ID with `fields('ids')`. - */ - public function first(): object|int|null - { - $comments = $this->runOnCopy(function (self $copy): void { - $copy->number = 1; - $copy->offset = null; - $copy->paged = null; - $copy->hierarchical = null; - }); - - return $comments[0] ?? null; - } - - /** - * @return array - */ - public function ids(): array + protected function newWpQuery(): object { - return array_map('intval', $this->runOnCopy(function (self $copy): void { - $copy->fields = 'ids'; - $copy->hierarchical = null; - })); + return new WP_Comment_Query; } /** - * The total number of matching comments, whatever the limit, offset or page set on the builder. + * Descendants are ignored by WP for ids and counts, and would hide the record a terminal asks for. */ - public function count(): int + protected function prepareCopy(self $copy): void { - $copyArgs = $this->copyWith(function (self $copy): void { - $copy->count = true; - $copy->number = null; - $copy->offset = null; - $copy->paged = null; - $copy->hierarchical = null; - }); - - return (int) (new WP_Comment_Query)->query($copyArgs); - } - - public function exists(): bool - { - return $this->runOnCopy(function (self $copy): void { - $copy->fields = 'ids'; - $copy->number = 1; - $copy->offset = null; - $copy->paged = null; - $copy->hierarchical = null; - }) !== []; - } - - /** - * @return array - */ - private function runOnCopy(callable $configure): array - { - $comments = (new WP_Comment_Query)->query($this->copyWith($configure)); - - return is_array($comments) ? array_values($comments) : []; + $copy->hierarchical = null; } } diff --git a/src/Traits/Concerns/DisablesOrder.php b/src/Traits/Concerns/DisablesOrder.php new file mode 100644 index 0000000..707d09b --- /dev/null +++ b/src/Traits/Concerns/DisablesOrder.php @@ -0,0 +1,18 @@ +orderby = 'none'; + + return $this; + } +} diff --git a/src/Traits/Concerns/FiltersByDomainAndPath.php b/src/Traits/Concerns/FiltersByDomainAndPath.php new file mode 100644 index 0000000..814a8d6 --- /dev/null +++ b/src/Traits/Concerns/FiltersByDomainAndPath.php @@ -0,0 +1,86 @@ +|null + */ + protected ?array $domain_In = null; + + /** + * @var array|null + */ + protected ?array $domain_NotIn = null; + + protected ?string $path = null; + + /** + * @var array|null + */ + protected ?array $path_In = null; + + /** + * @var array|null + */ + protected ?array $path_NotIn = null; + + public function domain(string $domain): self + { + $this->domain = $domain; + + return $this; + } + + /** + * @param array $domains + */ + public function domainIn(array $domains): self + { + $this->domain_In = $domains; + + return $this; + } + + /** + * @param array $domains + */ + public function domainNotIn(array $domains): self + { + $this->domain_NotIn = $domains; + + return $this; + } + + public function path(string $path): self + { + $this->path = $path; + + return $this; + } + + /** + * @param array $paths + */ + public function pathIn(array $paths): self + { + $this->path_In = $paths; + + return $this; + } + + /** + * @param array $paths + */ + public function pathNotIn(array $paths): self + { + $this->path_NotIn = $paths; + + return $this; + } +} diff --git a/src/Traits/Concerns/RetrievesRecords.php b/src/Traits/Concerns/RetrievesRecords.php new file mode 100644 index 0000000..7e7907f --- /dev/null +++ b/src/Traits/Concerns/RetrievesRecords.php @@ -0,0 +1,100 @@ +runOnCopy(function (self $copy): void { + $copy->number = 1; + $copy->resetPaging(); + }); + + return $records[0] ?? null; + } + + /** + * @return array + */ + public function ids(): array + { + return array_map('intval', $this->runOnCopy(function (self $copy): void { + $copy->fields = 'ids'; + })); + } + + /** + * The total number of matching records, whatever the limit or offset set on the builder. + */ + public function count(): int + { + $args = $this->copyWith(function (self $copy): void { + $copy->count = true; + $copy->number = null; + $copy->resetPaging(); + }); + + return (int) $this->newWpQuery()->query($args); + } + + public function exists(): bool + { + return $this->runOnCopy(function (self $copy): void { + $copy->fields = 'ids'; + $copy->number = 1; + $copy->resetPaging(); + }) !== []; + } + + /** + * @return array + */ + private function runOnCopy(callable $configure): array + { + $records = $this->newWpQuery()->query($this->copyWith(function (self $copy) use ($configure): void { + $configure($copy); + $this->prepareCopy($copy); + })); + + return is_array($records) ? array_values($records) : []; + } + + private function resetPaging(): void + { + $this->offset = null; + + if (property_exists($this, 'paged')) { + $this->paged = null; + } + } +} diff --git a/src/Traits/Comments/Fields.php b/src/Traits/Concerns/SelectsIds.php similarity index 69% rename from src/Traits/Comments/Fields.php rename to src/Traits/Concerns/SelectsIds.php index 14e869b..3abd7a5 100644 --- a/src/Traits/Comments/Fields.php +++ b/src/Traits/Concerns/SelectsIds.php @@ -2,19 +2,18 @@ declare(strict_types=1); -namespace Pollora\Query\Traits\Comments; +namespace Pollora\Query\Traits\Concerns; use Pollora\Query\QueryException; -use Pollora\Query\Traits\Concerns\ValidatesChoices; -trait Fields +trait SelectsIds { use ValidatesChoices; protected ?string $fields = null; /** - * 'ids' returns comment IDs only, 'all' (or '*') whole comments, which is WP's default. + * 'ids' returns IDs only, 'all' (or '*') whole objects, which is WP's default. * * @throws QueryException */ diff --git a/src/Traits/Concerns/SkipsFoundRows.php b/src/Traits/Concerns/SkipsFoundRows.php new file mode 100644 index 0000000..f9786a2 --- /dev/null +++ b/src/Traits/Concerns/SkipsFoundRows.php @@ -0,0 +1,21 @@ +noFoundRows = $value; + + return $this; + } +} diff --git a/src/Traits/Networks/Caching.php b/src/Traits/Networks/Caching.php new file mode 100644 index 0000000..f5fa61e --- /dev/null +++ b/src/Traits/Networks/Caching.php @@ -0,0 +1,24 @@ +updateNetworkCache = $value; + + return $this; + } +} diff --git a/src/Traits/Networks/Filters.php b/src/Traits/Networks/Filters.php new file mode 100644 index 0000000..d7685e9 --- /dev/null +++ b/src/Traits/Networks/Filters.php @@ -0,0 +1,54 @@ +|null + */ + protected ?array $network_In = null; + + /** + * @var array|null + */ + protected ?array $network_NotIn = null; + + protected ?string $search = null; + + /** + * @param array $networkIds + */ + public function whereIn(array $networkIds): self + { + $this->network_In = $networkIds; + + return $this; + } + + /** + * @param array $networkIds + */ + public function whereNotIn(array $networkIds): self + { + $this->network_NotIn = $networkIds; + + return $this; + } + + /** + * Searches the domain and the path. A leading and/or trailing `*` makes it a wildcard search. + */ + public function search(string $keyword): self + { + $this->search = $keyword; + + return $this; + } +} diff --git a/src/Traits/Networks/Order.php b/src/Traits/Networks/Order.php new file mode 100644 index 0000000..89dd592 --- /dev/null +++ b/src/Traits/Networks/Order.php @@ -0,0 +1,41 @@ +orderBy('domain', $order); + } + + public function orderByPath(string $order = 'ASC'): self + { + return $this->orderBy('path', $order); + } + + public function orderByDomainLength(string $order = 'ASC'): self + { + return $this->orderBy('domain_length', $order); + } + + public function orderByPathLength(string $order = 'ASC'): self + { + return $this->orderBy('path_length', $order); + } + + /** + * Keeps the order of the IDs given to whereIn(). + */ + public function orderByInclude(): self + { + return $this->orderBy('network__in'); + } +} diff --git a/src/Traits/Networks/Retrieval.php b/src/Traits/Networks/Retrieval.php new file mode 100644 index 0000000..e027745 --- /dev/null +++ b/src/Traits/Networks/Retrieval.php @@ -0,0 +1,18 @@ +updateSiteCache = $value; + + return $this; + } + + public function updateMetaCache(bool $value = true): self + { + $this->updateSiteMetaCache = $value; + + return $this; + } +} diff --git a/src/Traits/Sites/Filters.php b/src/Traits/Sites/Filters.php new file mode 100644 index 0000000..82f512b --- /dev/null +++ b/src/Traits/Sites/Filters.php @@ -0,0 +1,206 @@ +|null + */ + protected ?array $site_In = null; + + /** + * @var array|null + */ + protected ?array $site_NotIn = null; + + protected ?int $networkId = null; + + /** + * @var array|null + */ + protected ?array $network_In = null; + + /** + * @var array|null + */ + protected ?array $network_NotIn = null; + + protected ?int $public = null; + + protected ?int $archived = null; + + protected ?int $mature = null; + + protected ?int $spam = null; + + protected ?int $deleted = null; + + protected ?int $langId = null; + + /** + * @var array|null + */ + protected ?array $lang_In = null; + + /** + * @var array|null + */ + protected ?array $lang_NotIn = null; + + protected ?string $search = null; + + /** + * @var array|null + */ + protected ?array $searchColumns = null; + + /** + * @param array $siteIds + */ + public function whereIn(array $siteIds): self + { + $this->site_In = $siteIds; + + return $this; + } + + /** + * @param array $siteIds + */ + public function whereNotIn(array $siteIds): self + { + $this->site_NotIn = $siteIds; + + return $this; + } + + /** + * Sites of one network. WP queries every network when it is not set (or 0). + */ + public function networkId(int $networkId): self + { + $this->networkId = $networkId; + + return $this; + } + + /** + * @param array $networkIds + */ + public function networkIn(array $networkIds): self + { + $this->network_In = $networkIds; + + return $this; + } + + /** + * @param array $networkIds + */ + public function networkNotIn(array $networkIds): self + { + $this->network_NotIn = $networkIds; + + return $this; + } + + /** + * Public sites, or with false the sites that are not. Without it, WP applies no filter. + */ + public function wherePublic(bool $value = true): self + { + $this->public = (int) $value; + + return $this; + } + + public function whereArchived(bool $value = true): self + { + $this->archived = (int) $value; + + return $this; + } + + public function whereMature(bool $value = true): self + { + $this->mature = (int) $value; + + return $this; + } + + public function whereSpam(bool $value = true): self + { + $this->spam = (int) $value; + + return $this; + } + + public function whereDeleted(bool $value = true): self + { + $this->deleted = (int) $value; + + return $this; + } + + public function langId(int $langId): self + { + $this->langId = $langId; + + return $this; + } + + /** + * @param array $langIds + */ + public function langIn(array $langIds): self + { + $this->lang_In = $langIds; + + return $this; + } + + /** + * @param array $langIds + */ + public function langNotIn(array $langIds): self + { + $this->lang_NotIn = $langIds; + + return $this; + } + + /** + * A leading and/or trailing `*` makes it a wildcard search. + */ + public function search(string $keyword): self + { + $this->search = $keyword; + + return $this; + } + + /** + * @param array $columns 'domain' and/or 'path'. + * + * @throws QueryException + */ + public function searchColumns(array $columns): self + { + foreach ($columns as $column) { + $this->assertOneOf($column, ['domain', 'path'], 'search column'); + } + + $this->searchColumns = $columns; + + return $this; + } +} diff --git a/src/Traits/Sites/Order.php b/src/Traits/Sites/Order.php new file mode 100644 index 0000000..b913e06 --- /dev/null +++ b/src/Traits/Sites/Order.php @@ -0,0 +1,67 @@ +orderBy('domain', $order); + } + + public function orderByPath(string $order = 'ASC'): self + { + return $this->orderBy('path', $order); + } + + public function orderByDomainLength(string $order = 'ASC'): self + { + return $this->orderBy('domain_length', $order); + } + + public function orderByPathLength(string $order = 'ASC'): self + { + return $this->orderBy('path_length', $order); + } + + public function orderByLastUpdated(string $order = 'DESC'): self + { + return $this->orderBy('last_updated', $order); + } + + /** + * Keeps the order of the IDs given to whereIn(). + */ + public function orderByInclude(): self + { + return $this->orderBy('site__in'); + } + + /** + * Keeps the order of the IDs given to networkIn(). + */ + public function orderByNetworkIn(): self + { + return $this->orderBy('network__in'); + } + + /** + * Most recently registered first. + */ + public function latest(): self + { + return $this->orderBy('registered', 'DESC'); + } + + public function oldest(): self + { + return $this->orderBy('registered', 'ASC'); + } +} diff --git a/src/Traits/Sites/Retrieval.php b/src/Traits/Sites/Retrieval.php new file mode 100644 index 0000000..41e5277 --- /dev/null +++ b/src/Traits/Sites/Retrieval.php @@ -0,0 +1,18 @@ +postId(3)->first())->toBe($comment) ->and(WP_Comment_Query::$log[0])->toMatchArray(['number' => 1, 'post_id' => 3]); @@ -168,7 +168,7 @@ }); test('CommentQuery: ids', function () { - WP_Comment_Query::$fakeComments = ['3', 5]; + WP_Comment_Query::$fakeResults = ['3', 5]; expect(CommentQuery::select()->limit(20)->ids())->toBe([3, 5]) ->and(WP_Comment_Query::$log[0])->toMatchArray(['fields' => 'ids', 'number' => 20]); @@ -185,7 +185,7 @@ test('CommentQuery: exists', function () { expect(CommentQuery::select()->exists())->toBeFalse(); - WP_Comment_Query::$fakeComments = [1]; + WP_Comment_Query::$fakeResults = [1]; expect(CommentQuery::select()->exists())->toBeTrue() ->and(WP_Comment_Query::$log[1])->toMatchArray(['fields' => 'ids', 'number' => 1]); diff --git a/tests/Feature/NetworkQueryTest.php b/tests/Feature/NetworkQueryTest.php new file mode 100644 index 0000000..b04a83c --- /dev/null +++ b/tests/Feature/NetworkQueryTest.php @@ -0,0 +1,118 @@ +getArguments())->toEqual([]); +}); + +test('NetworkQuery: find maps to network__in', function () { + expect(NetworkQuery::find(2)->getArguments())->toEqual(['network__in' => [2]]) + ->and(NetworkQuery::find([1, 2])->getArguments())->toEqual(['network__in' => [1, 2]]); +}); + +test('NetworkQuery: filters', function () { + $args = NetworkQuery::select() + ->whereIn([1])->whereNotIn([2]) + ->domain('a.test')->domainIn(['b.test'])->domainNotIn(['c.test']) + ->path('/')->pathIn(['/x/'])->pathNotIn(['/y/']) + ->search('*test*') + ->getArguments(); + + expect($args)->toEqual([ + 'network__in' => [1], 'network__not_in' => [2], + 'domain' => 'a.test', 'domain__in' => ['b.test'], 'domain__not_in' => ['c.test'], + 'path' => '/', 'path__in' => ['/x/'], 'path__not_in' => ['/y/'], + 'search' => '*test*', + ]); +}); + +test('NetworkQuery: limit and offset', function () { + expect(NetworkQuery::select()->limit(3)->skip(1)->getArguments())->toEqual(['number' => 3, 'offset' => 1]); +}); + +test('NetworkQuery: fields', function () { + expect(NetworkQuery::select('ids')->getArguments())->toEqual(['fields' => 'ids']) + ->and(NetworkQuery::select('all')->getArguments())->toEqual([]); +}); + +test('NetworkQuery: invalid fields throws', function () { + NetworkQuery::select('names'); +})->throws(QueryException::class); + +test('NetworkQuery: order', function () { + expect(NetworkQuery::select()->orderByDomain('desc')->orderByPathLength()->getArguments()['orderby']) + ->toBe(['domain' => 'DESC', 'path_length' => 'ASC']) + ->and(NetworkQuery::select()->orderByInclude()->getArguments()['orderby'])->toBe(['network__in' => 'ASC']) + ->and(NetworkQuery::select()->orderByPath()->orderByDomainLength()->getArguments()['orderby'])->toBe(['path' => 'ASC', 'domain_length' => 'ASC']) + ->and(NetworkQuery::select()->withoutOrder()->getArguments()['orderby'])->toBe('none'); +}); + +test('NetworkQuery: cache and found rows', function () { + expect(NetworkQuery::select()->noFoundRows(false)->updateCache(false)->getArguments()) + ->toEqual(['no_found_rows' => false, 'update_network_cache' => false]); +}); + +test('NetworkQuery: has no meta, date or paging methods', function () { + $query = NetworkQuery::select(); + + expect(method_exists($query, 'metaQuery'))->toBeFalse() + ->and(method_exists($query, 'dateQuery'))->toBeFalse() + ->and(method_exists($query, 'paged'))->toBeFalse(); +}); + +test('NetworkQuery: get runs WP_Network_Query with the arguments', function () { + $query = NetworkQuery::select()->domain('a.test')->get(); + + expect(WP_Network_Query::$log[0])->toEqual(['domain' => 'a.test']) + ->and($query)->toBeInstanceOf(WP_Network_Query::class); +}); + +test('NetworkQuery: first', function () { + $network = new stdClass; + WP_Network_Query::$fakeResults = [$network, new stdClass]; + + expect(NetworkQuery::select()->domain('a.test')->first())->toBe($network) + ->and(WP_Network_Query::$log[0])->toEqual(['domain' => 'a.test', 'number' => 1]); +}); + +test('NetworkQuery: ids', function () { + WP_Network_Query::$fakeResults = ['2', 4]; + + expect(NetworkQuery::select()->limit(5)->ids())->toEqual([2, 4]) + ->and(WP_Network_Query::$log[0])->toEqual(['fields' => 'ids', 'number' => 5]); +}); + +test('NetworkQuery: count ignores the limit and the offset', function () { + WP_Network_Query::$fakeCount = 3; + + expect(NetworkQuery::select()->limit(5)->offset(2)->count())->toBe(3) + ->and(WP_Network_Query::$log[0])->toEqual(['count' => true]); +}); + +test('NetworkQuery: exists', function () { + expect(NetworkQuery::select()->exists())->toBeFalse(); + + WP_Network_Query::$fakeResults = [1]; + + expect(NetworkQuery::select()->exists())->toBeTrue(); +}); + +test('NetworkQuery: terminals do not alter the builder', function () { + $query = NetworkQuery::select()->domain('a.test')->limit(10)->offset(5); + $before = $query->getArguments(); + + $query->first(); + $query->ids(); + $query->count(); + $query->exists(); + + expect($query->getArguments())->toBe($before); +}); diff --git a/tests/Feature/SiteQueryTest.php b/tests/Feature/SiteQueryTest.php new file mode 100644 index 0000000..c4cf32b --- /dev/null +++ b/tests/Feature/SiteQueryTest.php @@ -0,0 +1,172 @@ +getArguments())->toEqual(['number' => 0]); +}); + +test('SiteQuery: limit restores a cap', function () { + expect(SiteQuery::select()->limit(20)->offset(5)->getArguments())->toEqual(['number' => 20, 'offset' => 5]); +}); + +test('SiteQuery: find maps to site__in', function () { + expect(SiteQuery::find(3)->getArguments())->toMatchArray(['site__in' => [3]]) + ->and(SiteQuery::find([1, 2])->getArguments())->toMatchArray(['site__in' => [1, 2]]); +}); + +test('SiteQuery: site and network filters', function () { + $args = SiteQuery::select()->whereIn([1])->whereNotIn([2])->networkId(4)->networkIn([5])->networkNotIn([6])->getArguments(); + + expect($args)->toMatchArray(['site__in' => [1], 'site__not_in' => [2], 'network_id' => 4, 'network__in' => [5], 'network__not_in' => [6]]); +}); + +test('SiteQuery: domain and path', function () { + $args = SiteQuery::select() + ->domain('a.test')->domainIn(['b.test'])->domainNotIn(['c.test']) + ->path('/blog/')->pathIn(['/x/'])->pathNotIn(['/y/']) + ->getArguments(); + + expect($args)->toMatchArray([ + 'domain' => 'a.test', 'domain__in' => ['b.test'], 'domain__not_in' => ['c.test'], + 'path' => '/blog/', 'path__in' => ['/x/'], 'path__not_in' => ['/y/'], + ]); +}); + +test('SiteQuery: status flags are sent as 1 or 0, and only when asked', function () { + expect(SiteQuery::select()->getArguments())->not->toHaveKeys(['public', 'archived', 'mature', 'spam', 'deleted']) + ->and(SiteQuery::select()->wherePublic()->whereArchived(false)->whereMature()->whereSpam(false)->whereDeleted()->getArguments()) + ->toMatchArray(['public' => 1, 'archived' => 0, 'mature' => 1, 'spam' => 0, 'deleted' => 1]); +}); + +test('SiteQuery: languages', function () { + expect(SiteQuery::select()->langId(2)->langIn(['1', '3'])->langNotIn(['4'])->getArguments()) + ->toMatchArray(['lang_id' => 2, 'lang__in' => ['1', '3'], 'lang__not_in' => ['4']]); +}); + +test('SiteQuery: search', function () { + expect(SiteQuery::select()->search('*blog*')->searchColumns(['domain'])->getArguments()) + ->toMatchArray(['search' => '*blog*', 'search_columns' => ['domain']]); +}); + +test('SiteQuery: invalid search column throws', function () { + SiteQuery::select()->searchColumns(['blogname']); +})->throws(QueryException::class, 'Invalid search column supplied: blogname'); + +test('SiteQuery: fields', function () { + expect(SiteQuery::select('ids')->getArguments()['fields'])->toBe('ids') + ->and(SiteQuery::select('*')->getArguments())->not->toHaveKey('fields'); +}); + +test('SiteQuery: invalid fields throws', function () { + SiteQuery::select('names'); +})->throws(QueryException::class, 'Invalid fields value supplied: names'); + +test('SiteQuery: orderby is a map of fields and directions', function () { + $args = SiteQuery::select()->orderByDomain('desc')->orderByPath()->latest()->getArguments(); + + expect($args['orderby'])->toBe(['domain' => 'DESC', 'path' => 'ASC', 'registered' => 'DESC']); +}); + +test('SiteQuery: order shortcuts', function () { + expect(SiteQuery::select()->oldest()->getArguments()['orderby'])->toBe(['registered' => 'ASC']) + ->and(SiteQuery::select()->orderByLastUpdated()->getArguments()['orderby'])->toBe(['last_updated' => 'DESC']) + ->and(SiteQuery::select()->orderByDomainLength()->orderByPathLength()->getArguments()['orderby'])->toBe(['domain_length' => 'ASC', 'path_length' => 'ASC']) + ->and(SiteQuery::select()->orderByInclude()->orderByNetworkIn()->getArguments()['orderby'])->toBe(['site__in' => 'ASC', 'network__in' => 'ASC']) + ->and(SiteQuery::select()->withoutOrder()->getArguments()['orderby'])->toBe('none'); +}); + +test('SiteQuery: invalid order direction throws', function () { + SiteQuery::select()->orderByDomain('sideways'); +})->throws(QueryException::class); + +test('SiteQuery: cache and found rows', function () { + $args = SiteQuery::select()->noFoundRows(false)->updateCache(false)->updateMetaCache(false)->getArguments(); + + expect($args)->toMatchArray(['no_found_rows' => false, 'update_site_cache' => false, 'update_site_meta_cache' => false]); +}); + +test('SiteQuery: meta and meta_query reuse the shared builders', function () { + $args = SiteQuery::select() + ->metaKey('plan')->metaValue('pro') + ->metaQuery(function (MetaQuery $q) { + $q->where($q->meta('seats')->greaterThan(10)); + }) + ->getArguments(); + + expect($args)->toMatchArray(['meta_key' => 'plan', 'meta_value' => 'pro']) + ->and($args['meta_query'][0])->toMatchArray(['key' => 'seats', 'compare' => '>', 'value' => 10]); +}); + +test('SiteQuery: date_query leaves the column to WP (registered)', function () { + $args = SiteQuery::select()->dateQuery(function (DateQuery $q) { + $q->where($q->date()->after('2024-01-01')); + })->getArguments(); + + expect($args['date_query'][0])->not->toHaveKey('column'); +}); + +test('SiteQuery: get runs WP_Site_Query with the arguments', function () { + $query = SiteQuery::select()->domain('a.test')->get(); + + expect(WP_Site_Query::$log[0])->toMatchArray(['domain' => 'a.test']) + ->and($query)->toBeInstanceOf(WP_Site_Query::class); +}); + +test('SiteQuery: first', function () { + $site = new stdClass; + WP_Site_Query::$fakeResults = [$site, new stdClass]; + + expect(SiteQuery::select()->domain('a.test')->first())->toBe($site) + ->and(WP_Site_Query::$log[0])->toMatchArray(['number' => 1, 'domain' => 'a.test']); +}); + +test('SiteQuery: first returns null when nothing matches', function () { + expect(SiteQuery::select()->offset(5)->first())->toBeNull() + ->and(WP_Site_Query::$log[0])->not->toHaveKey('offset'); +}); + +test('SiteQuery: ids', function () { + WP_Site_Query::$fakeResults = ['3', 5]; + + expect(SiteQuery::select()->ids())->toBe([3, 5]) + ->and(WP_Site_Query::$log[0])->toMatchArray(['fields' => 'ids', 'number' => 0]); +}); + +test('SiteQuery: count ignores the limit and the offset', function () { + WP_Site_Query::$fakeCount = 12; + + expect(SiteQuery::select()->limit(5)->offset(2)->count())->toBe(12) + ->and(WP_Site_Query::$log[0])->toMatchArray(['count' => true]) + ->and(WP_Site_Query::$log[0])->not->toHaveKeys(['number', 'offset']); +}); + +test('SiteQuery: exists', function () { + expect(SiteQuery::select()->exists())->toBeFalse(); + + WP_Site_Query::$fakeResults = [1]; + + expect(SiteQuery::select()->exists())->toBeTrue() + ->and(WP_Site_Query::$log[1])->toMatchArray(['fields' => 'ids', 'number' => 1]); +}); + +test('SiteQuery: terminals do not alter the builder', function () { + $query = SiteQuery::select()->domain('a.test')->limit(10)->offset(5); + $before = $query->getArguments(); + + $query->first(); + $query->ids(); + $query->count(); + $query->exists(); + + expect($query->getArguments())->toBe($before)->and($before)->not->toHaveKey('count'); +}); diff --git a/tests/helpers/wp.php b/tests/helpers/wp.php index 1e2fccb..06ec8fc 100644 --- a/tests/helpers/wp.php +++ b/tests/helpers/wp.php @@ -129,40 +129,53 @@ public function get_total(): int } } -if (! class_exists('WP_Comment_Query')) { - /** - * Minimal WP_Comment_Query double: records the arguments and returns the comments set up by the test. - */ - class WP_Comment_Query - { - /** @var array */ - public static array $log = []; - - public static mixed $fakeComments = []; +/** + * Behaviour shared by the doubles of the queries whose `query()` returns a list, or a number with `count`. + */ +trait WpQueryDouble +{ + /** @var array */ + public static array $log = []; - public static int $fakeCount = 0; + public static array $fakeResults = []; - public array $query_vars = []; + public static int $fakeCount = 0; - public array $comments = []; + public array $query_vars = []; - public function __construct(array $args = []) - { - if ($args !== []) { - $this->query($args); - } + public function __construct(array $args = []) + { + if ($args !== []) { + $this->query($args); } + } - public function query(array $args): array|int - { - self::$log[] = $args; - $this->query_vars = $args; + public function query(array $args): array|int + { + self::$log[] = $args; + $this->query_vars = $args; - if (! empty($args['count'])) { - return self::$fakeCount; - } + return ! empty($args['count']) ? self::$fakeCount : self::$fakeResults; + } +} - return $this->comments = self::$fakeComments; - } +if (! class_exists('WP_Comment_Query')) { + class WP_Comment_Query + { + use WpQueryDouble; + } +} + +if (! class_exists('WP_Site_Query')) { + class WP_Site_Query + { + use WpQueryDouble; + } +} + +if (! class_exists('WP_Network_Query')) { + class WP_Network_Query + { + use WpQueryDouble; } }