diff --git a/README.md b/README.md index 78b1d81..f6449a0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Query: a fluent builder for WP_Query + Query: fluent builders for the WordPress query classes

@@ -11,9 +11,11 @@ License

-Query builds `WP_Query` objects with chained, type-hinted methods instead of nested arrays. `PostQuery` covers the `WP_Query` parameters (authors, taxonomies, meta, dates, pagination, order, cache…) and `TaxQuery`, `MetaQuery` and `DateQuery` build the nested sub-queries. It is for WordPress developers who are tired of looking up array keys and debugging misplaced `relation` entries. +Query builds the arguments of the WordPress query classes (`WP_Query`, `WP_Term_Query`, `WP_User_Query`, `WP_Comment_Query`, `WP_Site_Query` and `WP_Network_Query`) with chained, type-hinted methods instead of nested arrays. Tax, meta and date sub-queries are built with `where()` and `orWhere()`, and the `relation` keys and the nesting are written for you. -> Part of [Pollora](https://pollora.dev), the Laravel framework for WordPress. In a Pollora project it is already installed: use the `Pollora\Support\Facades\PostQuery` facade. +It is for WordPress developers who are tired of looking up array keys and debugging misplaced `relation` entries. + +> Part of [Pollora](https://pollora.dev), the Laravel framework for WordPress. In a Pollora project `PostQuery` is already installed: use the `Pollora\Support\Facades\PostQuery` facade. ## Installation @@ -21,7 +23,7 @@ Query builds `WP_Query` objects with chained, type-hinted methods instead of nes composer require pollora/query ``` -Requires PHP 8.2+ and WordPress: `get()` returns a `WP_Query`. If your WordPress site does not use Composer yet, see [this article on managing WordPress with Composer](https://deliciousbrains.com/storing-wordpress-in-git/). +Requires PHP 8.2+ and WordPress. If your WordPress site does not use Composer yet, see [this article on managing WordPress with Composer](https://deliciousbrains.com/storing-wordpress-in-git/). ## Quick start @@ -38,7 +40,7 @@ $query = PostQuery::select() ->get(); // WP_Query ``` -Active products in a category, with a meta query and a tax query: +Active products in a category, with a tax query and a meta query: ```php use Pollora\Query\MetaQuery; @@ -58,97 +60,136 @@ $query = PostQuery::select() ->get(); ``` -Only the IDs of a few posts, or the arguments without running the query: +You rarely need the whole `WP_Query` object: terminal methods return what you actually want. + +```php +$latest = PostQuery::select()->postType('post')->latest()->first(); // WP_Post or null +$ids = PostQuery::select()->postType('page')->limit(20)->ids(); // [12, 15, 31] +$total = PostQuery::select()->postType('post')->postStatus('publish')->count(); +$exists = PostQuery::select()->postType('page')->postSlug('about')->exists(); +``` + +The other query classes work the same way: + +```php +use Pollora\Query\CommentQuery; +use Pollora\Query\TermQuery; +use Pollora\Query\UserQuery; + +$categories = TermQuery::select()->taxonomy('category')->rootOnly()->orderByCount()->get(); +$editors = UserQuery::select()->roleIn(['editor', 'author'])->latest()->limit(20)->get(); +$pending = CommentQuery::select()->postId(42)->pending()->count(); +``` + +To see the arguments without running the query, use `getArguments()`: ```php -$ids = PostQuery::find([1, 2, 3])->fields('ids')->get()->posts; +PostQuery::select()->postType('page')->postParent(5)->getArguments(); +``` -$args = PostQuery::select()->postType('page')->postParent(5)->getArguments(); -// ['post_type' => 'page', 'post_parent' => 5, 'fields' => 'all', ...] +`getArguments()` returns: + +```php +[ + 'fields' => 'all', + 'post_parent' => 5, + 'post_type' => 'page', +] ``` -## Why PostQuery +## The query classes + +| Class | Builds the arguments of | Documentation | +|---|---|---| +| `PostQuery` | `WP_Query`: posts, pages, custom post types | [PostQuery](docs/post-query.md) | +| `TermQuery` | `WP_Term_Query`: categories, tags, custom taxonomies | [TermQuery](docs/term-query.md) | +| `UserQuery` | `WP_User_Query` | [UserQuery](docs/user-query.md) | +| `CommentQuery` | `WP_Comment_Query` | [CommentQuery](docs/comment-query.md) | +| `SiteQuery`, `NetworkQuery` | `WP_Site_Query`, `WP_Network_Query` (multisite) | [SiteQuery and NetworkQuery](docs/site-network-query.md) | + +The sub-queries are shared: [`TaxQuery`](docs/tax-query.md) (posts), [`MetaQuery`](docs/meta-query.md) (posts, terms, users, comments, sites) and [`DateQuery`](docs/date-query.md) (posts, users, comments, sites). + +## Documentation + +- [Concepts](docs/concepts.md): how the builders work, defaults, errors, `where()` / `orWhere()`, raw arguments. **Start here.** +- [Getting results](docs/results.md): `get()`, `first()`, `ids()`, `count()`, `exists()` and friends. +- [Tax queries](docs/tax-query.md), [meta queries](docs/meta-query.md), [date queries](docs/date-query.md). +- [Upgrading](docs/upgrading.md): what changed since 1.0. + +## Why use it Compared with passing an array to `WP_Query`: - Chained methods with descriptive names, which read top to bottom. - Type-hinted parameters and IDE autocompletion, instead of array keys to remember. -- Tax, meta and date sub-queries built with `where()` and `orWhere()`: the `relation` keys and the nesting are written for you. +- Invalid values (an unknown field, a wrong order direction) throw a `QueryException` instead of being silently ignored. +- Tax, meta and date sub-queries without hand-written `relation` keys. ### A complete example -A query combining a post type, a tax query, a date query, a meta query, a permission and caching, then the `WP_Query` arguments it replaces. - -#### With `PostQuery` +A query combining a post type, a tax query, a date query, a meta query, a permission and caching (with the `use` statements of the classes above, plus `Pollora\Query\DateQuery`): ```php -use Pollora\Query\DateQuery; -use Pollora\Query\MetaQuery; -use Pollora\Query\PostQuery; -use Pollora\Query\TaxQuery; - -$results = PostQuery::select() +PostQuery::select() ->postType('product') ->taxQuery(function (TaxQuery $query) { $query->where( - $query - ->taxonomy('category') - ->contains(['tee-shirt', 'sportswear']) - ->searchByTermSlug() + $query->taxonomy('category')->contains(['tee-shirt', 'sportswear'])->searchByTermSlug() ); }) ->dateQuery(function (DateQuery $query) { $query->where( - $query - ->date('posted_at') - ->between('2021-01-01', '2022-02-01') + $query->date('posted_at')->between('2021-01-01', '2022-02-01') )->orWhere( $query->date()->created()->after('2021-01-01') ); }) ->metaQuery(function (MetaQuery $query) { $query - ->where( - $query->meta('status')->equalTo('active') - )->orWhere( - $query->meta('highlighted')->equalTo(0) - ); + ->where($query->meta('status')->equalTo('active')) + ->orWhere($query->meta('highlighted')->equalTo(0)); }) ->userPermission('readable') ->cacheResults() ->get(); ``` -#### The equivalent `WP_Query` +`WP_Query` receives: ```php -new WP_Query([ +[ 'cache_results' => true, 'date_query' => [ 'relation' => 'OR', [ 'column' => 'posted_at', - 'before' => - [ - 'year' => '2022', - 'month' => '02', - 'day' => '01', - ], - 'after' => - [ - 'year' => '2021', - 'month' => '01', - 'day' => '01', - ], + 'before' => [ + 'year' => 2022, + 'month' => 2, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + 'after' => [ + 'year' => 2021, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], ], [ 'column' => 'post_date', - 'after' => - [ - 'year' => '2021', - 'month' => '01', - 'day' => '01', - ], + 'after' => [ + 'year' => 2021, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], ], ], 'fields' => 'all', @@ -157,6 +198,7 @@ new WP_Query([ [ 'key' => 'status', 'compare' => '=', + 'type' => 'CHAR', 'value' => 'active', ], [ @@ -173,1760 +215,19 @@ new WP_Query([ [ 'taxonomy' => 'category', 'field' => 'slug', - 'terms' => - [ - 'tee-shirt', - 'sportswear', - ], + 'terms' => [ + 'tee-shirt', + 'sportswear', + ], 'operator' => 'IN', 'include_children' => true, ], ], -]); +] ``` The same query as a `WP_Query` array is much harder to read and to get right. -## Reference - -[Basic usage](#basic-usage) · [Author](#author) · [Category](#category) · [Tag](#tag) · [Tax query](#tax-query) · [Search](#search) · [Post](#post) · [Password](#password) · [Status](#status) · [Comment Parameter](#comment-parameter) · [Pagination](#pagination) · [Order](#order) · [Date](#date) · [Meta Query](#meta-query) · [Permission](#permission) · [Mimetype](#mimetype) · [Cache](#cache) - -### Basic usage - -To get started with the `PostQuery` class, you can use the `select()` method to specify the fields you want to retrieve and the `get()` method to generate a WP_Query object with the corresponding arguments. - -By default, the `PostQuery` class adopts a more intuitive approach when it comes to querying posts by post type. Unlike the default behavior of WordPress's `WP_Query`, which sets the `post_type` parameter to `'post'` if not specified, the `PostQuery` class assumes a broader scope. It sets the `post_type` parameter to `'any'` by default, indicating that it will search across all post types. This default behavior aligns with a more logical and inclusive approach, ensuring that you can effortlessly query posts of any type without needing to explicitly specify the post type every time you create a query. This simplifies the process and enhances flexibility when constructing your queries. - -#### Retrieve All Fields - -```php -PostQuery::select()->get(); // Equivalent to PostQuery::select('*')->get(); -``` - -This will generate the following WP_Query arguments: - -```php -new WP_Query([ - 'fields' => 'all', - 'post_type' => 'any', -]); -``` - -In this case, the `fields` argument is set to `'all'`, indicating that all fields should be retrieved. - -#### Specify Specific Fields - -You can use the `select()` method with specific field names to tailor your query to your needs. - -```php -PostQuery::select('id=>parent')->get(); -``` - -This will generate the following WP_Query arguments: - -```php -new WP_Query([ - 'fields' => 'id=>parent', - 'post_type' => 'any', -]); -``` - -Here, the `fields` argument is set to `'id=>parent'`, which means only the 'id' and 'parent' fields will be retrieved. - -#### Retrieve Only IDs - -To retrieve only the IDs of the posts, you can use the following code: - -```php -PostQuery::select('ids')->get(); -``` - -This will generate the following WP_Query arguments: - -```php -new WP_Query([ - 'fields' => 'ids', - 'post_type' => 'any', -]); -``` - -In this case, the `fields` argument is set to `'ids'`, indicating that only the post IDs will be returned. - -#### Find specific post id - -The `find($postID)` method is used to create a WP_Query instance that retrieves a specific post by its ID. This method simplifies the process of querying a single post and allows you to specify the fields you want to retrieve. - -To use the `find()` method, follow these steps: - -1. Call the `find($postID)` method on an instance of the `PostQuery` class, passing the desired post ID as a parameter. -2. Chain other methods to further customize the query, such as `fields()`, if needed. -3. Finally, call the `get()` method to generate a WP_Query object with the specified arguments. - -```php -PostQuery::find(1) // you can also pass an array of post ids. - ->fields('ids') - ->get(); -``` - -This will generate the following WP_Query arguments: - -```php -new WP_Query([ - 'p' => 1, // Retrieve the post with ID 1 - 'fields' => 'ids', // Retrieve only the post ID - 'post_type' => 'any', -]); -``` - -In this example, the `find(1)` method specifies that you want to retrieve the post with ID 1, the `fields('ids')` method indicates that you only want to retrieve the post ID, and the `post_type` argument is set to `'any'`, including all post types in the query. - -### Author - -The `PostQuery` class provides several methods for specifying the author(s) of the posts you want to retrieve. These methods allow you to filter posts based on author IDs, author usernames, and author IDs to include or exclude. - -#### author($authorID) - -The `author($authorID)` method allows you to query posts authored by a specific user using their user ID. Here's an example: - -```php -PostQuery::select() - ->author(1) - ->get(); -``` - -This will generate the following WP_Query arguments: - -```php -new WP_Query([ - 'author' => 1, // Retrieve posts authored by the user with ID 1 - 'post_type' => 'any' // Include all post types in the query -]); -``` - -#### authorName($authorUsername) - -The `authorName($authorUsername)` method allows you to query posts authored by a specific user using their username. Here's an example: - -```php -PostQuery::select() - ->authorName('taylor') - ->get(); -``` - -This will generate the following WP_Query arguments: - -```php -new WP_Query([ - 'author_name' => 'taylor', // Retrieve posts authored by the user with the username 'taylor' - 'post_type' => 'any' // Include all post types in the query -]); -``` - -#### authorIn($authorIDs) - -The `authorIn($authorIDs)` method allows you to query posts authored by one or more users specified by their user IDs. You can pass an array of user IDs to this method. Here's an example: - -```php -PostQuery::select() - ->authorIn([1, 2, 3]) - ->get(); -``` - -This will generate the following WP_Query arguments: - -```php -new WP_Query([ - 'author__in' => [1, 2, 3], // Retrieve posts authored by users with IDs 1, 2, or 3 - 'post_type' => 'any' // Include all post types in the query -]); -``` - -#### authorNotIn($authorIDs) - -The `authorNotIn($authorIDs)` method allows you to exclude posts authored by one or more users specified by their user IDs. You can pass an array of user IDs to this method. Here's an example: - -```php -PostQuery::select() - ->authorNotIn([1, 2, 3]) - ->get(); -``` - -This will generate the following WP_Query arguments: - -```php -new WP_Query([ - 'author__not_in' => [1, 2, 3], // Exclude posts authored by users with IDs 1, 2, or 3 - 'post_type' => 'any' // Include all post types in the query -]); -``` - -### Category - -The `PostQuery` class allows you to query posts based on categories using various methods. These methods enable you to filter posts by category ID, category name, and include or exclude categories. - -#### cat($categoryID) - -The `cat($categoryID)` method allows you to query posts belonging to a specific category by specifying its category ID. Here's an example: - -```php -PostQuery::select() - ->cat(1) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'cat' => 1, // Retrieve posts from category with ID 1 - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### categoryName($categoryName) - -The `categoryName($categoryName)` method allows you to query posts belonging to a specific category by specifying its name. Here's an example: - -```php -PostQuery::select() - ->categoryName('sales') - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'category_name' => 'sales', // Retrieve posts from the 'sales' category - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### categoryIn($categoryIDs) - -The `categoryIn($categoryIDs)` method allows you to include posts from one or more categories specified by their category IDs. You can pass an array of category IDs to this method. Here's an example: - -```php -PostQuery::select() - ->categoryIn([1, 2, 3]) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'category__in' => [1, 2, 3], // Include posts from categories with IDs 1, 2, or 3 - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### categoryNotIn($categoryIDs) - -The `categoryNotIn($categoryIDs)` method allows you to exclude posts from one or more categories specified by their category IDs. You can pass an array of category IDs to this method. Here's an example: - -```php -PostQuery::select() - ->categoryNotIn([1, 2, 3]) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'category__not_in' => [1, 2, 3], // Exclude posts from categories with IDs 1, 2, or 3 - 'post_type' => 'any', // Include all post types in the query -]); -``` - -### Tag - -The `PostQuery` class allows you to query posts based on tags using various methods. These methods enable you to filter posts by tag name, tag ID, tag slugs, and include or exclude tags. - -#### tag($tagName) - -The `tag($tagName)` method allows you to query posts associated with a specific tag by specifying its name. Here's an example: - -```php -PostQuery::select() - ->tag('programming') - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'tag' => 'programming', // Retrieve posts associated with the 'programming' tag - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### tagId($tagID) - -The `tagId($tagID)` method allows you to query posts associated with a specific tag by specifying its ID. Here's an example: - -```php -PostQuery::select() - ->tagId(1) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'tag_id' => 1, // Retrieve posts associated with the tag with ID 1 - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### tagAnd($tagIDs) - -The `tagAnd($tagIDs)` method allows you to query posts that are associated with all of the specified tags by specifying their IDs. You can pass an array of tag IDs to this method. Here's an example: - -```php -PostQuery::select() - ->tagAnd([1, 2]) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'tag__and' => [1, 2], // Retrieve posts associated with both tags with IDs 1 and 2 - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### tagIn($tagIDs) - -The `tagIn($tagIDs)` method allows you to query posts associated with one or more tags by specifying their IDs. You can pass an array of tag IDs to this method. Here's an example: - -```php -PostQuery::select() - ->tagIn([3, 4]) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'tag__in' => [3, 4], // Retrieve posts associated with tags with IDs 3 or 4 - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### tagNotIn($tagIDs) - -The `tagNotIn($tagIDs)` method allows you to exclude posts associated with one or more tags by specifying their IDs. You can pass an array of tag IDs to this method. Here's an example: - -```php -PostQuery::select() - ->tagNotIn([5, 6]) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'tag__not_in' => [5, 6], // Exclude posts associated with tags with IDs 5 or 6 - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### tagSlugAnd($tagSlugs) - -The `tagSlugAnd($tagSlugs)` method allows you to query posts that are associated with all of the specified tags by specifying their slugs. You can pass an array of tag slugs to this method. Here's an example: - -```php -PostQuery::select() - ->tagSlugAnd(['dev', 'qa']) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'tag_slug__and' => ['dev', 'qa'], // Retrieve posts associated with both 'dev' and 'qa' tags - 'post_type' => 'any', // Include all post types in the query -]); -``` - -#### tagSlugIn($tagSlugs) - -The `tagSlugIn($tagSlugs)` method allows you to query posts associated with one or more tags by specifying their slugs. You can pass an array of tag slugs to this method. Here's an example: - -```php -PostQuery::select() - ->tagSlugIn(['frontend', 'backend']) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'tag_slug__in' => ['frontend', 'backend'], // Retrieve posts associated with 'frontend' or 'backend' tags - 'post_type' => 'any', // Include all post types in the query -]); -``` - -### Tax query - -The `PostQuery` class allows you to filter posts based on taxonomy terms using the `taxQuery()` method. This method provides a powerful way to construct complex queries involving taxonomy terms and their relationships. - -#### Simple tax query - -In its simplest form, you can use the `taxQuery()` method to filter posts by a single taxonomy and a list of terms. For example: - -```php -PostQuery::select() - ->taxQuery(function (TaxQuery $query) { - $query->where( - $query - ->taxonomy('category') - ->contains(['tee-shirt', 'sportswear']) - ->searchByTermSlug() - ); - }) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'post_type' => 'any', - 'tax_query' => [ - 'relation' => 'AND', - [ - 'taxonomy' => 'category', - 'field' => 'slug', - 'terms' => ['tee-shirt', 'sportswear'], - 'operator' => 'IN', - 'include_children' => true, - ], - ], -]); -``` - -#### 'OR' Relation - -You can also use the 'OR' relation to query posts that match any of the specified taxonomy conditions. For example: - -```php -PostQuery::select() - ->taxQuery(function (TaxQuery $query) { - $query->where( - $query - ->taxonomy('category') - ->contains([10, 20]) - ->searchById() - )->orWhere( - $query - ->taxonomy('event') - ->contains(['black-friday', 'christmas-sales']) - ->searchByTermSlug() - ); - }) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'post_type' => 'any', - 'tax_query' => [ - 'relation' => 'OR', - [ - 'taxonomy' => 'category', - 'field' => 'term_id', - 'terms' => [10, 20], - 'operator' => 'IN', - 'include_children' => true, - ], - [ - 'taxonomy' => 'event', - 'field' => 'slug', - 'terms' => ['black-friday', 'christmas-sales'], - 'operator' => 'IN', - 'include_children' => true, - ], - ], -]); -``` - -#### Nested Conditions - -You can also create nested conditions within the Tax_Query. In the following example, we use nested conditions with 'OR' and 'AND' relations: - -```php -PostQuery::select() - ->taxQuery(function (TaxQuery $query) { - $query - ->where( - $query->taxonomy('status')->notContains(['private']) - ) - ->orWhere(function (TaxQuery $query) { - $query - ->where( - $query->taxonomy('attributes')->exists() - ) - ->orWhere( - $query->taxonomy('product_cat')->contains(['tee-shirt', 'sportswear']) - ); - }) - ->orWhere(function (TaxQuery $query) { - $query - ->where( - $query->taxonomy('promo_cat')->contains(['summer', 'blackfriday']) - ) - ->andWhere( - $query->taxonomy('new_products')->notExists() - ); - }); - }) - ->get(); -``` - -This generates the following WP_Query arguments: - -```php -new WP_Query([ - 'post_type' => 'any', - 'tax_query' => [ - 'relation' => 'OR', - [ - 'taxonomy' => 'status', - 'field' => 'term_id', - 'terms' => [ - 'private', - ], - 'operator' => 'NOT IN', - 'include_children' => true, - ], - [ - 'relation' => 'OR', - [ - 'taxonomy' => 'attributes', - 'field' => 'term_id', - 'terms' => null, - 'operator' => 'EXISTS', - 'include_children' => true, - ], - [ - 'taxonomy' => 'product_cat', - 'field' => 'term_id', - 'terms' => [ - 'tee-shirt', - 'sportswear', - ], - 'operator' => 'IN', - 'include_children' => true, - ], - ], - [ - 'relation' => 'AND', - [ - 'taxonomy' => 'promo_cat', - 'field' => 'term_id', - 'terms' => [ - 'summer', - 'blackfriday', - ], - 'operator' => 'IN', - 'include_children' => true, - ], - [ - 'taxonomy' => 'new_products', - 'field' => 'term_id', - 'terms' => null, - 'operator' => 'NOT EXISTS', - 'include_children' => true, - ], - ], - ], -]) -``` - -### Search - -The `PostQuery` class allows you to perform keyword-based searches on posts using the `search()` method. This method helps you filter posts that match a specific keyword or phrase. - -#### search($keyword) - -The `search($keyword)` method allows you to perform a keyword-based search for posts. You can specify the desired keyword or phrase as a parameter. Here's an example: - -```php -PostQuery::select() - ->search('my keyword') - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 's' => 'my keyword', // Perform a keyword-based search for posts containing 'my keyword' - 'post_type' => 'any', -]); -``` - -### Post - -The `PostQuery` class provides methods for filtering posts based on various post-related parameters. These methods allow you to specify the post type, post ID, post slug, post parent, and more. - -#### postType($type) - -The `postType($type)` method allows you to specify the post type you want to query. You can provide the post type as a parameter. Here's an example: - -```php -PostQuery::select() - ->postType('article') - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_type' => 'article', // Retrieve posts of the 'article' post type -]); -``` - -#### postId($id) - -The `postId($id)` method allows you to query a specific post by its ID. You can provide the post ID as a parameter. For example: - -```php -PostQuery::select() - ->postId(42) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'p' => 42, // Retrieve the post with ID 42 - 'post_type' => 'any' -]); -``` - -#### postSlug($slug) - -The `postSlug($slug)` method allows you to query a specific post by its slug. You can provide the post slug as a parameter. For example: - -```php -PostQuery::select() - ->postSlug('mon-article') - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'name' => 'mon-article', // Retrieve the post with the slug 'mon-article' - 'post_type' => 'any' -]); - -``` - -#### postParent($parentID) - -The `postParent($parentID)` method allows you to query posts that have a specific parent post. You can provide the parent post ID as a parameter. For example: - -```php -PostQuery::select() - ->postParent(5) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_parent' => 5, // Retrieve posts with a parent post of ID 5 - 'post_type' => 'any', -]); -``` - -#### whereParentIn($parentIDs) - -The `whereParentIn($parentIDs)` method allows you to query posts that have parent posts specified by their IDs. You can pass an array of parent post IDs to this method. For example: - -```php -PostQuery::select() - ->whereParentIn([1, 2, 3]) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_parent__in' => [1, 2, 3], // Retrieve posts with parent post IDs in the array - 'post_type' => 'any' -]); -``` - -#### whereParentNotIn($parentIDs) - -The `whereParentNotIn($parentIDs)` method allows you to exclude posts that have parent posts specified by their IDs. You can pass an array of parent post IDs to this method. For example: - -```php -PostQuery::select() - ->whereParentNotIn([4, 5, 6]) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_parent__not_in' => [4, 5, 6], // Exclude posts with parent post IDs in the array - 'post_type' => 'any' -]); -``` - -#### whereIn($postIDs) - -The `whereIn($postIDs)` method allows you to query posts with specific IDs. You can pass an array of post IDs to this method. For example: - -```php -PostQuery::select() - ->whereIn([7, 8, 9]) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post__in' => [7, 8, 9], // Retrieve posts with IDs in the array - 'post_type' => 'any' -]); -``` - -#### whereNotIn($postIDs) - -The `whereNotIn($postIDs)` method allows you to exclude posts with specific IDs. You can pass an array of post IDs to this method. For example: - -```php -PostQuery::select() - ->whereNotIn([10, 11, 12]) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post__not_in' => [10, 11, 12], // Exclude posts with IDs in the array - 'post_type' => 'any' -]); -``` - -#### slugIn($slugs) - -The `slugIn($slugs)` method allows you to query posts with specific slugs. You can pass an array of post slugs to this method. For example: - -```php -PostQuery::select() - ->slugIn(['slug-1', 'slug-2']) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_name__in' => ['slug-1', 'slug-2'], // Retrieve posts with slugs in the array - 'post_type' => 'any' -]); -``` - -#### Note: 'pagename' and 'page_id' - -Unlike WP_Query, the `PostQuery` class does not provide separate methods for 'pagename' and 'page_id' parameters. Instead, you can use 'name' and 'p' parameters while specifying the 'post_type' as 'page' to achieve the equivalent results: - -```php -PostQuery::select() - ->postType('page') - ->postSlug('my-page') - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_type' => 'page', // Query for pages - 'name' => 'my-page', // Specify the page slug - 'post_type' => 'any' -]); -``` - -### Password - -The `PostQuery` class provides methods to filter posts based on their password protection status and the specific post password. - -#### withPassword() - -The `withPassword()` method allows you to query posts that have a password protection set. You can use this method to retrieve posts that require a password to access. For example: - -```php -PostQuery::select() - ->withPassword() - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'has_password' => true, // Retrieve posts with password protection - 'post_type' => 'any' -]); -``` - -#### withoutPassword() - -The `withoutPassword()` method allows you to query posts that do not have a password protection set. You can use this method to exclude posts that require a password to access. For example: - -```php -PostQuery::select() - ->withoutPassword() - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'has_password' => false, // Exclude posts with password protection - 'post_type' => 'any' -]); -``` - -#### withPassword($password) - -The `withPassword($password)` method allows you to query posts that have a specific post password set. You can provide the desired post password as a parameter. For example: - -```php -PostQuery::select() - ->withPassword('zxcvbn') - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_password' => 'zxcvbn', // Retrieve posts with the specified post password - 'post_type' => 'any' -]); -``` - -### Status - -The `PostQuery` class allows you to filter posts based on their status using the `postStatus()` method. This method helps you query posts with a specific status. - -#### postStatus($status) - -The `postStatus($status)` method allows you to specify the status of posts you want to retrieve. You can provide the desired status as a parameter. For example: - -```php -PostQuery::select() - ->postStatus('publish') - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_status' => 'publish', // Retrieve posts with the 'publish' status - 'post_type' => 'any' -]); -``` - -You can use this method to query posts with different statuses such as 'publish,' 'draft,' 'pending,' or custom statuses defined in your WordPress installation. It provides precise control over the status of the posts you retrieve in your queries. - -### Comment Parameter - -The `PostQuery` class provides a method to filter posts based on the number of comments they have using the `commentCount()` method. This method allows you to retrieve posts with a specific number of comments. - -#### commentCount($count) - -The `commentCount($count)` method allows you to specify the number of comments a post should have in order to be retrieved in the query. You can provide the desired comment count as a parameter. For example: - -```php -PostQuery::select() - ->commentCount(1) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'comment_count' => 1, // Retrieve posts with exactly 1 comment -]); -``` - -### Pagination - -The `PostQuery` class provides methods to control the pagination of query results. These methods allow you to specify the number of posts to retrieve per page, skip a certain number of posts, and more. - -#### take($count), limit($count) or postsPerPage($count) - -The `take($count)`, `limit($count)`, and `postsPerPage($count)` methods allow you to specify the number of posts to retrieve per page. You can provide the desired post count as a parameter. For example: - -```php -PostQuery::select() - ->take(5) - ->get(); -``` - -These methods generate the following WP_Query argument: - -```php -new WP_Query([ - 'posts_per_page' => 5, // Retrieve 5 posts per page -]); -``` - -You can use these methods to control the number of posts displayed on each page of your query results. - -#### skip($count) or offset($count) - -The `skip($count)` and `offset($count)` methods allow you to skip a certain number of posts in the query results. You can provide the desired skip count as a parameter. For example: - -```php -PostQuery::select() - ->skip(5) - ->get(); -``` - -These methods generate the following WP_Query argument: - -```php -new WP_Query([ - 'offset' => 5, // Skip the first 5 posts in the query results -]); -``` - -You can use these methods to skip a specific number of posts, useful for creating paginated queries. - -#### noPaging() - -The `noPaging()` method allows you to disable pagination entirely, retrieving all posts matching the query without any pagination. For example: - -```php -PostQuery::select() - ->noPaging() - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'nopaging' => true, // Retrieve all posts without pagination -]); -``` - -Use this method when you want to retrieve all matching posts in a single query, regardless of the number. - -#### postsPerArchivePage($count) - -The `postsPerArchivePage($count)` method allows you to specify the number of posts to display per archive page. This is particularly useful for archive pages like category or tag archives. For example: - -```php -PostQuery::select() - ->postsPerArchivePage(5) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'posts_per_archive_page' => 5, // Display 5 posts per archive page -]); -``` - -You can use this method to control the number of posts displayed on archive pages. - -#### page($pageNumber) - -The `page($pageNumber)` method allows you to specify the page number when paginating query results. You can provide the desired page number as a parameter. For example: - -```php -PostQuery::select() - ->page(666) - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'page' => 666, // Retrieve results for page number 666 -]); -``` - -Use this method when you want to retrieve a specific page of results in a paginated query. - -#### ignoreStickyPosts() - -The `ignoreStickyPosts()` method allows you to exclude sticky posts from the query results. Sticky posts are posts that are pinned to the top of the list. For example: - -```php -PostQuery::select() - ->ignoreStickyPosts() - ->get(); -``` - -This generates the following WP_Query argument: - -```php -new WP_Query([ - 'ignore_sticky_posts' => true, // Exclude sticky posts from the query -]); -``` - -### Order - -The `PostQuery` class provides methods to control the order in which posts are retrieved. You can specify one or more orderby parameters to sort the query results based on various criteria. - -#### orderBy($field, $order = 'DESC') - -The `orderBy($field, $order = 'DESC')` method allows you to specify the field by which the posts should be ordered and the order in which they should be sorted. You can provide the desired field and order as parameters. For example: - -```php -PostQuery::select() - ->orderBy('most_comments') - ->orderBy('post_date', 'ASC') - ->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'orderby' => [ - 'most_comments' => 'DESC', // Order by 'most_comments' in descending order - 'post_date' => 'ASC', // Then order by 'post_date' in ascending order - ], -]); -``` - -#### latest(), oldest() and inRandomOrder() - -Shortcuts: `latest()` orders by `post_date` descending, `oldest()` by `post_date` ascending, and `inRandomOrder()` sets `orderby` to `rand`. - -```php -PostQuery::select()->latest()->get(); -``` - -### Date - -When `date()` is called without a column, none is sent and WP uses the default of the queried table (`post_date` for posts, `user_registered` for users). Use `created()`, `modified()` or `registered()` to be explicit. - -The `PostQuery` class allows you to query posts based on date-related criteria using the `DateQuery` class. This class provides methods to create complex date queries, allowing you to filter posts by their publication, modification, or custom dates. - -#### `dateQuery($callback)` - -The `dateQuery($callback)` method allows you to define a complex date query using the `DateQuery` class. You can pass a callback function to create the date query conditions. For example: - -```php -PostQuery::select()->dateQuery(function (DateQuery $query) { - $query - ->where( - $query->date('edited_at')->before('2022-01-01')->after([ - 'year' => '2021', - 'month' => '01', - 'day' => '01', - ]) - ) - ->inclusive(); -})->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'date_query' => [ - 'relation' => 'AND', - [ - 'column' => 'edited_at', - 'before' => [ - 'year' => 2022, - 'month' => 1, - 'day' => 1, - 'hour' => 12, - 'minute' => 0, - 'second' => 0, - ], - 'after' => [ - 'year' => 2021, - 'month' => 1, - 'day' => 1, - ], - ], - 'inclusive' => true, - ], -]); -``` - -You can use this method to create complex date queries that include conditions like before, after, and inclusive/exclusive settings. - -#### `date($column = 'post_date')` - -The `date($column = 'post_date')` method within the `DateQuery` class allows you to specify the column or type of date to query. You can choose from 'post_date,' 'post_modified,' or other custom date columns. For example: - -```php -$query->date('edited_at'); -``` - -#### `before($date)` - -The `before($date)` method within the `DateQuery` class allows you to specify that the date should be before a certain point in time. You can provide the date in various formats, such as 'YYYY-MM-DD' or an array specifying 'year,' 'month,' 'day,' 'hour,' 'minute,' and 'second.' - -#### `after($date)` - -The `after($date)` method within the `DateQuery` class allows you to specify that the date should be after a certain point in time. Like the `before()` method, you can provide the date in various formats. - -#### `between($start, $end)` - -The `between($start, $end)` method within the `DateQuery` class allows you to specify that the date should be between two points in time. You can provide the start and end dates in various formats. - -#### `created()` and `modified()` - -The `created()` method within the `DateQuery` class makes the date query apply to the publication date of the post (`post_date`, the default column). The `modified()` method makes it apply to the last modified date (`post_modified`). This is useful for distinguishing between post creation and modification dates. - -#### `inclusive()` - -The `inclusive()` method within the `DateQuery` class allows you to specify that the date query should include posts that match the exact date and time specified. This is particularly useful when using the `before()` and `after()` methods. - -### Meta Query - -The `PostQuery` class allows you to perform custom queries based on post metadata using the `MetaQuery` class. This class provides methods to create complex meta queries, allowing you to filter posts based on custom field values. - -#### `metaQuery($callback)` - -The `metaQuery($callback)` method allows you to define a complex meta query using the `MetaQuery` class. You can pass a callback function to create the meta query conditions. For example: - -```php -PostQuery::select()->metaQuery(function (MetaQuery $query) { - $query->where( - $query->meta('status')->equalTo('active') - ); -})->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'meta_query' => [ - 'relation' => 'AND', - [ - 'key' => 'status', - 'compare' => '=', - 'type' => 'CHAR', - 'value' => 'active', - ], - ], -]); -``` - -#### meta($key) - -The `meta($key)` method within the `MetaQuery` class allows you to specify the custom field key you want to query. For example: - -#### Comparison Methods - -The `MetaQuery` class offers several methods to specify the comparison operations to perform on custom fields. You can chain these methods to build complex conditions. - -##### ofType($type) - -This method allows you to specify the type of comparison to use. Possible types are class constants such as `MetaQueryBuilder::NUMERIC`, `MetaQueryBuilder::CHAR`, `MetaQueryBuilder::DATE`, etc. By default, the type is automatically determined based on the value provided during comparison. - -Example of usage: - -```php -$query->meta('my_post_meta')->ofType('numeric'); // You can also use a number -``` - -###### Note : - -The `detectValueType` method is used internally to automatically determine the data type of the value provided during comparison. It is called automatically when you use other comparison methods. - -This method performs the following checks to determine the data type: - -- If the value is an array, it takes the data type of the first element. -- If a data type is explicitly set using the `ofType` method, it uses that data type. -- If the value is `null`, it defaults to the `CHAR` data type. -- If the value is `0` or `1`, it uses the `BINARY` data type. -- If the value is a negative integer, it uses the `SIGNED` data type. -- If the value is a non-negative integer, it uses the `UNSIGNED` data type. -- If the value is a floating-point number, it uses the `DECIMAL` data type. -- If the value is a string containing only a numeric date (e.g., '2022-01-01'), it uses the `DATE` data type. -- If the value is a string containing only a numeric time (e.g., '12:00:00'), it uses the `TIME` data type. -- If the value is a string containing both date and time (e.g., '2022-01-01 12:00:00'), it uses the `DATETIME` data type. -- If none of the above conditions apply, it defaults to the `CHAR` data type. - -##### equalTo($value) - -This method specifies that the custom field must be equal to a certain value. - -Example of usage: - -```php -$query->meta('my_post_meta')->equalTo('active'); // You can also use a number -``` - -##### notEqualTo(mixed $value) - -This method specifies that the custom field must not be equal to a certain value. - -Example of usage: - -```php -$query->meta('my_post_meta')->notEqualTo('inactive'); -``` - -##### greaterThan($value) - -This method specifies that the custom field must be greater than a certain value. - -Example of usage: - -```php -$query->meta('my_post_meta')->greaterThan(100); -``` - -##### greaterOrEqualTo($value) - -This method specifies that the custom field must be greater than or equal to a certain value. - -Example of usage: - -```php -$query->meta('my_post_meta')->greaterOrEqualTo(100); -``` - -##### lessThan($value) - -This method specifies that the custom field must be less than a certain value. - -Example of usage: - -```php -$query->meta('my_post_meta')->lessThan(5); -``` - -##### lessOrEqualTo($value) - -This method specifies that the custom field must be less than or equal to a certain value. - -Example of usage: - -```php -$query->meta('my_post_meta')->lessOrEqualTo(5); -``` - -##### `between(mixed $lowerBoundary, mixed $upperBoundary): self` - -This method specifies that the custom field must be between two given values. - -Example of usage: - -```php -$query->meta('my_post_meta')->between('2022-01-01', '2022-12-31'); -``` - -##### notBetween($lowerBoundary, $upperBoundary) - -This method specifies that the custom field must not be between two given values. - -Example of usage: - -```php -$query->meta('my_post_meta')->notBetween('2022-01-01', '2022-12-31'); -``` - -##### `like(string $value): self` - -This method specifies a partial match of the custom field with a string. - -Example of usage: - -```php -$query->meta('my_post_meta')->like('keyword'); -``` - -##### notLike($value) - -This method specifies that the custom field must not partially match a string. - -Example of usage: - -```php -$query->meta('my_post_meta')->notLike('excluded'); -``` - -##### in($values) - -This method specifies that the custom field must match one of the values in a given array. - -Example of usage: - -```php -$query->meta('my_post_meta')->in(['value1', 'value2', 'value3']); -``` - -##### notIn($values) - -This method specifies that the custom field must not match any of the values in a given array. - -Example of usage: - -```php -$query->meta('my_post_meta')->notIn(['excluded1', 'excluded2']); -``` - -##### state($value) - -The `state($value)` method within the `MetaQuery` class allows you to use the named meta queries and use this state for ordering. See [this section of the documentation](https://developer.wordpress.org/reference/classes/wp_query/#order-orderby-parameters) for more information. - -Example of usage: - -```php -PostQuery::select() - ->metaQuery(function (MetaQuery $query) { - $query - ->where( - $query - ->meta('menu_order') - ->state('menu_order_state') - ->greaterThan(10) - ); - - }) - ->orderBy('menu_order_state', 'ASC') - ->get(); -``` - -##### exists() - -This method specifies that the custom field must exist (be defined). - -Example of usage: - -```php -$query->meta('my_post_meta')->exists(); -``` - -##### notExists() - -This method specifies that the custom field must not exist (not be defined). - -Example of usage: - -```php -$query->meta('my_post_meta')->notExists(); -``` - -#### `metaQuery` Method Chaining - -You can chain multiple `meta()` methods to create complex meta queries. For example: - -```php -PostQuery::select()->metaQuery(function (MetaQuery $query) { - $query - ->where( - $query->meta('status')->equalTo('active') - ) - ->orWhere( - $query->meta('highlighted')->equalTo(1) - ); - -})->get(); -``` - -This generates a meta query with an 'OR' relation between the two conditions. - -#### Nested Meta Queries - -You can create nested meta queries by using the `MetaQuery` class within the `metaQuery()` callback. This allows you to create complex meta queries with multiple levels of conditions. For example: - -```php -PostQuery::select() - ->metaQuery(function (MetaQuery $query) { - $query - ->where( - $query->meta('status')->equalTo('active') - ) - ->orWhere(function (MetaQuery $query) { - $query - ->where( - $query->meta('_sku')->equalTo('H123456789') - ) - ->orWhere( - $query->meta('_sku')->equalTo('D123456784') - ); - }) - ->orWhere(function (MetaQuery $query) { - $query - ->where( - $query->meta('promo_cat')->notIn(['summer', 'blackfriday'])->state('promo_cat') - ) - ->andWhere( - $query->meta('promo_cat')->notEqualTo('sales') - ); - }) - ->orWhere( - $query->meta('sale_date') - ->between('2024-01-01', '2024-12-31') - ); - - }) - ->orderBy('promo_cat') - ->get(); -``` - -### Permission - -The `PostQuery` class allows you to query posts based on specific user permissions. You can use the following permission parameters to filter posts based on their accessibility to users. - -#### `userPermission(string $permission)` - -The `userPermission` method is used to filter posts based on user permissions. It accepts one parameter: - -- `$permission` (string): Specifies the type of permission to filter posts by. Valid values include: - - - `'readable'`: This permission filters posts that the user can read. - - `'editable'`: This permission filters posts that the user can edit. - -##### Filter posts that the user can read. - -```php -PostQuery::select() - ->userPermission('readable') - ->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'perm' => 'readable', - 'post_type' => 'any', -]); -``` - -##### Filter posts that the user can edit. - -```php -PostQuery::select() - ->userPermission('editable') - ->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'perm' => 'editable', - 'post_type' => 'any', -]); -``` - -##### Invalid permissions - -```php -PostQuery::select() - ->userPermission('invalid') - ->get(); -``` - -When an invalid permission is provided, all posts are returned in the generated WP_Query: - -```php -new WP_Query([ - 'post_type' => 'any', -]); -``` - -### Mimetype - -The `PostQuery` class allows you to filter posts based on their MIME types. You can use the `postMimeType` method to specify the MIME type(s) you want to filter by. - -#### `postMimeType(string|array $mimeTypes)` - -The `postMimeType` method filters posts based on MIME type(s). It accepts one parameter: - -- `$mimeTypes` (string|array): Specifies the MIME type(s) to filter posts by. You can provide a single MIME type as a string or an array of multiple MIME types. - -##### Filter posts by a single MIME type (e.g., 'image/gif'). - -```php -PostQuery::select() - ->postMimeType('image/gif') - ->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_mime_type' => 'image/gif', - 'post_type' => 'any', -]); -``` - -##### Filter posts by an array of MIME types. - -```php -PostQuery::select() - ->postMimeType(['image/jpeg', 'image/gif', 'image/png', 'image/bmp', 'image/tiff', 'image/x-icon']) - ->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'post_mime_type' => ['image/jpeg', 'image/gif', 'image/png', 'image/bmp', 'image/tiff', 'image/x-icon'], - 'post_type' => 'any', -]); -``` - -### Cache - -The `PostQuery` class provides methods for controlling caching behavior when querying posts. - -#### cacheResults() - -The `cacheResults` method enables caching of query results. When you use this method, the query results will be cached for faster retrieval. It doesn't accept any parameters. - -```php -PostQuery::select() - ->cacheResults() - ->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'cache_results' => true, - 'post_type' => 'any', -]); -``` - -#### updateMetaCache($update) - -The `updateMetaCache` method allows you to control whether post meta data is cached. You can specify whether to update the post meta cache or not using this method. - -- `$update` (bool): Pass `true` to update the post meta cache or `false` to disable it. - -```php -PostQuery::select() - ->updateMetaCache(false) - ->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'update_post_meta_cache' => false, - 'post_type' => 'any', -]); -``` - -#### updateTermCache($update) - -The `updateTermCache` method allows you to control whether post term data is cached. You can specify whether to update the post term cache or not using this method. - -- `$update` (bool): Pass `true` to update the post term cache or `false` to disable it. - -```php -PostQuery::select() - ->updateTermCache(true) - ->get(); -``` - -This method generates the following WP_Query argument: - -```php -new WP_Query([ - 'update_post_term_cache' => true, - 'post_type' => 'any', -]); -``` - -### Retrieving results - -`get()` returns the `WP_Query`. For the common cases, terminal methods run the query with the arguments that fit their purpose: - -```php -PostQuery::select()->postType('page')->first(); // first post or null (posts_per_page 1, no_found_rows) -PostQuery::select()->postType('page')->limit(20)->ids(); // array of IDs (fields ids, no_found_rows) -PostQuery::select()->postType('page')->count(); // total matches, whatever the limit or page -PostQuery::select()->postType('page')->exists(); // bool -``` - -They work on a copy, so the builder can be reused afterwards. `first()`, `count()` and `exists()` reset `nopaging`, `offset`, `paged` and `page`; `ids()` honours them. As with `get()`, WP's own `posts_per_page` default applies when no limit is set, so call `limit()` or `noPaging()` if you need every ID. - -### Additional parameters - -```php -PostQuery::select() - ->pageId(7)->pagename('parent/child')->title('Hello')->menuOrder(3) // page_id, pagename, title, menu_order - ->year(2024)->month(3)->day(5)->hour(14)->minute(30)->second(0) // year, monthnum, day, hour, minute, second - ->yearMonth(202403)->week(12) // m, w - ->exactSearch()->searchSentence()->searchColumns(['post_title']) // exact, sentence, search_columns - ->metaKey('color')->metaValue('red')->metaCompare('!=')->metaType('CHAR') // top-level meta_* parameters - ->orderByMeta('price', 'DESC', numeric: true) // meta_key + orderby meta_value_num - ->noFoundRows()->suppressFilters()->lazyLoadTermMeta(false) // no_found_rows, suppress_filters, lazy_load_term_meta - ->get(); -``` - -`metaCompareKey()` and `metaTypeKey()` map to `meta_compare_key` and `meta_type_key`. As in WP, `orderByMeta()` also restricts the results to posts that have the key. - -Inside a meta query, `regexp()`, `notRegexp()` and `rlike()` add the matching operators, and `keyCompare('LIKE')` / `keyType('CHAR')` compare the meta key itself (`compare_key`, `type_key`). For taxonomies, `containsAll([1, 2])` requires every term (`operator => 'AND'`). - -## TermQuery - -`TermQuery` builds `WP_Term_Query` arguments the same way `PostQuery` does for posts. - -```php -use Pollora\Query\TermQuery; - -TermQuery::select() - ->taxonomy('category') - ->rootOnly() // parent => 0 - ->search('news') - ->orderByCount() // orderby count, order DESC - ->limit(10) - ->get(); // WP_Term_Query -``` - -- **Selection**: `taxonomy()`, `objectIds()`, `whereIn()` (`include`), `whereNotIn()` (`exclude`), `excludeTree()`, `name()`, `slug()`, `termTaxonomyId()`, `search()`, `nameLike()`, `descriptionLike()`. `TermQuery::find(5)` or `find([1, 2])` is `include`. -- **Hierarchy**: `childOf()`, `parentId()`, `rootOnly()`, `childless()`, `hierarchical()`, `padCounts()`, `unrestricted()` (`get => all`). -- **Order**: `orderBy($field, $direction)`, `orderByName()`, `orderByCount()`, `orderByInclude()`, `orderBySlugIn()`, `withoutOrder()`, `orderByMeta()`. `WP_Term_Query` sorts by one field only, so there is no array form as in `PostQuery`. -- **Limit**: `limit()` / `take()` (`number`), `offset()` / `skip()`. -- **Fields**: `select('ids')` or `fields()` accept `all`, `all_with_object_id`, `ids`, `tt_ids`, `names`, `slugs`, `count`, `id=>parent`, `id=>name`, `id=>slug`; anything else throws a `QueryException`. -- **Meta**: `metaKey()`, `metaValue()`, `metaCompare()`, `metaType()` (+ `_key` variants) and `metaQuery()` work as for posts. -- **Cache**: `cacheResults()`, `updateMetaCache()` (`update_term_meta_cache`), `cacheDomain()`. -- **Results**: `get()` returns the `WP_Term_Query`; `first()`, `ids()`, `names()`, `slugs()`, `count()` and `exists()` run it on a copy of the builder. - -> WP applies a few overrides that the builder does not hide: `unrestricted()` ignores `childOf()`, `childless()`, `hideEmpty()`, `hierarchical()` and `padCounts()`; a non-zero `parentId()` ignores `childOf()`; without `taxonomy()` every taxonomy is queried. - -> **Empty terms are returned by default** (`hide_empty => false`), whereas WP hides them. As with `post_type => 'any'` for posts, no constraint is applied unless you ask for one: call `hideEmpty()` to get WP's behaviour. - -## UserQuery - -`UserQuery` builds `WP_User_Query` arguments. Like `PostQuery`, it applies no constraint unless you ask for one (WP returns every user when `number` is not set). - -```php -use Pollora\Query\UserQuery; - -UserQuery::select() - ->roleIn(['editor', 'author']) - ->search('*bob*')->searchColumns(['user_login', 'display_name']) - ->latest() // registered last first - ->limit(20)->paged(2) - ->get(); // WP_User_Query -``` - -- **Roles and capabilities**: `role()` (every role), `roleIn()`, `roleNotIn()`, `capability()`, `capabilityIn()`, `capabilityNotIn()`, `blogId()`. Capabilities only work when stored in the database. -- **Identity**: `whereIn()` (`include`), `whereNotIn()` (`exclude`), `login()`, `loginIn()`, `loginNotIn()`, `nicename()`, `nicenameIn()`, `nicenameNotIn()`. `UserQuery::find(5)` or `find([1, 2])` is `include`. -- **Search**: `search()` (a leading/trailing `*` is a wildcard) and `searchColumns()`, which only accepts `ID`, `user_login`, `user_email`, `user_url`, `user_nicename` and `display_name`. -- **Authors**: `hasPublishedPosts()` (every public post type) or `hasPublishedPosts(['post', 'page'])`. -- **Order**: `orderBy($field, $direction)` can be chained, as `orderby` accepts a map of fields and directions. Shortcuts: `orderByName()`, `orderByLogin()`, `orderByEmail()`, `orderByPostCount()`, `orderByInclude()`, `orderByLoginIn()`, `orderByNicenameIn()`, `latest()`, `oldest()`, `orderByMeta()`. The top-level `order` parameter is not needed. -- **Limit**: `limit()` / `take()` (`number`), `offset()` / `skip()`, `paged()`. -- **Fields**: `select('ID')`, `select(['ID', 'user_email'])` or `fields()`; `all`, `ID`, `display_name`, `user_login`, `user_nicename`, `user_email`, `user_url`, `user_registered`, `user_pass`, `user_activation_key`, `user_status`, `spam`, `deleted`. Anything else throws a `QueryException`. -- **Meta and dates**: `metaKey()`, `metaValue()`, `metaCompare()`, `metaType()`, `metaQuery()` as for posts; `dateQuery()` filters on `user_registered` (use `$q->date()` without a column). -- **Performance**: `cacheResults()`, `countTotal()`, `withoutCount()`. -- **Results**: `get()` returns the `WP_User_Query`; `first()`, `ids()`, `pluck('user_email')`, `count()` and `exists()` run it on a copy of the builder. `count()` ignores `limit()`, `offset()` and `paged()`. - -The deprecated `who` parameter is not supported: use `capability()`. - -## CommentQuery - -`CommentQuery` builds `WP_Comment_Query` arguments. `metaQuery()`, `dateQuery()` and the `meta*()` methods are the same ones as on `PostQuery`. - -```php -use Pollora\Query\CommentQuery; - -CommentQuery::select() - ->postId(42) - ->approved() - ->threaded() - ->latest() - ->get(); // WP_Comment_Query -``` - -- **Comments**: `whereIn()` (`comment__in`), `whereNotIn()`, `authorEmail()`, `authorUrl()`, `authorIn()`, `authorNotIn()`, `userId()`, `karma()`, `search()`, `includeUnapproved()`. `CommentQuery::find(5)` or `find([1, 2])` is `comment__in`. -- **Status and type**: `status()`, `approved()` (`approve`), `pending()` (`hold`), `type()`, `typeIn()`, `typeNotIn()`. -- **Replies**: `parentId()`, `rootOnly()`, `parentIn()`, `parentNotIn()`, `hierarchical('threaded' | 'flat' | false)`, `threaded()`, `flat()`. -- **Post**: `postId()`, `postIn()`, `postNotIn()`, `postAuthor()`, `postAuthorIn()`, `postAuthorNotIn()`, `postName()`, `postParent()`, `postStatus()`, `postType()`. -- **Order**: `orderBy($field, $direction)` can be chained (fields and directions are sent as a map); `orderByDate()`, `orderByAuthor()`, `orderByInclude()`, `latest()`, `oldest()`, `withoutOrder()`, `orderByMeta()`. -- **Limit**: `limit()` / `take()` (`number`), `offset()` / `skip()`, `paged()`. -- **Fields**: `select('ids')` or `fields('ids')`; `'all'` (or `'*'`) is WP's default. -- **Cache**: `noFoundRows()`, `updateMetaCache()`, `updatePostCache()`, `cacheDomain()`. -- **Results**: `get()` returns the `WP_Comment_Query`; `first()`, `ids()`, `count()` and `exists()` run it on a copy of the builder. `count()` ignores `limit()`, `offset()` and `paged()`. - -> 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/docs/comment-query.md b/docs/comment-query.md new file mode 100644 index 0000000..6a3b71d --- /dev/null +++ b/docs/comment-query.md @@ -0,0 +1,247 @@ +# CommentQuery + +`CommentQuery` builds the arguments of [`WP_Comment_Query`](https://developer.wordpress.org/reference/classes/wp_comment_query/). + +Read [Concepts](concepts.md) first, and [Getting results](results.md) for the terminal methods. Examples assume: + +```php +use Pollora\Query\CommentQuery; +``` + +- [Defaults](#defaults) +- [Selecting comments](#selecting-comments) +- [Status and type](#status-and-type) +- [Replies](#replies) +- [The post a comment belongs to](#the-post-a-comment-belongs-to) +- [Order](#order) +- [Limit and pagination](#limit-and-pagination) +- [Fields](#fields) +- [Meta and dates](#meta-and-dates) +- [Cache](#cache) +- [Results](#results) + +## Defaults + +```php +CommentQuery::select()->get(); +``` + +`WP_Comment_Query` receives: + +```php +[] +``` + +The builder sends nothing, so WordPress' own defaults apply, and two of them matter: + +- **`status` is `'all'`, which is pending *and* approved comments. Spam and trashed comments are left out.** The builder keeps it that way on purpose: listing spam by default would be unsafe. Ask for them with `status('spam')` or `status('trash')`. +- **`no_found_rows` is `true`**: the total is not counted, so `found_comments` and `max_num_pages` are `0` on the query object. Call `noFoundRows(false)` if you paginate. `count()` is not affected. + +## Selecting comments + +| Method | WordPress argument | Notes | +|---|---|---| +| `CommentQuery::find(5)`, `find([1, 2])` | `comment__in` | | +| `whereIn(array)` | `comment__in` | | +| `whereNotIn(array)` | `comment__not_in` | | +| `authorEmail(string)` | `author_email` | | +| `authorUrl(string)` | `author_url` | | +| `authorIn(array)` / `authorNotIn(array)` | `author__in` / `author__not_in` | IDs of the registered users who wrote the comments. | +| `userId(int)` | `user_id` | | +| `karma(int)` | `karma` | | +| `search(string)` | `search` | The keyword in the author, email, URL, IP address or content (contains). | +| `includeUnapproved(array)` | `include_unapproved` | IDs or emails of users whose pending comments are returned **whatever the status**. | + +An empty `whereIn([])` is **not** "no result": WordPress ignores an empty `comment__in`, so every comment matches. Guard an empty list yourself. + +## Status and type + +| Method | WordPress argument | Notes | +|---|---|---| +| `status(string\|array)` | `status` | `'hold'`, `'approve'`, `'all'`, `'spam'`, `'trash'` or a custom status. | +| `approved()` | `status => 'approve'` | | +| `pending()` | `status => 'hold'` | Awaiting moderation. | +| `type(string\|array)` | `type` | `'comment'`, `'pings'` (pingbacks and trackbacks) or any custom type. | +| `typeIn(array)` / `typeNotIn(array)` | `type__in` / `type__not_in` | | + +```php +CommentQuery::select()->status(['approve', 'hold'])->typeNotIn(['pingback', 'trackback'])->get(); +``` + +`WP_Comment_Query` receives: + +```php +[ + 'status' => [ + 'approve', + 'hold', + ], + 'type__not_in' => [ + 'pingback', + 'trackback', + ], +] +``` + +## Replies + +| Method | WordPress argument | Notes | +|---|---|---| +| `rootOnly()` | `parent => 0` | Top-level comments. | +| `parentId(int)` | `parent` | The direct replies to a comment. | +| `parentIn(array)` / `parentNotIn(array)` | `parent__in` / `parent__not_in` | | +| `hierarchical('threaded' \| 'flat' \| false)` | `hierarchical` | Include the descendants of the matches. `'threaded'` nests them in a `children` property, `'flat'` appends them to the list. Any other value throws a `QueryException`. | +| `threaded()` | `hierarchical => 'threaded'` | | +| `flat()` | `hierarchical => 'flat'` | | + +WordPress ignores `hierarchical` when only IDs or a count are asked. + +```php +CommentQuery::select()->postId(42)->approved()->rootOnly()->threaded()->get(); +``` + +`WP_Comment_Query` receives: + +```php +[ + 'parent' => 0, + 'status' => 'approve', + 'hierarchical' => 'threaded', + 'post_id' => 42, +] +``` + +## The post a comment belongs to + +| Method | WordPress argument | +|---|---| +| `postId(int)` | `post_id` | +| `postIn(array)` / `postNotIn(array)` | `post__in` / `post__not_in` | +| `postAuthor(int)` | `post_author` | +| `postAuthorIn(array)` / `postAuthorNotIn(array)` | `post_author__in` / `post_author__not_in` | +| `postName(string)` | `post_name` | +| `postParent(int)` | `post_parent` | +| `postStatus(string\|array)` | `post_status` (`'any'` is accepted) | +| `postType(string\|array)` | `post_type` (`'any'` is accepted) | + +```php +CommentQuery::select()->postType('product')->postStatus('publish')->type('review')->get(); +``` + +`WP_Comment_Query` receives: + +```php +[ + 'type' => 'review', + 'post_status' => 'publish', + 'post_type' => 'product', +] +``` + +## Order + +`orderBy($field, $direction = 'ASC')` adds a sort key, in call order; it also takes a map of fields and directions. Each field has its own direction. WordPress' default (no `orderBy()`) is the newest first (`comment_date_gmt DESC`). + +| Method | Sorts by | +|---|---| +| `orderBy($field, $direction)` | `comment_date`, `comment_date_gmt`, `comment_author`, `comment_author_email`, `comment_content`, `comment_karma`, `comment_parent`, `comment_post_ID`, `comment_type`, `user_id`, `comment_ID`, `meta_value`, `meta_value_num`, a meta key or a meta clause name… | +| `orderByDate($direction = 'DESC')` | `comment_date_gmt` | +| `orderByAuthor($direction = 'ASC')` | `comment_author` | +| `orderByInclude()` | `comment__in`: keep the order of `whereIn()` | +| `latest()` | `comment_date_gmt` descending | +| `oldest()` | `comment_date_gmt` ascending | +| `orderByMeta($key, $direction, $numeric)` | `meta_value` or `meta_value_num`, and sets `meta_key` | +| `withoutOrder()` | no `ORDER BY` (`'none'`) | + +An invalid direction throws a `QueryException`. After `withoutOrder()`, any `orderBy()` starts a new sort. + +```php +CommentQuery::select()->postId(42)->oldest()->get(); +``` + +`WP_Comment_Query` receives: + +```php +[ + 'orderby' => [ + 'comment_date_gmt' => 'ASC', + ], + 'post_id' => 42, +] +``` + +## Limit and pagination + +| Method | WordPress argument | +|---|---| +| `limit(int)`, `take(int)` | `number` | +| `offset(int)`, `skip(int)` | `offset` (applied only with a limit) | +| `paged(int)` | `paged` | + +## Fields + +`fields('ids')` (or `CommentQuery::select('ids')`) returns comment IDs; `'all'` or `'*'` returns whole `WP_Comment` objects, which is the default. Anything else throws a `QueryException`. + +## Meta and dates + +`metaKey()`, `metaValue()`, `metaQuery()`… work as for posts: see [Meta queries](meta-query.md). `dateQuery()` filters on the comment date (`comment_date` unless you name a column): see [Date queries](date-query.md). + +```php +CommentQuery::select() + ->metaQuery(function (MetaQuery $query) { + $query->where($query->meta('rating')->ofType('NUMERIC')->greaterOrEqualTo(4)); + }) + ->dateQuery(function (DateQuery $query) { + $query->where($query->date()->after('2024-01-01')); + }) + ->get(); +``` + +`WP_Comment_Query` receives: + +```php +[ + 'date_query' => [ + 'relation' => 'AND', + [ + 'after' => [ + 'year' => 2024, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + ], + ], + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'rating', + 'compare' => '>=', + 'type' => 'NUMERIC', + 'value' => 4, + ], + ], +] +``` + +## Cache + +| Method | WordPress argument | Notes | +|---|---|---| +| `noFoundRows(bool = true)` | `no_found_rows` | `noFoundRows(false)` counts the total (see [Defaults](#defaults)). | +| `updateMetaCache(bool = true)` | `update_comment_meta_cache` | | +| `updatePostCache(bool = true)` | `update_comment_post_cache` | Primes the cache of the posts the comments belong to. | +| `cacheDomain(string)` | `cache_domain` | | + +## Results + +`get()` returns the `WP_Comment_Query` (the comments are in `->comments`). The [terminal methods](results.md) are: + +```php +$comment = CommentQuery::select()->postId(42)->latest()->first(); // WP_Comment|null +$ids = CommentQuery::select()->postId(42)->approved()->ids(); +$pending = CommentQuery::select()->pending()->count(); +$has = CommentQuery::select()->authorEmail('me@example.com')->exists(); +``` diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..3d3971e --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,391 @@ +# Concepts + +This page explains how the builders work. It applies to every query class: [`PostQuery`](post-query.md), [`TermQuery`](term-query.md), [`UserQuery`](user-query.md), [`CommentQuery`](comment-query.md), [`SiteQuery` and `NetworkQuery`](site-network-query.md). + +- [A builder collects arguments](#a-builder-collects-arguments) +- [Getting the result](#getting-the-result) +- [A common vocabulary](#a-common-vocabulary) +- [Defaults](#defaults) +- [Errors](#errors) +- [Sub-queries: `where()` and `orWhere()`](#sub-queries-where-and-orwhere) +- [Arguments the builders do not cover](#arguments-the-builders-do-not-cover) +- [Reusing a builder](#reusing-a-builder) +- [Adding a parameter](#adding-a-parameter) + +## A builder collects arguments + +Every query class is a builder: you start it with `select()` (or `find()`), chain methods, and each method stores one WordPress argument. Nothing runs until you ask for a result. + +```php +use Pollora\Query\PostQuery; + +$query = PostQuery::select() // new builder + ->postType('page') // stores 'post_type' + ->postParent(5); // stores 'post_parent' +``` + +`select()` and `find()` are the two entry points: + +| Entry point | Meaning | +|---|---| +| `Class::select()` | An empty builder. | +| `Class::select('ids')` | An empty builder that returns [only IDs](results.md#fields) (the accepted values depend on the class). | +| `Class::find(5)` | A builder restricted to one ID (`p` for posts, `include` for terms and users, `comment__in`, `site__in`, `network__in`). | +| `Class::find([1, 2, 3])` | A builder restricted to several IDs. | + +## Getting the result + +There are three ways to leave the builder: + +| You call | You get | +|---|---| +| `getArguments()` | The plain array of arguments. Nothing is queried: use it to inspect, log, cache or pass to another API. | +| `get()` | The `WP_Query` (or `WP_Term_Query`, `WP_User_Query`…) object, built with those arguments. | +| `first()`, `ids()`, `count()`, `exists()`… | Directly the thing you wanted. See [Getting results](results.md). | + +`get()` is `new WP_Query($query->getArguments())`, nothing more: + +```php +PostQuery::select()->postType('page')->postParent(5)->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_parent' => 5, + 'post_type' => 'page', +] +``` + +Every example in this documentation shows the arguments that `getArguments()` returns for the code above it. The keys `fields` and `post_type` come from the [defaults](#defaults). + +## A common vocabulary + +The same idea has the same method name in every class, whatever the WordPress argument is called. + +| Idea | Methods | Examples of WordPress arguments | +|---|---|---| +| Restrict to IDs of the queried object | `whereIn([...])`, `whereNotIn([...])` | `post__in`, `include`, `comment__in`, `site__in`, `network__in` | +| Restrict by another list | `xxxIn([...])`, `xxxNotIn([...])` | `author__in`, `role__in`, `category__not_in`, `domain__in` | +| Number of results | `limit(n)` or `take(n)` | `posts_per_page`, `number` | +| Skip results | `offset(n)` or `skip(n)` | `offset` | +| Page of results | `paged(n)` | `paged` | +| Sorting | `orderBy($field, $direction)`, `orderByXxx()`, `latest()`, `oldest()` | `orderby`, `order` | +| Type of data returned | `fields('ids')` | `fields` | +| Skip the total count | `noFoundRows()` | `no_found_rows` | +| Cache | `cacheResults()`, `updateMetaCache()` | `cache_results`, `update_*_meta_cache` | +| Meta | `metaKey()`, `metaValue()`, `metaQuery()` | `meta_key`, `meta_value`, `meta_query` | +| Dates | `dateQuery()` | `date_query` | + +A `Not` or `NotIn` suffix excludes, an `In` suffix is a list. Boolean switches default to `true` when called without an argument: `noFoundRows()` is `noFoundRows(true)`. + +## Defaults + +A builder sends **only what you asked for**, plus a few defaults chosen so that an unfiltered query returns everything and nothing is silently cut. Each class documents its own; here they are together. + +| Class | What the builder sends by default | WordPress' own default | Why | +|---|---|---|---| +| `PostQuery` | `post_type => 'any'`, `fields => 'all'` | `post_type => 'post'` | A query without a type should not hide pages and custom post types. `find(5)` finds a page too. | +| `TermQuery` | `hide_empty => false`, `fields => 'all'` | `hide_empty => true` | A term that has no post yet is not "missing". Call `hideEmpty()` for WordPress' behaviour. | +| `UserQuery` | `fields => 'all'` | no limit | Same as WordPress. | +| `CommentQuery` | nothing | `status => 'all'` (pending and approved: **no spam, no trash**), `no_found_rows => true` | Kept as is on purpose: listing spam by default would be unsafe. Ask for it with `status('spam')`. | +| `SiteQuery` | `number => 0` | `number => 100` | WordPress silently stops at 100 sites. Call `limit()` for a cap. | +| `NetworkQuery` | nothing | no limit | Same as WordPress. | + +## Errors + +A value that WordPress would silently ignore throws a `Pollora\Query\QueryException` instead, so a typo does not turn into an unfiltered query. + +```php +PostQuery::select()->userPermission('writable'); +// QueryException: Invalid permission supplied: writable +``` + +```php +UserQuery::select()->searchColumns(['user_pass']); +// QueryException: Invalid search column supplied: user_pass +``` + +```php +TermQuery::select()->orderBy('name', 'sideways'); +// QueryException: Invalid order direction supplied: SIDEWAYS +``` + +The checks cover `fields()` values, order directions, `userPermission()`, search columns, the field of a tax clause, the `hierarchical()` mode of comments and the keys of a date array. Free-form values (a meta key, a meta type, a post status) are not checked: WordPress and your database own them. + +## Sub-queries: `where()` and `orWhere()` + +`taxQuery()`, `metaQuery()` and `dateQuery()` take a closure. It receives a builder on which you add clauses with `where()` (or `andWhere()`, its alias) and `orWhere()`, and the `relation` keys are written for you. + +```php +PostQuery::select() + ->metaQuery(function (MetaQuery $query) { + $query + ->where($query->meta('status')->equalTo('active')) + ->orWhere($query->meta('featured')->equalTo('yes')); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'OR', + [ + 'key' => 'status', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'active', + ], + [ + 'key' => 'featured', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'yes', + ], + ], + 'post_type' => 'any', +] +``` + +### Mixing `where()` and `orWhere()` + +WordPress allows a single `relation` per level. When you switch relation on a level that already holds several clauses, they are grouped first, left to right, **as Eloquent does**: `where(a)->where(b)->orWhere(c)` is `(a AND b) OR c`. + +```php +PostQuery::select() + ->metaQuery(function (MetaQuery $query) { + $query + ->where($query->meta('status')->equalTo('active')) + ->where($query->meta('stock')->greaterThan(10)) + ->orWhere($query->meta('featured')->equalTo('yes')); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'OR', + [ + 'relation' => 'AND', + [ + 'key' => 'status', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'active', + ], + [ + 'key' => 'stock', + 'compare' => '>', + 'type' => 'UNSIGNED', + 'value' => 10, + ], + ], + [ + 'key' => 'featured', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'yes', + ], + ], + 'post_type' => 'any', +] +``` + +Reading the output: `relation` is `OR`, its first clause is the group `status AND stock`, and the second is `featured`. + +With a single clause, the relation just changes: `orWhere(a)->where(b)` is `a AND b`. + +### Nested groups + +Pass a closure to `where()` or `orWhere()` to group conditions: + +```php +PostQuery::select() + ->metaQuery(function (MetaQuery $query) { + $query + ->where($query->meta('status')->equalTo('active')) + ->where(function (MetaQuery $group) { + $group + ->where($group->meta('stock')->greaterThan(10)) + ->orWhere($group->meta('backorder')->equalTo('yes')); + }); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'status', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'active', + ], + [ + 'relation' => 'OR', + [ + 'key' => 'stock', + 'compare' => '>', + 'type' => 'UNSIGNED', + 'value' => 10, + ], + [ + 'key' => 'backorder', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'yes', + ], + ], + ], + 'post_type' => 'any', +] +``` + +That reads `status = active AND (stock > 10 OR backorder = yes)`. + +### Calling a sub-query method twice + +Each call adds to the previous ones; clauses are never duplicated. + +```php +PostQuery::select() + ->metaQuery(fn (MetaQuery $query) => $query->where($query->meta('a')->equalTo('1'))) + ->metaQuery(fn (MetaQuery $query) => $query->where($query->meta('b')->equalTo('2'))) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'a', + 'compare' => '=', + 'type' => 'NUMERIC', + 'value' => '1', + ], + [ + 'key' => 'b', + 'compare' => '=', + 'type' => 'NUMERIC', + 'value' => '2', + ], + ], + 'post_type' => 'any', +] +``` + +### A clause without a closure + +Each clause builder can also be passed directly, which is handy for a single condition. `Query::meta()`, `Query::tax()` and `Query::date()` create them without a closure: + +```php +PostQuery::select() + ->metaQuery(Query::meta('status')->equalTo('active')) + ->taxQuery(Query::tax('category')->searchByTermSlug()->contains(['news'])) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'status', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'active', + ], + ], + 'post_type' => 'any', + 'tax_query' => [ + 'relation' => 'AND', + [ + 'taxonomy' => 'category', + 'field' => 'slug', + 'terms' => [ + 'news', + ], + 'operator' => 'IN', + 'include_children' => true, + ], + ], +] +``` + +## Arguments the builders do not cover + +`setRawArgs()` merges a plain array into the arguments, for an argument added by a plugin or a newer WordPress. **Raw arguments win** over the ones set by methods. + +```php +PostQuery::select() + ->postType('page') + ->setRawArgs(['ep_integrate' => true, 'post_type' => 'product']) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'product', + 'ep_integrate' => true, +] +``` + +## Reusing a builder + +Methods that **set** an argument change the builder and return it, so keep a reference only if you mean to build on it: + +```php +$base = PostQuery::select()->postType('product')->postStatus('publish'); + +$base->limit(3); // $base now has a limit, whether or not you use the result +$sameBase = $base->limit(5); // $sameBase and $base are the same object +``` + +To derive several queries from a base, create it with a function (or `clone` it): + +```php +$products = fn () => PostQuery::select()->postType('product')->postStatus('publish'); + +$latest = $products()->latest()->take(3)->get(); +$total = $products()->count(); +``` + +[Terminal methods](results.md) (`first()`, `ids()`, `count()`, `exists()`…) work on an internal copy and leave the builder untouched, so `$query->count()` followed by `$query->get()` is safe. + +## Adding a parameter + +The builders turn property names into WordPress arguments with `Str::snake()`; this is what the [wordpress-args](https://github.com/Pollora/wordpress-args) package does. A parameter is a protected property plus a method that sets it: + +| Property | Argument | +|---|---| +| `$postType` | `post_type` | +| `$noFoundRows` | `no_found_rows` | +| `$category_In` | `category__in` | +| `$tagSlug_NotIn` | `tag_slug__not_in` | + +The underscore before `In` / `NotIn` is what produces the double underscore of the WordPress name. A property left at `null` is not sent. diff --git a/docs/date-query.md b/docs/date-query.md new file mode 100644 index 0000000..b95b27b --- /dev/null +++ b/docs/date-query.md @@ -0,0 +1,287 @@ +# Date queries + +`dateQuery()` filters by one or several date ranges, on any date column, and builds the `date_query` argument. It is available on [`PostQuery`](post-query.md), [`UserQuery`](user-query.md), [`CommentQuery`](comment-query.md) and [`SiteQuery`](site-network-query.md). + +Examples assume: + +```php +use Pollora\Query\DateQuery; +use Pollora\Query\PostQuery; +``` + +- [A first clause](#a-first-clause) +- [Dates you can give](#dates-you-can-give) +- [Ranges: `before()`, `after()`, `between()`](#ranges-before-after-between) +- [An exact period: `within()`](#an-exact-period-within) +- [Which date: columns](#which-date-columns) +- [Including the boundaries: `inclusive()`](#including-the-boundaries-inclusive) +- [Combining clauses](#combining-clauses) + +## A first clause + +`dateQuery()` takes a closure. It receives a `DateQuery` builder: start a clause with `date()`, describe the range, and add it with `where()`. + +```php +PostQuery::select() + ->postType('post') + ->dateQuery(function (DateQuery $query) { + $query->where($query->date()->after('2024-01-01')); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'date_query' => [ + 'relation' => 'AND', + [ + 'after' => [ + 'year' => 2024, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + ], + ], + 'fields' => 'all', + 'post_type' => 'post', +] +``` + +A date string becomes a precise date: year, month, day, hour, minute and second, on a 24-hour clock. A date without a time is midnight. + +## Dates you can give + +| You give | The builder sends | +|---|---| +| `'2024-03-05'`, `'2024-03-05 14:30:00'`, any string `strtotime()` understands (`'last monday'`) | the year, month, day, hour, minute and second of that moment | +| a numeric string, read as a timestamp (`'1709647800'`) | the same, from the timestamp | +| an array `['year' => 2024, 'month' => 3]` | the array, untouched: use it for a partial date | + +A string `strtotime()` cannot read throws a `QueryException`. + +## Ranges: `before()`, `after()`, `between()` + +| Method | Matches | +|---|---| +| `after($date)` | after the date | +| `before($date)` | before the date | +| `between($from, $to)` | after `$from` and before `$to` | + +Both can be set on one clause. With an array, only the parts you give are compared: + +```php +PostQuery::select() + ->dateQuery(function (DateQuery $query) { + $query->where( + $query->date()->after(['year' => 2021, 'month' => 6])->before('2022-01-01') + ); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'date_query' => [ + 'relation' => 'AND', + [ + 'before' => [ + 'year' => 2022, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + 'after' => [ + 'year' => 2021, + 'month' => 6, + ], + ], + ], + 'fields' => 'all', + 'post_type' => 'any', +] +``` + +## An exact period: `within()` + +`within()` matches **a period** rather than a range: pass the parts that define it. + +```php +PostQuery::select() + ->postType('post') + ->dateQuery(fn (DateQuery $query) => $query->where($query->date()->within(['year' => 2024, 'month' => 3]))) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'date_query' => [ + 'relation' => 'AND', + [ + 'year' => 2024, + 'month' => 3, + ], + ], + 'fields' => 'all', + 'post_type' => 'post', +] +``` + +With a date string, `within('2024-03-05')` is that **whole day**, while `within('2024-03-05 14:30:00')` is that exact second. + +An array with a key other than `year`, `month`, `day`, `hour`, `minute` and `second` throws a `QueryException`. + +## Which date: columns + +Without a column, WordPress uses the date of the queried table: `post_date` for posts, `user_registered` for users, `comment_date` for comments, `registered` for sites. Nothing is sent by the builder. + +| Method | Column | +|---|---| +| `date()` | the default of the table | +| `date('post_modified_gmt')` | the column you name; it must be one WordPress accepts for that table | +| `created()` | `post_date` | +| `modified()` | `post_modified` | +| `registered()` | `user_registered` | + +```php +PostQuery::select() + ->postType('post') + ->dateQuery(function (DateQuery $query) { + $query->where($query->date()->modified()->after('2024-01-01')); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'date_query' => [ + 'relation' => 'AND', + [ + 'column' => 'post_modified', + 'after' => [ + 'year' => 2024, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + ], + ], + 'fields' => 'all', + 'post_type' => 'post', +] +``` + +## Including the boundaries: `inclusive()` + +By default `before()` and `after()` exclude the date itself. `inclusive()` includes it. + +- `$query->date()->after('2024-01-01')->inclusive()` applies to **that clause**. +- `$query->inclusive()` applies to **every clause** of the query, including the ones added after it. + +WordPress reads `inclusive` on each clause and does not inherit it from the query root, so the builder writes it on every clause for you. + +```php +PostQuery::select() + ->dateQuery(function (DateQuery $query) { + $query + ->where($query->date()->after('2024-01-01')) + ->orWhere($query->date()->before('2023-01-01')) + ->inclusive(); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'date_query' => [ + 'relation' => 'OR', + [ + 'after' => [ + 'year' => 2024, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + 'inclusive' => true, + ], + [ + 'before' => [ + 'year' => 2023, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + 'inclusive' => true, + ], + ], + 'fields' => 'all', + 'post_type' => 'any', +] +``` + +## Combining clauses + +`where()` and `andWhere()` add a clause with `AND`, `orWhere()` with `OR`, and closures group clauses. See [Concepts](concepts.md#sub-queries-where-and-orwhere). + +```php +UserQuery::select() + ->dateQuery(function (DateQuery $query) { + $query->where($query->date()->between('2024-01-01', '2024-03-31')); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'date_query' => [ + 'relation' => 'AND', + [ + 'before' => [ + 'year' => 2024, + 'month' => 3, + 'day' => 31, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + 'after' => [ + 'year' => 2024, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + ], + ], + 'fields' => 'all', +] +``` + +## Method reference + +On the `DateQuery` builder (the closure argument): `date($column = null)`, `where()`, `andWhere()`, `orWhere()`, `inclusive()`. + +On a clause (`DateQueryBuilder`): `after()`, `before()`, `between()`, `within()`, `created()`, `modified()`, `registered()`, `inclusive()`. `extractFromDate($date)` is the conversion they use (a date string or timestamp to an array of parts); you rarely need it. diff --git a/docs/meta-query.md b/docs/meta-query.md new file mode 100644 index 0000000..ced5129 --- /dev/null +++ b/docs/meta-query.md @@ -0,0 +1,319 @@ +# Meta queries + +`metaQuery()` filters by custom fields (post meta, term meta, user meta, comment meta, site meta), with several conditions combined by `AND` / `OR`. It builds the `meta_query` argument. + +It is available on [`PostQuery`](post-query.md), [`TermQuery`](term-query.md), [`UserQuery`](user-query.md), [`CommentQuery`](comment-query.md) and [`SiteQuery`](site-network-query.md), with the same methods. Examples assume: + +```php +use Pollora\Query\MetaQuery; +use Pollora\Query\PostQuery; +``` + +- [A first clause](#a-first-clause) +- [Comparisons](#comparisons) +- [Value types](#value-types) +- [Combining clauses](#combining-clauses) +- [Named clauses and sorting](#named-clauses-and-sorting) +- [Comparing the key](#comparing-the-key) +- [The simple top-level arguments](#the-simple-top-level-arguments) + +## A first clause + +`metaQuery()` takes a closure. It receives a `MetaQuery` builder: start a clause with `meta($key)`, add a comparison, and add the clause with `where()`. + +```php +PostQuery::select() + ->postType('product') + ->metaQuery(function (MetaQuery $query) { + $query->where($query->meta('status')->equalTo('active')); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'status', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'active', + ], + ], + 'post_type' => 'product', +] +``` + +Always finish a clause with a comparison. `meta('status')` alone has no `compare` and no `value`. + +For a single clause you can skip the closure: `Query::meta('status')->equalTo('active')` returns the same clause builder, and `metaQuery()` accepts it directly (see [Concepts](concepts.md#a-clause-without-a-closure)). + +## Comparisons + +| Method | `compare` | Notes | +|---|---|---| +| `equalTo($value)` | `=` | | +| `notEqualTo($value)` | `!=` | | +| `greaterThan($value)` | `>` | | +| `greaterOrEqualTo($value)` | `>=` | | +| `lessThan($value)` | `<` | | +| `lessOrEqualTo($value)` | `<=` | | +| `between($min, $max)` | `BETWEEN` | The value is `[$min, $max]`. | +| `notBetween($min, $max)` | `NOT BETWEEN` | | +| `in(array)` | `IN` | | +| `notIn(array)` | `NOT IN` | | +| `like(string)` | `LIKE` | **Contains.** WordPress wraps the value in `%…%` itself and escapes any `%` or `_` you give: pass the bare text. | +| `notLike(string)` | `NOT LIKE` | | +| `regexp(string)` | `REGEXP` | | +| `notRegexp(string)` | `NOT REGEXP` | | +| `rlike(string)` | `RLIKE` | | +| `exists()` | `EXISTS` | The key is set, whatever its value. No value is sent. | +| `notExists()` | `NOT EXISTS` | The key is not set. | + +```php +PostQuery::select() + ->postType('product') + ->metaQuery(function (MetaQuery $query) { + $query + ->where($query->meta('price')->between(10, 50)) + ->where($query->meta('color')->in(['red', 'blue'])) + ->where($query->meta('discontinued')->notExists()); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'price', + 'compare' => 'BETWEEN', + 'type' => 'UNSIGNED', + 'value' => [ + 10, + 50, + ], + ], + [ + 'key' => 'color', + 'compare' => 'IN', + 'type' => 'CHAR', + 'value' => [ + 'red', + 'blue', + ], + ], + [ + 'key' => 'discontinued', + 'compare' => 'NOT EXISTS', + 'type' => 'CHAR', + ], + ], + 'post_type' => 'product', +] +``` + +## Value types + +WordPress compares meta values as text unless a `type` is given. The builder **detects the type from the value**, so `greaterThan(10)` compares numbers, not strings. + +| Value | Type sent | +|---|---| +| `0` or `1` (integers) | `BINARY` | +| a negative integer | `SIGNED` | +| any other integer | `UNSIGNED` | +| a float | `DECIMAL` | +| a numeric string (`'42'`) | `NUMERIC` | +| `'2024-03-05'` | `DATE` | +| `'14:30:00'` | `TIME` | +| `'2024-03-05 14:30:00'` | `DATETIME` | +| `null`, any other string, a boolean | `CHAR` | + +For a list (`in()`, `between()`…), one type is chosen so that **every** value fits: the common type when they agree, otherwise the widest numeric one (`DECIMAL`, then `NUMERIC`, then `SIGNED`, then `UNSIGNED`), otherwise `CHAR`. An empty list is `CHAR`. So `between(1, 50)` is `UNSIGNED` and `between(-5, 5)` is `SIGNED`. + +**`BINARY` compares as text.** For `0` and `1` it is fine with `equalTo()`, but if you use `greaterThan(0)` on a numeric field, force the type: + +```php +PostQuery::select() + ->metaQuery(function (MetaQuery $query) { + $query->where($query->meta('stock')->ofType('NUMERIC')->greaterThan(0)); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'stock', + 'compare' => '>', + 'type' => 'NUMERIC', + 'value' => 0, + ], + ], + 'post_type' => 'any', +] +``` + +`ofType($type)` always wins over detection, in any order relative to the comparison. The values WordPress accepts are `NUMERIC`, `BINARY`, `CHAR`, `DATE`, `DATETIME`, `DECIMAL`, `SIGNED`, `TIME` and `UNSIGNED`; the builder does not check them. + +## Combining clauses + +`where()` and `andWhere()` add a clause with `AND`, `orWhere()` with `OR`, and closures group clauses. See [Concepts](concepts.md#sub-queries-where-and-orwhere) for how `where(a)->where(b)->orWhere(c)` is grouped. + +```php +PostQuery::select() + ->postType('product') + ->metaQuery(function (MetaQuery $query) { + $query + ->where($query->meta('status')->equalTo('active')) + ->where(function (MetaQuery $group) { + $group + ->where($group->meta('stock')->ofType('NUMERIC')->greaterThan(0)) + ->orWhere($group->meta('backorder')->equalTo('yes')); + }); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'status', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'active', + ], + [ + 'relation' => 'OR', + [ + 'key' => 'stock', + 'compare' => '>', + 'type' => 'NUMERIC', + 'value' => 0, + ], + [ + 'key' => 'backorder', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'yes', + ], + ], + ], + 'post_type' => 'product', +] +``` + +## Named clauses and sorting + +`state($name)` gives a clause a name. It becomes the key of the clause in the query, and you can then **sort by it** with `orderBy()`, which is how you sort by a value that is also filtered. + +```php +PostQuery::select() + ->postType('event') + ->metaQuery(function (MetaQuery $query) { + $query->where($query->meta('starts_at')->state('start')->greaterOrEqualTo('2024-06-01')); + }) + ->orderBy('start', 'ASC') + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + 'start' => [ + 'key' => 'starts_at', + 'compare' => '>=', + 'type' => 'DATE', + 'value' => '2024-06-01', + ], + ], + 'orderby' => [ + 'start' => 'ASC', + ], + 'post_type' => 'event', +] +``` + +To sort by a custom field without filtering on a value, use `orderByMeta($key)` (or `orderBy('meta_value')` with `metaKey()`), described in [PostQuery](post-query.md#order). + +## Comparing the key + +By default the key must match exactly. `keyCompare()` changes how the key itself is compared, and `keyType()` the type it is cast to. With `LIKE`, as for values, WordPress matches keys that **contain** the text: this finds every field whose key contains `attr_`. + +```php +PostQuery::select() + ->metaQuery(function (MetaQuery $query) { + $query->where($query->meta('attr_')->keyCompare('LIKE')->exists()); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'attr_', + 'compare_key' => 'LIKE', + 'compare' => 'EXISTS', + 'type' => 'CHAR', + ], + ], + 'post_type' => 'any', +] +``` + +WordPress accepts `=`, `!=`, `LIKE`, `NOT LIKE`, `IN`, `NOT IN`, `EXISTS`, `NOT EXISTS`, `REGEXP`, `NOT REGEXP` and `RLIKE` for `keyCompare()`. + +## The simple top-level arguments + +When a single condition is enough, no closure is needed: `metaKey()`, `metaValue()`, `metaCompare()`, `metaType()`, `metaCompareKey()` and `metaTypeKey()` set the `meta_key`, `meta_value`, `meta_compare`, `meta_type`, `meta_compare_key` and `meta_type_key` arguments directly. They are available on the same classes as `metaQuery()`. + +```php +PostQuery::select()->postType('product')->metaKey('color')->metaValue('red')->metaCompare('!=')->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_key' => 'color', + 'meta_value' => 'red', + 'meta_compare' => '!=', + 'post_type' => 'product', +] +``` + +Sorting by a meta field relies on these arguments: `orderByMeta('price', 'ASC', numeric: true)` sets `meta_key` and sorts by `meta_value_num`. + +## Method reference + +On the `MetaQuery` builder (the closure argument): `meta($key)`, `where()`, `andWhere()`, `orWhere()`. + +On a clause (`MetaQueryBuilder`): `equalTo()`, `notEqualTo()`, `greaterThan()`, `greaterOrEqualTo()`, `lessThan()`, `lessOrEqualTo()`, `between()`, `notBetween()`, `in()`, `notIn()`, `like()`, `notLike()`, `regexp()`, `notRegexp()`, `rlike()`, `exists()`, `notExists()`, `ofType()`, `state()`, `keyCompare()`, `keyType()`. diff --git a/docs/post-query.md b/docs/post-query.md new file mode 100644 index 0000000..17bc333 --- /dev/null +++ b/docs/post-query.md @@ -0,0 +1,445 @@ +# PostQuery + +`PostQuery` builds the arguments of [`WP_Query`](https://developer.wordpress.org/reference/classes/wp_query/): posts, pages, attachments and custom post types. + +Read [Concepts](concepts.md) first for how builders, defaults and errors work, and [Getting results](results.md) for `first()`, `ids()`, `count()` and `exists()`. Examples assume: + +```php +use Pollora\Query\PostQuery; +``` + +- [Defaults](#defaults) +- [Selecting posts](#selecting-posts) +- [Post type, status and MIME type](#post-type-status-and-mime-type) +- [Authors](#authors) +- [Categories](#categories) +- [Tags](#tags) +- [Taxonomies](#taxonomies) +- [Search](#search) +- [Password](#password) +- [Comment count](#comment-count) +- [Permission](#permission) +- [Dates](#dates) +- [Custom fields](#custom-fields) +- [Limits and pagination](#limits-and-pagination) +- [Order](#order) +- [Fields](#fields) +- [Cache and performance](#cache-and-performance) + +## Defaults + +```php +PostQuery::select()->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'any', +] +``` + +- `post_type` is `'any'` (WordPress uses `'post'`), so pages and custom post types are included. Call `postType()` to restrict it. `'any'` still leaves out the post types registered with `exclude_from_search`. +- `fields` is `'all'`. +- Everything else is WordPress' default: for instance only `publish` posts (and the private ones the current user can read) are returned, and the number of posts per page is the *Posts per page* setting. + +## Selecting posts + +| Method | WordPress argument | Notes | +|---|---|---| +| `PostQuery::find(5)` | `p` | One post by ID. | +| `PostQuery::find([1, 2, 3])` | `post__in` | Several posts by ID. | +| `postId(int)` | `p` | | +| `postSlug(string)` | `name` | The slug of a post, or of a page. | +| `pageId(int)` | `page_id` | | +| `pagename(string)` | `pagename` | The slug of a page. Hierarchical paths work: `'parent/child'`. | +| `title(string)` | `title` | Exact post title. | +| `menuOrder(int)` | `menu_order` | | +| `postParent(int)` | `post_parent` | Children of a post (`0`: top-level only). | +| `whereParentIn(array)` / `whereParentNotIn(array)` | `post_parent__in` / `post_parent__not_in` | | +| `whereIn(array)` / `whereNotIn(array)` | `post__in` / `post__not_in` | Include / exclude by ID. | +| `slugIn(array)` | `post_name__in` | Several slugs. | + +```php +PostQuery::select()->postType('page')->pagename('about/team')->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'pagename' => 'about/team', + 'post_type' => 'page', +] +``` + +An empty list is **not** "no result": `whereIn([])` sends an empty `post__in`, which WordPress ignores, so every post matches. Guard an empty list yourself before building the query. + +## Post type, status and MIME type + +| Method | WordPress argument | +|---|---| +| `postType(string\|array)` | `post_type` | +| `postStatus(string\|array)` | `post_status` | +| `postMimeType(string\|array)` | `post_mime_type` | + +```php +PostQuery::select() + ->postType(['post', 'event']) + ->postStatus(['publish', 'future']) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => [ + 'post', + 'event', + ], + 'post_status' => [ + 'publish', + 'future', + ], +] +``` + +```php +PostQuery::select()->postType('attachment')->postStatus('inherit')->postMimeType(['image/jpeg', 'image/png'])->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_mime_type' => [ + 'image/jpeg', + 'image/png', + ], + 'post_type' => 'attachment', + 'post_status' => 'inherit', +] +``` + +## Authors + +| Method | WordPress argument | +|---|---| +| `author(int)` | `author` | +| `authorName(string)` | `author_name` (the user's nicename) | +| `authorIn(array)` / `authorNotIn(array)` | `author__in` / `author__not_in` | + +```php +PostQuery::select()->postType('post')->authorIn([1, 4])->get(); +``` + +`WP_Query` receives: + +```php +[ + 'author__in' => [ + 1, + 4, + ], + 'fields' => 'all', + 'post_type' => 'post', +] +``` + +## Categories + +| Method | WordPress argument | Notes | +|---|---|---| +| `cat(int)` | `cat` | | +| `categoryName(string)` | `category_name` | The slug. | +| `categoryAnd(array)` | `category__and` | In **all** these categories. | +| `categoryIn(array)` | `category__in` | In **any** of them. | +| `categoryNotIn(array)` | `category__not_in` | | + +```php +PostQuery::select()->postType('post')->categoryAnd([3, 7])->get(); +``` + +`WP_Query` receives: + +```php +[ + 'category__and' => [ + 3, + 7, + ], + 'fields' => 'all', + 'post_type' => 'post', +] +``` + +## Tags + +| Method | WordPress argument | Notes | +|---|---|---| +| `tag(string)` | `tag` | A slug. | +| `tagId(int)` | `tag_id` | | +| `tagAnd(array)` | `tag__and` | Tag IDs, **all** of them. | +| `tagIn(array)` | `tag__in` | Tag IDs, any of them. | +| `tagNotIn(array)` | `tag__not_in` | | +| `tagSlugAnd(array)` | `tag_slug__and` | Tag slugs, all of them. | +| `tagSlugIn(array)` | `tag_slug__in` | Tag slugs, any of them. | + +## Taxonomies + +`taxQuery()` filters on any taxonomy, with `where()` / `orWhere()` and nesting. It has [its own page](tax-query.md). + +```php +PostQuery::select() + ->postType('product') + ->taxQuery(function (TaxQuery $query) { + $query->where($query->taxonomy('product_cat')->searchByTermSlug()->contains(['shoes'])); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'product', + 'tax_query' => [ + 'relation' => 'AND', + [ + 'taxonomy' => 'product_cat', + 'field' => 'slug', + 'terms' => [ + 'shoes', + ], + 'operator' => 'IN', + 'include_children' => true, + ], + ], +] +``` + +## Search + +| Method | WordPress argument | +|---|---| +| `search(string)` | `s` | +| `exactSearch(bool = true)` | `exact`: match the whole keyword instead of a partial match | +| `searchSentence(bool = true)` | `sentence`: search the keyword as a phrase instead of splitting it into terms | +| `searchColumns(array)` | `search_columns`: among `post_title`, `post_excerpt`, `post_content` | + +```php +PostQuery::select()->search('summer sale')->searchSentence()->searchColumns(['post_title'])->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'any', + 's' => 'summer sale', + 'sentence' => true, + 'search_columns' => [ + 'post_title', + ], +] +``` + +## Password + +| Method | WordPress argument | +|---|---| +| `withPassword()` | `has_password => true`: only password protected posts | +| `withoutPassword()` | `has_password => false`: only posts without a password | +| `withPassword('secret')` | `post_password`: posts protected by that exact password | + +## Comment count + +`commentCount()` takes a number (exactly that many comments) or WordPress' array form: + +```php +PostQuery::select()->commentCount(['value' => 10, 'compare' => '>='])->get(); +``` + +`WP_Query` receives: + +```php +[ + 'comment_count' => [ + 'value' => 10, + 'compare' => '>=', + ], + 'fields' => 'all', + 'post_type' => 'any', +] +``` + +## Permission + +`userPermission('readable' | 'editable')` (`perm`) keeps the posts the current user can read, or edit. Any other value throws a `QueryException`. + +## Dates + +Two ways to filter by date: + +- **Simple fields**, for one exact value: `year()`, `month()` (`monthnum`), `day()`, `hour()`, `minute()`, `second()`, `week()` (`w`, week of the year, 0 to 53) and `yearMonth()` (`m`, as `YYYYMM`). +- **A date query**, for ranges, several conditions and other columns: `dateQuery()`, documented in [Date queries](date-query.md). + +```php +PostQuery::select()->postType('post')->year(2024)->month(3)->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'year' => 2024, + 'monthnum' => 3, + 'post_type' => 'post', +] +``` + +## Custom fields + +Three levels, from simplest to most flexible: + +1. **Top-level arguments**: `metaKey()`, `metaValue()`, `metaCompare()`, `metaType()` and, for the key itself, `metaCompareKey()` and `metaTypeKey()` (`meta_key`, `meta_value`, `meta_compare`, `meta_type`, `meta_compare_key`, `meta_type_key`). +2. **`metaQuery()`**, for several conditions and `OR`: [Meta queries](meta-query.md). +3. **`orderByMeta()`**, to sort by a custom field (see [Order](#order)). + +```php +PostQuery::select()->postType('product')->metaKey('color')->metaValue('red')->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_key' => 'color', + 'meta_value' => 'red', + 'post_type' => 'product', +] +``` + +## Limits and pagination + +| Method | WordPress argument | Notes | +|---|---|---| +| `limit(int)`, `take(int)`, `postsPerPage(int)` | `posts_per_page` | Three names for the same argument. `-1` is "all". | +| `noPaging(bool = true)` | `nopaging` | Return all posts. | +| `offset(int)`, `skip(int)` | `offset` | When set, WordPress ignores `paged`. Use one or the other. | +| `paged(int)` | `paged` | The page of the archive. | +| `page(int)` | `page` | The page of a paginated *static front page* (``). Not the same as `paged()`. | +| `postsPerArchivePage(int)` | `posts_per_archive_page` | Only applies to archive pages. | +| `ignoreStickyPosts(bool = true)` | `ignore_sticky_posts` | Stop putting sticky posts first. | + +```php +PostQuery::select()->postType('post')->take(10)->paged(3)->ignoreStickyPosts()->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'posts_per_page' => 10, + 'paged' => 3, + 'ignore_sticky_posts' => true, + 'post_type' => 'post', +] +``` + +## Order + +`orderBy($field, $direction = 'DESC')` adds a sort key; call it several times to sort by several fields, in call order. It also takes a map of fields and directions. `orderby` is always sent as an array. + +| Method | Meaning | +|---|---| +| `orderBy('title', 'ASC')` | Sort by a field: `post_date`, `title`, `menu_order`, `comment_count`, `post__in`, a named meta clause… | +| `orderBy(['post_date' => 'DESC', 'title' => 'ASC'])` | Several fields at once. | +| `latest()` | `post_date` descending. | +| `oldest()` | `post_date` ascending. | +| `inRandomOrder()` | `rand`. It replaces any other sort key, and a later `orderBy()` replaces it. | +| `orderByMeta($key, $direction = 'DESC', $numeric = false)` | Sort by a custom field (`meta_value`, or `meta_value_num` when `$numeric`). | + +```php +PostQuery::select()->postType('event')->orderBy('menu_order', 'ASC')->orderBy('title', 'ASC')->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'orderby' => [ + 'menu_order' => 'ASC', + 'title' => 'ASC', + ], + 'post_type' => 'event', +] +``` + +`orderByMeta()` also sets `meta_key`, and WordPress then only returns posts that **have** that key. + +```php +PostQuery::select()->postType('product')->orderByMeta('price', 'ASC', numeric: true)->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'meta_key' => 'price', + 'orderby' => [ + 'meta_value_num' => 'ASC', + ], + 'post_type' => 'product', +] +``` + +To sort by a clause of a meta query, name it with `state()` and use that name in `orderBy()`: see [Meta queries](meta-query.md#named-clauses-and-sorting). + +An invalid direction throws a `QueryException`. + +## Fields + +`fields()` (or `PostQuery::select('ids')`) is `'all'`, `'ids'` or `'id=>parent'`. See [Getting results](results.md#fields). + +## Cache and performance + +| Method | WordPress argument | Notes | +|---|---|---| +| `cacheResults(bool = true)` | `cache_results` | | +| `updateMetaCache(bool = true)` | `update_post_meta_cache` | Pass `false` if you never read the posts' custom fields. | +| `updateTermCache(bool = true)` | `update_post_term_cache` | Pass `false` if you never read the posts' terms. | +| `lazyLoadTermMeta(bool = true)` | `lazy_load_term_meta` | | +| `noFoundRows(bool = true)` | `no_found_rows` | Skips counting the total: faster, but `found_posts` and `max_num_pages` are `0`. | +| `suppressFilters(bool = true)` | `suppress_filters` | | + +Turning a cache off is only faster if you do not read what it holds: a `get_post_meta()` in the loop then costs one query per post. For the common cases, the terminal methods already do the right thing: `first()`, `ids()` and `exists()` skip the total count, and `ids()` fetches IDs only. + +```php +PostQuery::select()->postType('post')->noFoundRows()->updateMetaCache(false)->updateTermCache(false)->get(); +``` + +`WP_Query` receives: + +```php +[ + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'no_found_rows' => true, + 'fields' => 'all', + 'post_type' => 'post', +] +``` diff --git a/docs/results.md b/docs/results.md new file mode 100644 index 0000000..8a2afce --- /dev/null +++ b/docs/results.md @@ -0,0 +1,121 @@ +# Getting results + +`get()` returns the WordPress query object. Most of the time you want something simpler, so every query class has terminal methods that run the query with the arguments that fit their purpose. + +- [Overview](#overview) +- [How terminals behave](#how-terminals-behave) +- [`first()`](#first) +- [`ids()`, `names()`, `slugs()` and `pluck()`](#ids-names-slugs-and-pluck) +- [`count()`](#count) +- [`exists()`](#exists) +- [Fields](#fields) +- [Pagination and limits](#pagination-and-limits) + +## Overview + +| Method | Returns | `PostQuery` | `TermQuery` | `UserQuery` | `CommentQuery` | `SiteQuery` | `NetworkQuery` | +|---|---|:-:|:-:|:-:|:-:|:-:|:-:| +| `getArguments()` | `array` of arguments | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `get()` | `WP_Query`, `WP_Term_Query`, `WP_User_Query`, `WP_Comment_Query`, `WP_Site_Query`, `WP_Network_Query` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `first()` | the first match, or `null` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `ids()` | `int[]` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `count()` | `int`, the total number of matches | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `exists()` | `bool` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `names()`, `slugs()` | `string[]` | | ✓ | | | | | +| `pluck($field)` | `string[]`, one column | | | ✓ | | | | + +```php +use Pollora\Query\PostQuery; + +$post = PostQuery::select()->postType('page')->postSlug('about')->first(); // WP_Post|null +$ids = PostQuery::select()->postType('post')->limit(10)->ids(); // [42, 41, 39, ...] +$total = PostQuery::select()->postType('post')->postStatus('publish')->count(); +$exists = PostQuery::select()->postType('page')->postSlug('about')->exists(); +``` + +## How terminals behave + +A terminal method: + +1. works on **a copy** of the builder, so the builder keeps its own arguments and you can call another terminal (or `get()`) afterwards; +2. **overrides only what it needs** (a limit of 1, the `fields`, the count flag) and keeps your filters, order and meta/tax/date queries; +3. **ignores the pagination** that would make the result wrong (see each method). + +```php +$query = PostQuery::select()->postType('product')->limit(10)->paged(3); + +$query->count(); // total number of products, not 10 +$query->first(); // the first product of the query, not the first of page 3 +$query->get(); // still the third page of 10 products +``` + +## `first()` + +Runs the query with a limit of 1 and returns the first record, or `null` when nothing matches. It resets the offset and the page, and skips the total count. + +| Class | Returns | +|---|---| +| `PostQuery` | `WP_Post`, an ID with `fields('ids')`, a partial object with `fields('id=>parent')` | +| `TermQuery` | `WP_Term`, or the first value of the `fields()` you chose (`'ids'`, `'names'`…). With `fields('count')` it returns a `WP_Term`: a count has no "first". | +| `UserQuery` | `WP_User`, or the first value of the `fields()` you chose | +| `CommentQuery` | `WP_Comment`, an ID with `fields('ids')` | +| `SiteQuery`, `NetworkQuery` | `WP_Site`, `WP_Network`, an ID with `fields('ids')` | + +`first()` does not add an order: add one (`latest()`, `orderBy()`…) if "first" must mean something. + +## `ids()`, `names()`, `slugs()` and `pluck()` + +Return a plain list of scalars, and ask WordPress for that column only (no full objects). + +- `ids()` on every class: `int[]`. +- `TermQuery::names()` and `slugs()`: `string[]`. +- `UserQuery::pluck('user_email')`: any single user column among `ID`, `display_name`, `user_login`, `user_nicename`, `user_email`, `user_url`, `user_registered`, `user_pass`, `user_activation_key`, `user_status` (and `spam`, `deleted` on multisite). `pluck('all')` throws a `QueryException`: use `get()`. + +They keep your limit, offset and page: `limit(20)->ids()` returns at most 20 IDs. Without a limit, WordPress' default applies (see [Pagination and limits](#pagination-and-limits)), so call `limit()` or, for posts, `noPaging()` if you need every ID. + +## `count()` + +Returns the total number of matches, **whatever the limit, offset or page** set on the builder (`found_posts`, a `count` query, `get_total()`… depending on the class). It forces the total to be computed even if you called `noFoundRows()`. + +```php +PostQuery::select()->postType('post')->postStatus('publish')->count(); +UserQuery::select()->role('subscriber')->count(); +CommentQuery::select()->postId(42)->pending()->count(); +``` + +## `exists()` + +`true` when at least one record matches. It fetches a single ID, which is cheaper than `count() > 0`. + +## Fields + +`fields()` controls what a query returns. It validates its value and throws a `QueryException` for anything else. + +| Class | Accepted values | +|---|---| +| `PostQuery` | `'all'` (or `'*'`), `'ids'`, `'id=>parent'` | +| `TermQuery` | `'all'` (or `'*'`), `'all_with_object_id'`, `'ids'`, `'tt_ids'`, `'names'`, `'slugs'`, `'count'`, `'id=>parent'`, `'id=>name'`, `'id=>slug'` | +| `UserQuery` | `'all'` (or `'*'`), or one field, or **an array of fields**: `'ID'`, `'display_name'`, `'user_login'`, `'user_nicename'`, `'user_email'`, `'user_url'`, `'user_registered'`, `'user_pass'`, `'user_activation_key'`, `'user_status'`, `'spam'`, `'deleted'` | +| `CommentQuery`, `SiteQuery`, `NetworkQuery` | `'all'` (or `'*'`), `'ids'` | + +`select('ids')` is a shortcut for `select()->fields('ids')`: + +```php +$ids = PostQuery::select('ids')->postType('page')->get()->posts; // WP_Query::$posts is int[] +$mails = UserQuery::select(['ID', 'user_email'])->get()->get_results(); // objects with those two fields +``` + +## Pagination and limits + +Each WordPress query has its own limit behaviour, and the builders do not hide it: + +| Class | Without `limit()` | Notes | +|---|---|---| +| `PostQuery` | WordPress' *Posts per page* setting (10 by default) | `noPaging()` returns everything. `paged()` selects the page. | +| `TermQuery` | every term | | +| `UserQuery` | every user | `paged()` works with `limit()`. | +| `CommentQuery` | every comment | `noFoundRows` is `true` by default in WordPress: call `noFoundRows(false)` to get `found_comments` and `max_num_pages` from `get()`. | +| `SiteQuery` | every site (`number => 0`, WordPress alone stops at 100) | | +| `NetworkQuery` | every network | | + +For terms, users, comments, sites and networks, WordPress applies `offset` only together with a limit: use `skip()` with `limit()`. diff --git a/docs/site-network-query.md b/docs/site-network-query.md new file mode 100644 index 0000000..186a074 --- /dev/null +++ b/docs/site-network-query.md @@ -0,0 +1,204 @@ +# SiteQuery and NetworkQuery + +`SiteQuery` and `NetworkQuery` build the arguments of [`WP_Site_Query`](https://developer.wordpress.org/reference/classes/wp_site_query/) and [`WP_Network_Query`](https://developer.wordpress.org/reference/classes/wp_network_query/). They are for **multisite** installations. + +Read [Concepts](concepts.md) first, and [Getting results](results.md) for the terminal methods. Examples assume: + +```php +use Pollora\Query\NetworkQuery; +use Pollora\Query\SiteQuery; +``` + +- [SiteQuery](#sitequery) + - [Defaults](#defaults) + - [Selecting sites](#selecting-sites) + - [Status](#status) + - [Search](#search) + - [Order](#order) + - [Meta and dates](#meta-and-dates) + - [Cache](#cache) +- [NetworkQuery](#networkquery) +- [Limit, fields and results](#limit-fields-and-results) + +## SiteQuery + +### Defaults + +```php +SiteQuery::select()->get(); +``` + +`WP_Site_Query` receives: + +```php +[ + 'number' => 0, +] +``` + +`number` is `0`: **no limit**. `WP_Site_Query` alone returns at most 100 sites and does not tell you, which silently truncates a large network. Call `limit()` if you want a cap. WordPress applies `offset` only together with a limit, so use `skip()` with `limit()`. + +Archived, spam and deleted sites are **returned** unless you filter them (see [Status](#status)). + +### Selecting sites + +| Method | WordPress argument | Notes | +|---|---|---| +| `SiteQuery::find(3)`, `find([1, 2])` | `site__in` | WordPress' `ID` argument is the same thing. | +| `whereIn(array)` | `site__in` | | +| `whereNotIn(array)` | `site__not_in` | | +| `networkId(int)` | `network_id` | One network. WordPress queries every network when it is `0` or unset. | +| `networkIn(array)` / `networkNotIn(array)` | `network__in` / `network__not_in` | | +| `domain(string)` | `domain` | | +| `domainIn(array)` / `domainNotIn(array)` | `domain__in` / `domain__not_in` | | +| `path(string)` | `path` | | +| `pathIn(array)` / `pathNotIn(array)` | `path__in` / `path__not_in` | | +| `langId(int)` | `lang_id` | | +| `langIn(array)` / `langNotIn(array)` | `lang__in` / `lang__not_in` | | + +```php +SiteQuery::select()->networkId(1)->domainIn(['a.example.com', 'b.example.com'])->get(); +``` + +`WP_Site_Query` receives: + +```php +[ + 'network_id' => 1, + 'domain__in' => [ + 'a.example.com', + 'b.example.com', + ], + 'number' => 0, +] +``` + +### Status + +Each takes a `bool` (default `true`) and is sent as `1` or `0`. Without them WordPress applies no filter. + +| Method | WordPress argument | +|---|---| +| `wherePublic(bool = true)` | `public` | +| `whereArchived(bool = true)` | `archived` | +| `whereMature(bool = true)` | `mature` | +| `whereSpam(bool = true)` | `spam` | +| `whereDeleted(bool = true)` | `deleted` | + +```php +SiteQuery::select()->wherePublic()->whereArchived(false)->whereSpam(false)->whereDeleted(false)->get(); +``` + +`WP_Site_Query` receives: + +```php +[ + 'public' => 1, + 'archived' => 0, + 'spam' => 0, + 'deleted' => 0, + 'number' => 0, +] +``` + +### Search + +| Method | WordPress argument | +|---|---| +| `search(string)` | `search`: sites whose domain or path **contains** the keyword. A `*` inside it is a wildcard. | +| `searchColumns(array)` | `search_columns`: `'domain'` and/or `'path'`. Another column throws a `QueryException`. | + +### Order + +`orderBy($field, $direction = 'ASC')` adds a sort key, in call order; it also takes a map of fields and directions. + +| Method | Sorts by | +|---|---| +| `orderBy($field, $direction)` | `id`, `domain`, `path`, `network_id`, `last_updated`, `registered`, `domain_length`, `path_length`, `site__in`, `network__in`, `deleted`, `mature`, `spam`, `archived`, `public` | +| `orderByDomain()`, `orderByPath()` | `domain`, `path` | +| `orderByDomainLength()`, `orderByPathLength()` | `domain_length`, `path_length` | +| `orderByLastUpdated($direction = 'DESC')` | `last_updated` | +| `latest()` / `oldest()` | `registered` descending / ascending | +| `orderByInclude()` | `site__in`: keep the order of `whereIn()` | +| `orderByNetworkIn()` | `network__in`: keep the order of `networkIn()` | +| `orderByMeta($key, $direction, $numeric)` | `meta_value` or `meta_value_num`, and sets `meta_key` | +| `withoutOrder()` | no `ORDER BY` (`'none'`) | + +An invalid direction throws a `QueryException`. + +```php +SiteQuery::select()->wherePublic()->latest()->limit(10)->get(); +``` + +`WP_Site_Query` receives: + +```php +[ + 'public' => 1, + 'number' => 10, + 'orderby' => [ + 'registered' => 'DESC', + ], +] +``` + +### Meta and dates + +`metaKey()`, `metaValue()`, `metaQuery()`… work as for posts: see [Meta queries](meta-query.md). `dateQuery()` filters on the registration date (`registered`): see [Date queries](date-query.md). Do not name a column: the default is the right one. + +### Cache + +| Method | WordPress argument | Notes | +|---|---|---| +| `noFoundRows(bool = true)` | `no_found_rows` | WordPress skips the total by default: call `noFoundRows(false)` to get `found_sites` and `max_num_pages`. | +| `updateCache(bool = true)` | `update_site_cache` | | +| `updateMetaCache(bool = true)` | `update_site_meta_cache` | | + +## NetworkQuery + +`NetworkQuery` has fewer parameters, and no meta or date queries, as `WP_Network_Query` does not support them. The builder sends nothing by default: WordPress returns every network. + +| Method | WordPress argument | Notes | +|---|---|---| +| `NetworkQuery::find(2)`, `find([1, 2])` | `network__in` | | +| `whereIn(array)` / `whereNotIn(array)` | `network__in` / `network__not_in` | | +| `domain()`, `domainIn()`, `domainNotIn()` | `domain`, `domain__in`, `domain__not_in` | | +| `path()`, `pathIn()`, `pathNotIn()` | `path`, `path__in`, `path__not_in` | | +| `search(string)` | `search` | Domain or path **contains** the keyword. A `*` inside it is a wildcard. | +| `orderBy($field, $direction)` | `orderby` | `id`, `domain`, `path`, `domain_length`, `path_length`, `network__in`. Also `orderByDomain()`, `orderByPath()`, `orderByDomainLength()`, `orderByPathLength()`, `orderByInclude()`, `withoutOrder()`. | +| `noFoundRows(bool = true)` | `no_found_rows` | | +| `updateCache(bool = true)` | `update_network_cache` | | + +```php +NetworkQuery::select()->domain('example.com')->orderByDomain()->get(); +``` + +`WP_Network_Query` receives: + +```php +[ + 'domain' => 'example.com', + 'orderby' => [ + 'domain' => 'ASC', + ], +] +``` + +## Limit, fields and results + +Both classes share: + +| Method | WordPress argument | +|---|---| +| `limit(int)`, `take(int)` | `number` | +| `offset(int)`, `skip(int)` | `offset` (applied only with a limit) | +| `fields('ids')`, `select('ids')` | `fields`: `'ids'`, or `'all'` / `'*'` for whole objects. Anything else throws a `QueryException`. | + +`get()` returns the `WP_Site_Query` (sites in `->sites`) or the `WP_Network_Query` (networks in `->networks`). The [terminal methods](results.md) are: + +```php +$site = SiteQuery::select()->domain('shop.example.com')->first(); // WP_Site|null +$ids = SiteQuery::select()->networkId(1)->wherePublic()->ids(); +$total = SiteQuery::select()->whereSpam(false)->count(); +$has = NetworkQuery::select()->domain('example.com')->exists(); +``` diff --git a/docs/tax-query.md b/docs/tax-query.md new file mode 100644 index 0000000..2a994d2 --- /dev/null +++ b/docs/tax-query.md @@ -0,0 +1,277 @@ +# Tax queries + +`taxQuery()` filters [`PostQuery`](post-query.md) results by terms of any taxonomy, with several conditions combined by `AND` / `OR`. It builds the `tax_query` argument of `WP_Query`. + +Examples assume: + +```php +use Pollora\Query\PostQuery; +use Pollora\Query\TaxQuery; +``` + +- [A first clause](#a-first-clause) +- [Choosing the terms](#choosing-the-terms) +- [Matching by slug, name or ID](#matching-by-slug-name-or-id) +- [Child terms](#child-terms) +- [Combining clauses](#combining-clauses) +- [Method reference](#method-reference) + +## A first clause + +`taxQuery()` takes a closure. It receives a `TaxQuery` builder: start a clause with `taxonomy()`, describe it, and add it with `where()`. + +```php +PostQuery::select() + ->postType('post') + ->taxQuery(function (TaxQuery $query) { + $query->where( + $query->taxonomy('category')->contains([4, 9]) + ); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'post', + 'tax_query' => [ + 'relation' => 'AND', + [ + 'taxonomy' => 'category', + 'field' => 'term_id', + 'terms' => [ + 4, + 9, + ], + 'operator' => 'IN', + 'include_children' => true, + ], + ], +] +``` + +Each clause has the keys WordPress expects: `taxonomy`, `field`, `terms`, `operator` and `include_children`. + +For a single clause you can skip the closure: `Query::tax('category')->contains([4, 9])` returns the same clause builder, and `taxQuery()` accepts it directly (see [Concepts](concepts.md#a-clause-without-a-closure)). + +## Choosing the terms + +| Method | WordPress `operator` | Matches posts… | +|---|---|---| +| `contains(array)` | `IN` | having **at least one** of the terms | +| `containsAll(array)` | `AND` | having **all** the terms | +| `notContains(array)` | `NOT IN` | having **none** of the terms | +| `exists()` | `EXISTS` | having **any** term of the taxonomy | +| `notExists()` | `NOT EXISTS` | having **no** term of the taxonomy | + +```php +PostQuery::select() + ->taxQuery(function (TaxQuery $query) { + $query->where($query->taxonomy('post_tag')->containsAll([12, 15])); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'any', + 'tax_query' => [ + 'relation' => 'AND', + [ + 'taxonomy' => 'post_tag', + 'field' => 'term_id', + 'terms' => [ + 12, + 15, + ], + 'operator' => 'AND', + 'include_children' => true, + ], + ], +] +``` + +For `exists()` and `notExists()` the terms are irrelevant: WordPress ignores them. + +```php +PostQuery::select() + ->taxQuery(fn (TaxQuery $query) => $query->where($query->taxonomy('post_tag')->notExists())) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'any', + 'tax_query' => [ + 'relation' => 'AND', + [ + 'taxonomy' => 'post_tag', + 'field' => 'term_id', + 'terms' => null, + 'operator' => 'NOT EXISTS', + 'include_children' => true, + ], + ], +] +``` + +## Matching by slug, name or ID + +Terms are matched by `term_id` unless you say otherwise. + +| Method | `field` | +|---|---| +| `searchById()` | `term_id` (the default) | +| `searchByTermSlug()` | `slug` | +| `searchByTermName()` | `name` | +| `searchByTermTaxId()` | `term_taxonomy_id` | +| `field('slug')` | any of the above, by name. Other values throw a `QueryException`. | + +```php +PostQuery::select() + ->postType('product') + ->taxQuery(function (TaxQuery $query) { + $query->where( + $query->taxonomy('product_cat')->searchByTermSlug()->contains(['shoes', 'bags']) + ); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'product', + 'tax_query' => [ + 'relation' => 'AND', + [ + 'taxonomy' => 'product_cat', + 'field' => 'slug', + 'terms' => [ + 'shoes', + 'bags', + ], + 'operator' => 'IN', + 'include_children' => true, + ], + ], +] +``` + +```php +Query::tax('category')->field('title') +// QueryException: Invalid tax field type supplied: title +``` + +## Child terms + +For hierarchical taxonomies, WordPress includes the children of the terms you list. Turn that off with `onlyParent()` or `excludeChildren()` (same effect); `includeChildren()` puts it back. + +```php +PostQuery::select() + ->taxQuery(function (TaxQuery $query) { + $query->where($query->taxonomy('category')->contains([4])->onlyParent()); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'any', + 'tax_query' => [ + 'relation' => 'AND', + [ + 'taxonomy' => 'category', + 'field' => 'term_id', + 'terms' => [ + 4, + ], + 'operator' => 'IN', + 'include_children' => false, + ], + ], +] +``` + +## Combining clauses + +`where()` and `andWhere()` add a clause with `AND`, `orWhere()` with `OR`. Closures group clauses. The rules, including how `where(a)->where(b)->orWhere(c)` is read, are in [Concepts](concepts.md#sub-queries-where-and-orwhere). + +```php +PostQuery::select() + ->postType('product') + ->taxQuery(function (TaxQuery $query) { + $query + ->where($query->taxonomy('product_cat')->searchByTermSlug()->contains(['shoes'])) + ->where(function (TaxQuery $group) { + $group + ->where($group->taxonomy('product_tag')->searchByTermSlug()->contains(['sale'])) + ->orWhere($group->taxonomy('product_tag')->searchByTermSlug()->contains(['clearance'])); + }); + }) + ->get(); +``` + +`WP_Query` receives: + +```php +[ + 'fields' => 'all', + 'post_type' => 'product', + 'tax_query' => [ + 'relation' => 'AND', + [ + 'taxonomy' => 'product_cat', + 'field' => 'slug', + 'terms' => [ + 'shoes', + ], + 'operator' => 'IN', + 'include_children' => true, + ], + [ + 'relation' => 'OR', + [ + 'taxonomy' => 'product_tag', + 'field' => 'slug', + 'terms' => [ + 'sale', + ], + 'operator' => 'IN', + 'include_children' => true, + ], + [ + 'taxonomy' => 'product_tag', + 'field' => 'slug', + 'terms' => [ + 'clearance', + ], + 'operator' => 'IN', + 'include_children' => true, + ], + ], + ], +] +``` + +That reads `product_cat = shoes AND (product_tag = sale OR product_tag = clearance)`. + +## Method reference + +On the `TaxQuery` builder (the closure argument): `taxonomy($name)`, `where()`, `andWhere()`, `orWhere()`. + +On a clause (`TaxQueryBuilder`): `contains()`, `containsAll()`, `notContains()`, `exists()`, `notExists()`, `field()`, `searchById()`, `searchByTermSlug()`, `searchByTermName()`, `searchByTermTaxId()`, `includeChildren()`, `onlyParent()`, `excludeChildren()`. diff --git a/docs/term-query.md b/docs/term-query.md new file mode 100644 index 0000000..8d7300f --- /dev/null +++ b/docs/term-query.md @@ -0,0 +1,223 @@ +# TermQuery + +`TermQuery` builds the arguments of [`WP_Term_Query`](https://developer.wordpress.org/reference/classes/wp_term_query/): categories, tags and the terms of any custom taxonomy. + +Read [Concepts](concepts.md) first, and [Getting results](results.md) for the terminal methods. Examples assume: + +```php +use Pollora\Query\TermQuery; +``` + +- [Defaults](#defaults) +- [Choosing the taxonomy](#choosing-the-taxonomy) +- [Selecting terms](#selecting-terms) +- [Hierarchy](#hierarchy) +- [Search](#search) +- [Order](#order) +- [Limit](#limit) +- [Fields](#fields) +- [Meta](#meta) +- [Cache](#cache) +- [Results](#results) + +## Defaults + +```php +TermQuery::select()->get(); +``` + +`WP_Term_Query` receives: + +```php +[ + 'fields' => 'all', + 'hide_empty' => false, +] +``` + +- **`hide_empty` is `false`.** WordPress hides the terms that have no post by default, which makes a new term "disappear". The builder returns every term; call `hideEmpty()` to get WordPress' behaviour back. +- `fields` is `'all'` (full `WP_Term` objects). +- Without `taxonomy()`, WordPress queries **every** taxonomy. + +## Choosing the taxonomy + +| Method | WordPress argument | +|---|---| +| `taxonomy(string\|array)` | `taxonomy` | +| `objectIds(int\|array)` | `object_ids`: only the terms attached to these objects (posts, users…) | + +```php +TermQuery::select()->taxonomy('category')->objectIds(42)->get(); +``` + +`WP_Term_Query` receives: + +```php +[ + 'fields' => 'all', + 'taxonomy' => 'category', + 'object_ids' => 42, + 'hide_empty' => false, +] +``` + +## Selecting terms + +| Method | WordPress argument | Notes | +|---|---|---| +| `TermQuery::find(5)`, `find([1, 2])` | `include` | By term ID. | +| `whereIn(array)` | `include` | | +| `whereNotIn(array)` | `exclude` | Ignored by WordPress when `whereIn()` is used. | +| `excludeTree(array)` | `exclude_tree` | Excludes the terms **and all their descendants**. | +| `name(string\|array)` | `name` | Exact name(s). | +| `slug(string\|array)` | `slug` | Exact slug(s). | +| `termTaxonomyId(int\|array)` | `term_taxonomy_id` | | +| `hideEmpty(bool = true)` | `hide_empty` | Hide the terms with no post. | +| `withEmpty()` | `hide_empty => false` | The default. | + +An empty `whereIn([])` is **not** "no result": WordPress ignores an empty `include`, so every term matches. Guard an empty list yourself. + +## Hierarchy + +| Method | WordPress argument | Notes | +|---|---|---| +| `rootOnly()` | `parent => 0` | Top-level terms. | +| `parentId(int)` | `parent` | The direct children of a term. | +| `childOf(int)` | `child_of` | **All** the descendants of a term. Ignored when several taxonomies are queried. | +| `childless(bool = true)` | `childless` | Terms that have no children. | +| `hierarchical(bool = true)` | `hierarchical` | With `hideEmpty()`, keep the terms whose descendants are not empty. | +| `padCounts(bool = true)` | `pad_counts` | Add the children's count to each term's `count`. | +| `unrestricted()` | `get => 'all'` | Terms regardless of ancestry or emptiness. | + +WordPress applies some overrides that the builder does not hide: `unrestricted()` makes it ignore `childOf()`, `childless()`, `hideEmpty()`, `hierarchical()` and `padCounts()`; a non-zero `parentId()` makes it ignore `childOf()`. + +```php +TermQuery::select()->taxonomy('category')->parentId(12)->hideEmpty()->get(); +``` + +`WP_Term_Query` receives: + +```php +[ + 'fields' => 'all', + 'taxonomy' => 'category', + 'hide_empty' => true, + 'parent' => 12, +] +``` + +## Search + +| Method | WordPress argument | Matches | +|---|---|---| +| `search(string)` | `search` | the keyword in the name or the slug (contains) | +| `nameLike(string)` | `name__like` | names containing the text | +| `descriptionLike(string)` | `description__like` | descriptions containing the text | + +## Order + +A term query sorts by **one** field, unlike posts: `orderBy()` replaces the previous sort, and takes a direction (`ASC` by default, as in WordPress). + +| Method | `orderby` / `order` | +|---|---| +| `orderBy($field, $direction = 'ASC')` | any term field (`name`, `slug`, `term_id`, `description`, `parent`, `term_group`…), `count`, a meta key or a meta clause name | +| `orderByName($direction = 'ASC')` | `name` | +| `orderByCount($direction = 'DESC')` | `count`: the most used first | +| `orderByInclude()` | `include`: keep the order of `whereIn()`. Ignored by WordPress if `whereIn()` is empty. | +| `orderBySlugIn()` | `slug__in`: keep the order of `slug([...])`. Ignored unless `slug()` received an array. | +| `orderByMeta($key, $direction, $numeric)` | `meta_value` or `meta_value_num`, and sets `meta_key` | +| `withoutOrder()` | `none`: no `ORDER BY` | + +An invalid direction throws a `QueryException`. + +```php +TermQuery::select()->taxonomy('post_tag')->orderByCount()->limit(10)->get(); +``` + +`WP_Term_Query` receives: + +```php +[ + 'fields' => 'all', + 'taxonomy' => 'post_tag', + 'hide_empty' => false, + 'number' => 10, + 'orderby' => 'count', + 'order' => 'DESC', +] +``` + +## Limit + +`limit()` / `take()` (`number`) and `offset()` / `skip()` (`offset`). Without a limit WordPress returns every term, and it applies `offset` only together with a limit. + +## Fields + +`fields()` (or `TermQuery::select('ids')`): `'all'` (or `'*'`), `'all_with_object_id'`, `'ids'`, `'tt_ids'`, `'names'`, `'slugs'`, `'count'`, `'id=>parent'`, `'id=>name'`, `'id=>slug'`. Anything else throws a `QueryException`. + +```php +TermQuery::select('id=>name')->taxonomy('category')->get(); +``` + +`WP_Term_Query` receives: + +```php +[ + 'fields' => 'id=>name', + 'taxonomy' => 'category', + 'hide_empty' => false, +] +``` + +## Meta + +`metaKey()`, `metaValue()`, `metaCompare()`, `metaType()`, `metaCompareKey()`, `metaTypeKey()` and `metaQuery()` work as for posts: see [Meta queries](meta-query.md). + +```php +TermQuery::select() + ->taxonomy('product_cat') + ->metaQuery(function (MetaQuery $query) { + $query->where($query->meta('featured')->equalTo('yes')); + }) + ->get(); +``` + +`WP_Term_Query` receives: + +```php +[ + 'fields' => 'all', + 'taxonomy' => 'product_cat', + 'hide_empty' => false, + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'featured', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'yes', + ], + ], +] +``` + +## Cache + +| Method | WordPress argument | +|---|---| +| `cacheResults(bool = true)` | `cache_results` | +| `updateMetaCache(bool = true)` | `update_term_meta_cache` | +| `cacheDomain(string)` | `cache_domain` | + +## Results + +`get()` returns the `WP_Term_Query` (its terms are in `->terms`). The [terminal methods](results.md) are: + +```php +$term = TermQuery::select()->taxonomy('category')->slug('news')->first(); // WP_Term|null +$ids = TermQuery::select()->taxonomy('category')->rootOnly()->ids(); // [3, 8, 14] +$names = TermQuery::select()->taxonomy('post_tag')->orderByCount()->limit(5)->names(); // ['php', ...] +$slugs = TermQuery::select()->taxonomy('post_tag')->slugs(); +$total = TermQuery::select()->taxonomy('category')->hideEmpty()->count(); +$has = TermQuery::select()->taxonomy('category')->slug('news')->exists(); +``` diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..928043c --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,71 @@ +# Upgrading from 1.0 + +This page lists what changed since 1.0.3. Most of it fixes arguments that were wrong or ignored; a few changes alter the results of a query that used to run, so read [what to check](#what-to-check) first. + +- [What to check](#what-to-check) +- [Fixes](#fixes) +- [New](#new) + +## What to check + +These changes can alter the results, or the exceptions, of code that worked before. + +### `where()` then `orWhere()` is now grouped + +On a level that already had several clauses, switching relation used to turn the **whole** group into the new relation, silently. It now groups the clauses so far, like Eloquent: + +```php +$query->where($a)->where($b)->orWhere($c); +// before: a OR b OR c +// now: (a AND b) OR c +``` + +A chain with a single clause before the switch, or that only uses one of the two, is unchanged. See [Concepts](concepts.md#mixing-where-and-orwhere). + +### `inclusive()` now works + +`DateQuery::inclusive()` was written on the query root, where WordPress does not read it, so it had no effect. It is now written on each date clause, so **a query that called it becomes inclusive**: `before()` and `after()` now match the boundary date itself. Remove the call if you relied on the exclusive behaviour. + +### Hours are on a 24-hour clock + +A date string was converted with the 12-hour format, so `'2022-01-01'` became `hour => 12` and `'2022-01-01 15:10:00'` became `hour => 3`. Both are now right (`0` and `15`). If you compensated for it, remove the workaround. + +### Invalid values throw a `QueryException` + +These used to be silently ignored, and now throw: + +| Call | Invalid value | +|---|---| +| `fields()`, `PostQuery::select('x')` | anything but `'*'`, `'all'`, `'ids'`, `'id=>parent'` | +| `userPermission()` | anything but `'readable'` and `'editable'` | +| `orderBy()` | a direction other than `ASC` and `DESC` (case-insensitive) | + +### Removed methods + +`initQuery()`, `query()` and `genericQuery()` were public by accident (they came from an internal trait and did nothing useful on their own). They are removed from `PostQuery`. Nothing documented used them. + +## Fixes + +| Before | Now | +|---|---| +| `DateQueryBuilder::within()` always threw a `TypeError`. | It works: [Date queries](date-query.md#an-exact-period-within). | +| A debug `var_export` printed to the output when a date string was converted. | Removed. | +| `date()` without a column sent `'column' => null`. | No column is sent, so WordPress uses the default of the queried table. | +| A numeric (timestamp) string given to `before()` / `after()` threw a `TypeError`. | It is read as a timestamp. | +| `categoryAnd()` sent `category_and`, which WordPress ignores. | It sends `category__and`. | +| `postType(['post', 'page'])` threw a `TypeError`. | Arrays are accepted. | +| `orderBy(['a' => 'ASC'])` and `inRandomOrder()` followed by `orderBy()` or `latest()` threw a `TypeError`. | They work. `orderby` is always sent as an array, and `inRandomOrder()` is `['rand' => 'ASC']`. | +| `ofType()` was ignored (the type was always detected). | It is honoured. | +| The type of a list was detected from its first value: `between(1, 50)` was `BINARY`, `in([])` raised a warning. | One type fits every value: `between(1, 50)` is `UNSIGNED`, `in([])` is `CHAR`. See [Meta queries](meta-query.md#value-types). | +| Calling `metaQuery()`, `taxQuery()` or `dateQuery()` twice repeated the clauses of the first call. | Each call adds its own clauses once. | + +## New + +- **Query classes**: [`TermQuery`](term-query.md), [`UserQuery`](user-query.md), [`CommentQuery`](comment-query.md), [`SiteQuery` and `NetworkQuery`](site-network-query.md). +- **Terminal methods** on every class: `first()`, `ids()`, `count()` and `exists()`, plus `names()` and `slugs()` on terms and `pluck()` on users. See [Getting results](results.md). +- **`PostQuery` parameters**: `pageId()`, `pagename()`, `title()`, `menuOrder()`; the date fields `year()`, `month()`, `day()`, `hour()`, `minute()`, `second()`, `week()`, `yearMonth()`; `metaKey()`, `metaValue()`, `metaCompare()`, `metaType()`, `metaCompareKey()`, `metaTypeKey()` and `orderByMeta()`; `exactSearch()`, `searchSentence()`, `searchColumns()`; `noFoundRows()`, `suppressFilters()`, `lazyLoadTermMeta()`. +- **Meta clauses**: `regexp()`, `notRegexp()`, `rlike()`, `keyCompare()`, `keyType()`. +- **Tax clauses**: `containsAll()` (`operator => 'AND'`). +- **Dates**: `registered()`, and `inclusive()` on a single clause. +- **`Query::tax()`**, next to `Query::meta()` and `Query::date()`. +- **`setRawArgs()`** documented: [Concepts](concepts.md#arguments-the-builders-do-not-cover). diff --git a/docs/user-query.md b/docs/user-query.md new file mode 100644 index 0000000..9ac3c74 --- /dev/null +++ b/docs/user-query.md @@ -0,0 +1,275 @@ +# UserQuery + +`UserQuery` builds the arguments of [`WP_User_Query`](https://developer.wordpress.org/reference/classes/wp_user_query/). + +Read [Concepts](concepts.md) first, and [Getting results](results.md) for the terminal methods. Examples assume: + +```php +use Pollora\Query\UserQuery; +``` + +- [Defaults](#defaults) +- [Roles and capabilities](#roles-and-capabilities) +- [Selecting users](#selecting-users) +- [Search](#search) +- [Authors](#authors) +- [Order](#order) +- [Limit and pagination](#limit-and-pagination) +- [Fields](#fields) +- [Meta and dates](#meta-and-dates) +- [Performance](#performance) +- [Results](#results) + +## Defaults + +```php +UserQuery::select()->get(); +``` + +`WP_User_Query` receives: + +```php +[ + 'fields' => 'all', +] +``` + +The builder adds no constraint: WordPress returns **every** user, with no limit, and the full `WP_User` objects (`fields => 'all'`). + +## Roles and capabilities + +| Method | WordPress argument | Matches users… | +|---|---|---| +| `role(string\|array)` | `role` | having **every** listed role | +| `roleIn(array)` | `role__in` | having **at least one** of the roles | +| `roleNotIn(array)` | `role__not_in` | having **none** of the roles | +| `capability(string\|array)` | `capability` | having **every** listed capability | +| `capabilityIn(array)` | `capability__in` | having at least one | +| `capabilityNotIn(array)` | `capability__not_in` | having none | +| `blogId(int)` | `blog_id` | roles and capabilities are read on this site (multisite) | + +Capabilities only work if they are stored in the database: those added by a plugin on the fly, or modified by `map_meta_cap`, are not found. `capability('edit_posts')` is the replacement of the deprecated `who => 'authors'`, which `UserQuery` does not support. + +```php +UserQuery::select()->roleIn(['editor', 'author'])->get(); +``` + +`WP_User_Query` receives: + +```php +[ + 'fields' => 'all', + 'role__in' => [ + 'editor', + 'author', + ], +] +``` + +## Selecting users + +| Method | WordPress argument | +|---|---| +| `UserQuery::find(5)`, `find([1, 2])` | `include` | +| `whereIn(array)` | `include` | +| `whereNotIn(array)` | `exclude` | +| `login(string)` | `login` | +| `loginIn(array)` / `loginNotIn(array)` | `login__in` / `login__not_in` | +| `nicename(string)` | `nicename` | +| `nicenameIn(array)` / `nicenameNotIn(array)` | `nicename__in` / `nicename__not_in` | + +An empty `whereIn([])` is **not** "no result": WordPress ignores an empty `include`, so every user matches. Guard an empty list yourself. + +## Search + +| Method | WordPress argument | +|---|---| +| `search(string)` | `search` | +| `searchColumns(array)` | `search_columns`: among `ID`, `user_login`, `user_email`, `user_url`, `user_nicename`, `display_name`. Another column throws a `QueryException`. | + +**Without a `*`, the keyword must match a whole value.** A leading `*`, a trailing `*`, or both, make it a partial match (`*bob`, `bob*`, `*bob*`). + +Without `searchColumns()`, WordPress picks the columns from the keyword: an `@` searches the email, a number the login and the ID, a URL the website, anything else the login, website, email, nicename and display name. + +```php +UserQuery::select()->search('*smith*')->searchColumns(['display_name', 'user_email'])->get(); +``` + +`WP_User_Query` receives: + +```php +[ + 'fields' => 'all', + 'search' => '*smith*', + 'search_columns' => [ + 'display_name', + 'user_email', + ], +] +``` + +## Authors + +`hasPublishedPosts()` keeps the users who have published posts: in every public post type, or in the ones you give. + +```php +UserQuery::select()->hasPublishedPosts(['post', 'page'])->get(); +``` + +`WP_User_Query` receives: + +```php +[ + 'fields' => 'all', + 'has_published_posts' => [ + 'post', + 'page', + ], +] +``` + +## Order + +`orderBy($field, $direction = 'ASC')` adds a sort key, in call order; it also takes a map of fields and directions. `orderby` is sent as a map, so each field has its own direction. + +| Method | Sorts by | +|---|---| +| `orderBy($field, $direction)` | `ID`, `display_name`, `user_login`, `user_nicename`, `user_email`, `user_url`, `user_registered`, `post_count`, `meta_value`, `meta_value_num`, a meta key or a meta clause name | +| `orderByName()` | `display_name` | +| `orderByLogin()` | `user_login` | +| `orderByEmail()` | `user_email` | +| `orderByPostCount($direction = 'DESC')` | `post_count` | +| `latest()` | `user_registered` descending | +| `oldest()` | `user_registered` ascending | +| `orderByInclude()` | `include`: keep the order of `whereIn()` | +| `orderByLoginIn()` | `login__in`: keep the order of `loginIn()` | +| `orderByNicenameIn()` | `nicename__in`: keep the order of `nicenameIn()` | +| `orderByMeta($key, $direction, $numeric)` | `meta_value` or `meta_value_num`, and sets `meta_key` | + +An invalid direction throws a `QueryException`. + +```php +UserQuery::select()->orderBy('display_name')->orderBy('user_registered', 'DESC')->get(); +``` + +`WP_User_Query` receives: + +```php +[ + 'fields' => 'all', + 'orderby' => [ + 'display_name' => 'ASC', + 'user_registered' => 'DESC', + ], +] +``` + +## Limit and pagination + +| Method | WordPress argument | +|---|---| +| `limit(int)`, `take(int)` | `number` | +| `offset(int)`, `skip(int)` | `offset` | +| `paged(int)` | `paged`: the page, used together with `limit()` | + +```php +UserQuery::select()->role('subscriber')->limit(20)->paged(3)->get(); +``` + +`WP_User_Query` receives: + +```php +[ + 'fields' => 'all', + 'role' => 'subscriber', + 'number' => 20, + 'paged' => 3, +] +``` + +## Fields + +`fields()` (or `UserQuery::select(...)`) takes `'all'` (or `'*'`), one field, or an array of fields among `ID`, `display_name`, `user_login`, `user_nicename`, `user_email`, `user_url`, `user_registered`, `user_pass`, `user_activation_key`, `user_status`, `spam` and `deleted`. `'id'` is read as `'ID'`. An unknown field throws a `QueryException`, and so does `'all'` combined with other fields. + +```php +UserQuery::select(['ID', 'user_email'])->role('editor')->get(); +``` + +`WP_User_Query` receives: + +```php +[ + 'fields' => [ + 'ID', + 'user_email', + ], + 'role' => 'editor', +] +``` + +## Meta and dates + +`metaKey()`, `metaValue()`, `metaQuery()`… work as for posts: see [Meta queries](meta-query.md). + +`dateQuery()` filters on the registration date (`user_registered`): see [Date queries](date-query.md). Do not name a column: the default is the right one. + +```php +UserQuery::select() + ->metaQuery(function (MetaQuery $query) { + $query->where($query->meta('plan')->equalTo('pro')); + }) + ->dateQuery(function (DateQuery $query) { + $query->where($query->date()->after('2024-01-01')); + }) + ->get(); +``` + +`WP_User_Query` receives: + +```php +[ + 'date_query' => [ + 'relation' => 'AND', + [ + 'after' => [ + 'year' => 2024, + 'month' => 1, + 'day' => 1, + 'hour' => 0, + 'minute' => 0, + 'second' => 0, + ], + ], + ], + 'fields' => 'all', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'plan', + 'compare' => '=', + 'type' => 'CHAR', + 'value' => 'pro', + ], + ], +] +``` + +## Performance + +| Method | WordPress argument | Notes | +|---|---|---| +| `cacheResults(bool = true)` | `cache_results` | | +| `countTotal(bool = true)` | `count_total` | Count the total number of users (WordPress does by default). | +| `withoutCount()` | `count_total => false` | Faster when you do not paginate: `get_total()` is then `0`. | + +## Results + +`get()` returns the `WP_User_Query`: read `->get_results()` and `->get_total()`. The [terminal methods](results.md) are: + +```php +$user = UserQuery::select()->login('admin')->first(); // WP_User|null +$ids = UserQuery::select()->role('editor')->ids(); // [2, 7, 9] +$emails = UserQuery::select()->role('subscriber')->pluck('user_email'); // ['a@x.test', ...] +$total = UserQuery::select()->role('subscriber')->count(); +$has = UserQuery::select()->roleIn(['editor'])->exists(); +``` diff --git a/src/Traits/Networks/Filters.php b/src/Traits/Networks/Filters.php index d7685e9..b8e2efe 100644 --- a/src/Traits/Networks/Filters.php +++ b/src/Traits/Networks/Filters.php @@ -43,7 +43,7 @@ public function whereNotIn(array $networkIds): self } /** - * Searches the domain and the path. A leading and/or trailing `*` makes it a wildcard search. + * Matches networks whose domain or path contains the keyword. A `*` inside the keyword is a wildcard. */ public function search(string $keyword): self { diff --git a/src/Traits/Order.php b/src/Traits/Order.php index d829553..aa91559 100644 --- a/src/Traits/Order.php +++ b/src/Traits/Order.php @@ -4,8 +4,13 @@ namespace Pollora\Query\Traits; +use Pollora\Query\QueryException; +use Pollora\Query\Traits\Concerns\ValidatesChoices; + trait Order { + use ValidatesChoices; + /** * @var array|null */ @@ -13,6 +18,8 @@ trait Order /** * @param array|string $orderby A field, or a map of field => direction. + * + * @throws QueryException If a direction is not ASC or DESC. */ public function orderBy(array|string $orderby, string $order = 'DESC'): self { @@ -22,7 +29,7 @@ public function orderBy(array|string $orderby, string $order = 'DESC'): self unset($this->orderby['rand']); foreach ($columns as $column => $direction) { - $this->orderby[$column] = strtoupper($direction); + $this->orderby[$column] = $this->assertDirection($direction); } return $this; diff --git a/src/Traits/Sites/Filters.php b/src/Traits/Sites/Filters.php index 82f512b..3d6f02f 100644 --- a/src/Traits/Sites/Filters.php +++ b/src/Traits/Sites/Filters.php @@ -179,7 +179,7 @@ public function langNotIn(array $langIds): self } /** - * A leading and/or trailing `*` makes it a wildcard search. + * Matches sites whose domain or path contains the keyword. A `*` inside the keyword is a wildcard. */ public function search(string $keyword): self { diff --git a/src/Traits/User/Filters.php b/src/Traits/User/Filters.php index 7e6252c..f63e063 100644 --- a/src/Traits/User/Filters.php +++ b/src/Traits/User/Filters.php @@ -186,8 +186,8 @@ public function whereNotIn(array $userIds): self } /** - * Without searchColumns(), WP picks the columns from the keyword. A leading and/or trailing `*` - * makes it a wildcard search. + * Without a `*`, the keyword must match a whole value. A leading and/or trailing `*` makes it a + * partial match. Without searchColumns(), WP picks the columns from the keyword. */ public function search(string $keyword): self { diff --git a/tests/Feature/OrderTest.php b/tests/Feature/OrderTest.php index 9a9a478..f8c286c 100644 --- a/tests/Feature/OrderTest.php +++ b/tests/Feature/OrderTest.php @@ -1,6 +1,7 @@ latest()->getArguments()['orderby'])->toBe(['post_date' => 'DESC']) ->and(PostQuery::select()->oldest()->getArguments()['orderby'])->toBe(['post_date' => 'ASC']); }); + +test('Order: an invalid direction throws', function () { + PostQuery::select()->orderBy('post_date', 'sideways'); +})->throws(QueryException::class, 'Invalid order direction supplied: SIDEWAYS'); + +test('Order: an invalid direction in a map throws', function () { + PostQuery::select()->orderBy(['post_date' => 'ASC', 'title' => 'up']); +})->throws(QueryException::class);