From d0e956ff8ec5e0dad0174ea0cb754d02f05f45ed Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 01:25:24 +0000 Subject: [PATCH 01/70] Add Composer-installable WordPress query argument builders --- .gitignore | 4 + README.md | 66 +- composer.json | 27 + phpunit.xml | 8 + src/Utils/ArrayUtil.php | 95 ++ src/Utils/NumberUtil.php | 20 + src/WordPress/CommentQueryArg.php | 175 ++++ src/WordPress/DateQueryArg.php | 241 +++++ src/WordPress/IncludeExcludeIsQueryArg.php | 90 ++ src/WordPress/IncludeExcludeQueryArg.php | 79 ++ src/WordPress/IncludeQueryArg.php | 78 ++ src/WordPress/MetaQueryArg.php | 233 +++++ src/WordPress/NetworkQueryArg.php | 114 +++ src/WordPress/PostQueryArg.php | 274 ++++++ src/WordPress/QueryArg.php | 20 + src/WordPress/SearchQueryArg.php | 117 +++ src/WordPress/SiteQueryArg.php | 159 ++++ src/WordPress/TaxQueryArg.php | 235 +++++ src/WordPress/TermQueryArg.php | 169 ++++ src/WordPress/UserQueryArg.php | 836 ++++++++++++++++++ tests/WordPress/QueryArgumentBuildersTest.php | 53 ++ tests/bootstrap.php | 5 + 22 files changed, 3097 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/Utils/ArrayUtil.php create mode 100644 src/Utils/NumberUtil.php create mode 100644 src/WordPress/CommentQueryArg.php create mode 100644 src/WordPress/DateQueryArg.php create mode 100644 src/WordPress/IncludeExcludeIsQueryArg.php create mode 100644 src/WordPress/IncludeExcludeQueryArg.php create mode 100644 src/WordPress/IncludeQueryArg.php create mode 100644 src/WordPress/MetaQueryArg.php create mode 100644 src/WordPress/NetworkQueryArg.php create mode 100644 src/WordPress/PostQueryArg.php create mode 100644 src/WordPress/QueryArg.php create mode 100644 src/WordPress/SearchQueryArg.php create mode 100644 src/WordPress/SiteQueryArg.php create mode 100644 src/WordPress/TaxQueryArg.php create mode 100644 src/WordPress/TermQueryArg.php create mode 100644 src/WordPress/UserQueryArg.php create mode 100644 tests/WordPress/QueryArgumentBuildersTest.php create mode 100644 tests/bootstrap.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02a240d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/.phpunit.cache/ +/.phpunit.result.cache +/composer.lock diff --git a/README.md b/README.md index aedf963..a90638f 100644 --- a/README.md +++ b/README.md @@ -1 +1,65 @@ -# wordpress-database \ No newline at end of file +# Pentarix WordPress Database + +Composer-installable WordPress query argument builders with a fluent API. + +This package currently provides the WordPress query argument-building capabilities extracted from Vertex Framework. It builds argument arrays; it does not execute queries or provide an ORM. + +## Requirements + +- PHP 8.0 or later +- WordPress 6.3 or later when passing generated arguments to WordPress APIs + +## Installation + +```bash +composer require pentarix/wordpress-database +``` + +Composer maps the `Pentarix\\WordPressDatabase\\` namespace to `src/` using PSR-4. + +## Usage + +```php +use Pentarix\\WordPressDatabase\\WordPress\\MetaQueryArg; +use Pentarix\\WordPressDatabase\\WordPress\\PostQueryArg; + +$args = PostQueryArg::make() + ->postType('product') + ->postStatus('publish') + ->postsPerPage(12) + ->metaQuery(MetaQueryArg::make()->where('featured', true)) + ->toArray(); + +$query = new \\WP_Query($args); +``` + +## Available builders + +- `CommentQueryArg` +- `DateQueryArg` +- `MetaQueryArg` +- `NetworkQueryArg` +- `PostQueryArg` +- `SiteQueryArg` +- `TaxQueryArg` +- `TermQueryArg` +- `UserQueryArg` + +Supporting builders (`IncludeQueryArg`, `IncludeExcludeQueryArg`, `IncludeExcludeIsQueryArg`, and `SearchQueryArg`) are also available under `Pentarix\\WordPressDatabase\\WordPress`. + +## Scope + +The builders return arrays compatible with WordPress query APIs, including `WP_Query`, `WP_User_Query`, `WP_Comment_Query`, `WP_Term_Query`, and multisite query APIs. + +They do not execute database queries, replace WordPress query classes, or bypass WordPress's query lifecycle. Direct SQL, custom-table CRUD, repositories, models, and migrations are outside the current scope. + +## Development + +```bash +composer install +composer test +``` + +## Licence + +MIT. See [LICENSE](LICENSE). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..00100f0 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "pentarix/wordpress-database", + "description": "WordPress query argument builders with a fluent, Composer-autoloadable API.", + "type": "library", + "license": "MIT", + "require": { + "php": ">=8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^10.5" + }, + "autoload": { + "psr-4": { + "Pentarix\\\\WordPressDatabase\\\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Pentarix\\\\WordPressDatabase\\\\Tests\\\\": "tests/" + } + }, + "scripts": { + "test": "phpunit --configuration phpunit.xml" + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..fb3c22b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests/WordPress + + + diff --git a/src/Utils/ArrayUtil.php b/src/Utils/ArrayUtil.php new file mode 100644 index 0000000..6c3240c --- /dev/null +++ b/src/Utils/ArrayUtil.php @@ -0,0 +1,95 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\Utils; + +final class ArrayUtil +{ + public static function castValues( + int|float|string|array $value, + callable $cast + ): array { + $values = \is_array($value) ? $value : [$value]; + + return \array_values( + \array_unique( + \array_map($cast, $values), + SORT_REGULAR + ) + ); + } + public static function mergeCasted( + array $current, + int|float|string|array $value, + callable $cast + ): array { + return \array_values( + \array_unique( + \array_merge( + $current, + self::castValues($value, $cast) + ), + SORT_REGULAR + ) + ); + } + + public static function normalizeUniqueStrings(array $values): array + { + return \array_values( + \array_unique( + \array_filter( + $values, + static function ($value): bool { + if (!\is_string($value)) { + throw new \InvalidArgumentException( + 'Every value must be a string.' + ); + } + + return '' !== $value; + } + ), + SORT_STRING + ) + ); + } + + public static function mergeUnique(array ...$arrays): array + { + return \array_values( + \array_unique( + \array_merge(...$arrays), + SORT_REGULAR + ) + ); + } + + public static function removeValues( + array $current, + array $values, + bool $strict = false + ): array { + if (!$strict) { + return \array_values( + \array_diff($current, $values) + ); + } + + return \array_values( + \array_filter( + $current, + static fn($item): bool => !\in_array($item, $values, true) + ) + ); + } +} diff --git a/src/Utils/NumberUtil.php b/src/Utils/NumberUtil.php new file mode 100644 index 0000000..bfb7089 --- /dev/null +++ b/src/Utils/NumberUtil.php @@ -0,0 +1,20 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); +namespace Pentarix\WordPressDatabase\Utils; + +final class NumberUtil +{ + public static function absint(int|float|string $value): int + { + return (int) abs((float) $value); + } +} \ No newline at end of file diff --git a/src/WordPress/CommentQueryArg.php b/src/WordPress/CommentQueryArg.php new file mode 100644 index 0000000..0f7ffe5 --- /dev/null +++ b/src/WordPress/CommentQueryArg.php @@ -0,0 +1,175 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class CommentQueryArg implements QueryArg +{ + private IncludeExcludeQueryArg $commentIds; + private IncludeExcludeQueryArg $postIds; + private IncludeExcludeQueryArg $parentIds; + private IncludeExcludeQueryArg $userIds; + private ?int $postId = null; + private ?int $parent = null; + private ?int $author = null; + private ?string $authorEmail = null; + private ?string $authorUrl = null; + private ?string $authorIp = null; + private ?string $search = null; + private ?string $type = null; + private ?string $status = null; + private ?int $number = null; + private ?int $offset = null; + private string|array|null $orderby = null; + private ?string $order = null; + private ?string $fields = null; + private ?bool $hierarchical = null; + private ?bool $count = null; + private ?bool $noFoundRows = null; + private ?bool $updateCommentMetaCache = null; + private ?bool $updateCommentPostCache = null; + + public function __construct(array $queryArray = []) + { + $this->commentIds = IncludeExcludeQueryArg::make(['comment__in' => $queryArray['comment__in'] ?? [], 'comment__not_in' => $queryArray['comment__not_in'] ?? []], ['includeKey' => 'comment__in', 'excludeKey' => 'comment__not_in']); + $this->postIds = IncludeExcludeQueryArg::make(['post__in' => $queryArray['post__in'] ?? [], 'post__not_in' => $queryArray['post__not_in'] ?? []], ['includeKey' => 'post__in', 'excludeKey' => 'post__not_in']); + $this->parentIds = IncludeExcludeQueryArg::make(['parent__in' => $queryArray['parent__in'] ?? [], 'parent__not_in' => $queryArray['parent__not_in'] ?? []], ['includeKey' => 'parent__in', 'excludeKey' => 'parent__not_in']); + $this->userIds = IncludeExcludeQueryArg::make(['user__in' => $queryArray['user__in'] ?? [], 'user__not_in' => $queryArray['user__not_in'] ?? []], ['includeKey' => 'user__in', 'excludeKey' => 'user__not_in']); + if (array_key_exists('post_id', $queryArray)) { + $this->postId = (int) $queryArray['post_id']; + } + if (array_key_exists('parent', $queryArray)) { + $this->parent = (int) $queryArray['parent']; + } + if (array_key_exists('user_id', $queryArray)) { + $this->author = (int) $queryArray['user_id']; + } + if (array_key_exists('author_email', $queryArray)) { + $this->authorEmail = (string) $queryArray['author_email']; + } + if (array_key_exists('author_url', $queryArray)) { + $this->authorUrl = (string) $queryArray['author_url']; + } + if (array_key_exists('author_ip', $queryArray)) { + $this->authorIp = (string) $queryArray['author_ip']; + } + if (array_key_exists('search', $queryArray)) { + $this->search = (string) $queryArray['search']; + } + if (array_key_exists('type', $queryArray)) { + $this->type = (string) $queryArray['type']; + } + if (array_key_exists('status', $queryArray)) { + $this->status = (string) $queryArray['status']; + } + if (array_key_exists('number', $queryArray)) { + $this->number = (int) $queryArray['number']; + } + if (array_key_exists('offset', $queryArray)) { + $this->offset = (int) $queryArray['offset']; + } + if (array_key_exists('orderby', $queryArray)) { + $this->orderby = $queryArray['orderby']; + } + if (array_key_exists('order', $queryArray)) { + $this->order = (string) $queryArray['order']; + } + if (array_key_exists('fields', $queryArray)) { + $this->fields = (string) $queryArray['fields']; + } + if (array_key_exists('hierarchical', $queryArray)) { + $this->hierarchical = (bool) $queryArray['hierarchical']; + } + if (array_key_exists('count', $queryArray)) { + $this->count = (bool) $queryArray['count']; + } + if (array_key_exists('no_found_rows', $queryArray)) { + $this->noFoundRows = (bool) $queryArray['no_found_rows']; + } + if (array_key_exists('update_comment_meta_cache', $queryArray)) { + $this->updateCommentMetaCache = (bool) $queryArray['update_comment_meta_cache']; + } + if (array_key_exists('update_comment_post_cache', $queryArray)) { + $this->updateCommentPostCache = (bool) $queryArray['update_comment_post_cache']; + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function includeComments(int|array $ids): self + { + $this->commentIds->include($ids); + + return $this; + } + + public function excludeComments(int|array $ids): self + { + $this->commentIds->exclude($ids); + + return $this; + } + + public function postId(int $id): self + { + $this->postId = $id; + + return $this; + } + + public function search(string $value): self + { + $this->search = $value; + + return $this; + } + + public function toArray(): array + { + $args = []; + $args += $this->commentIds->toArray(); + $args += $this->postIds->toArray(); + $args += $this->parentIds->toArray(); + $args += $this->userIds->toArray(); + $values = [ + 'post_id' => $this->postId, + 'parent' => $this->parent, + 'user_id' => $this->author, + 'author_email' => $this->authorEmail, + 'author_url' => $this->authorUrl, + 'author_ip' => $this->authorIp, + 'search' => $this->search, + 'type' => $this->type, + 'status' => $this->status, + 'number' => $this->number, + 'offset' => $this->offset, + 'orderby' => $this->orderby, + 'order' => $this->order, + 'fields' => $this->fields, + 'hierarchical' => $this->hierarchical, + 'count' => $this->count, + 'no_found_rows' => $this->noFoundRows, + 'update_comment_meta_cache' => $this->updateCommentMetaCache, + 'update_comment_post_cache' => $this->updateCommentPostCache, + ]; + foreach ($values as $key => $value) { + if (null !== $value) { + $args[$key] = $value; + } + } + + return $args; + } +} diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php new file mode 100644 index 0000000..1c7f80d --- /dev/null +++ b/src/WordPress/DateQueryArg.php @@ -0,0 +1,241 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class DateQueryArg implements QueryArg +{ + + private array $query = []; + + public function __construct(array $queryArray = []) + { + $this->query = $queryArray; + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function where(array $clause): self + { + return $this->addClause($clause, 'AND'); + } + + public function orWhere(array $clause): self + { + return $this->addClause($clause, 'OR'); + } + + public function after( + string|array $date, + bool $inclusive = false, + string $column = 'post_date' + ): self { + return $this->where( + [ + 'after' => $date, + 'inclusive' => $inclusive, + 'column' => $column, + ] + ); + } + + public function before( + string|array $date, + bool $inclusive = false, + string $column = 'post_date' + ): self { + return $this->where( + [ + 'before' => $date, + 'inclusive' => $inclusive, + 'column' => $column, + ] + ); + } + + public function between( + string|array $after, + string|array $before, + bool $inclusive = true, + string $column = 'post_date' + ): self { + return $this->where( + [ + 'after' => $after, + 'before' => $before, + 'inclusive' => $inclusive, + 'column' => $column, + ] + ); + } + + public function year(int $year, string $column = 'post_date'): self + { + return $this->where([ + 'year' => $year, + 'column' => $column, + ]); + } + + public function month(int $month, string $column = 'post_date'): self + { + return $this->where([ + 'month' => $month, + 'column' => $column, + ]); + } + + public function week(int $week, string $column = 'post_date'): self + { + return $this->where([ + 'week' => $week, + 'column' => $column, + ]); + } + + public function day(int $day, string $column = 'post_date'): self + { + return $this->where([ + 'day' => $day, + 'column' => $column, + ]); + } + + public function hour(int $hour, string $column = 'post_date'): self + { + return $this->where([ + 'hour' => $hour, + 'column' => $column, + ]); + } + + public function minute(int $minute, string $column = 'post_date'): self + { + return $this->where([ + 'minute' => $minute, + 'column' => $column, + ]); + } + + public function second(int $second, string $column = 'post_date'): self + { + return $this->where([ + 'second' => $second, + 'column' => $column, + ]); + } + + public function whereGroup( + callable $callback, + string $relation = 'AND' + ): self { + return $this->addGroup($callback, $relation, 'AND'); + } + + public function orWhereGroup( + callable $callback, + string $relation = 'AND' + ): self { + return $this->addGroup($callback, $relation, 'OR'); + } + + public function whereAny(callable $callback): self + { + return $this->whereGroup($callback, 'OR'); + } + + public function whereAll(callable $callback): self + { + return $this->whereGroup($callback, 'AND'); + } + + public function whereClause( + array $clause, + string $boolean = 'AND' + ): self { + return $this->addClause($clause, $boolean); + } + + private function addClause(array $clause, string $boolean): self + { + $boolean = 'OR' === strtoupper($boolean) ? 'OR' : 'AND'; + + if ([] === $this->query) { + $this->query = [$clause]; + + return $this; + } + + if ('OR' === $boolean) { + if (isset($this->query['relation']) && 'OR' === $this->query['relation']) { + $this->query[] = $clause; + } else { + $this->query = [ + 'relation' => 'OR', + $this->query, + $clause, + ]; + } + + return $this; + } + + if (!isset($this->query['relation']) || 'AND' === $this->query['relation']) { + $this->query[] = $clause; + } else { + $this->query = [ + 'relation' => 'AND', + $this->query, + $clause, + ]; + } + + return $this; + } + + private function addGroup( + callable $callback, + string $relation, + string $boolean + ): self { + $group = new static(); + + $callback($group); + + $query = $group->toArray(); + + if ([] === $query) { + return $this; + } + + $relation = 'OR' === strtoupper($relation) ? 'OR' : 'AND'; + + if (isset($query['relation'])) { + $query['relation'] = $relation; + } else { + $query = [ + 'relation' => $relation, + $query, + ]; + } + + return $this->addClause($query, $boolean); + } + + public function toArray(): array + { + return $this->query; + } +} diff --git a/src/WordPress/IncludeExcludeIsQueryArg.php b/src/WordPress/IncludeExcludeIsQueryArg.php new file mode 100644 index 0000000..2d4c840 --- /dev/null +++ b/src/WordPress/IncludeExcludeIsQueryArg.php @@ -0,0 +1,90 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +use Pentarix\WordPressDatabase\Utils\ArrayUtil; +use InvalidArgumentException; + +class IncludeExcludeIsQueryArg extends IncludeExcludeQueryArg +{ + + protected string $isKey; + + protected bool $multiValue = false; + + protected string|array|null $is = null; + + public function __construct( + array $queryArray = [], + bool $isIdentifier = true, + bool $multiValue = false, + string $includeKey = 'include', + string $excludeKey = 'exclude', + string $isKey = 'is' + ) { + $this->multiValue = $multiValue; + $this->isKey = $isKey; + + parent::__construct($queryArray, $isIdentifier, $includeKey, $excludeKey); + + if (isset($queryArray[$this->isKey])) { + $this->is($queryArray[$this->isKey]); + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static( + $queryArray, + $args['isIdentifier'] ?? true, + $args['multiValue'] ?? false, + $args['includeKey'] ?? 'include', + $args['excludeKey'] ?? 'exclude', + $args['isKey'] ?? 'is' + ); + } + + public function is(string|array $value): static + { + if (false === $this->multiValue) { + if (!\is_string($value)) { + throw new InvalidArgumentException('Is value must be a string in single-value mode.'); + } + + $this->is = '' === $value ? null : $value; + + return $this; + } + + if (!\is_array($value)) { + throw new InvalidArgumentException('Is value must be an array in multi-value mode.'); + } + + $value = ArrayUtil::normalizeUniqueStrings($value); + + $this->is = [] === $value ? null : $value; + + return $this; + } + + public function toArray(): array + { + $output = parent::toArray(); + + if (null !== $this->is) { + $output[$this->isKey] = $this->is; + } + + return $output; + } +} diff --git a/src/WordPress/IncludeExcludeQueryArg.php b/src/WordPress/IncludeExcludeQueryArg.php new file mode 100644 index 0000000..45cdf04 --- /dev/null +++ b/src/WordPress/IncludeExcludeQueryArg.php @@ -0,0 +1,79 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +use Pentarix\WordPressDatabase\Utils\ArrayUtil; + +class IncludeExcludeQueryArg extends IncludeQueryArg +{ + + protected string $excludeKey; + + protected array $excluded = []; + + public function __construct( + array $queryArray = [], + bool $isIdentifier = true, + string $includeKey = 'include', + string $excludeKey = 'exclude' + ) { + parent::__construct($queryArray, $isIdentifier, $includeKey); + + $this->excludeKey = $excludeKey; + + if (isset($queryArray[$this->excludeKey])) { + $this->exclude((array) $queryArray[$this->excludeKey]); + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static( + $queryArray, + $args['isIdentifier'] ?? true, + $args['includeKey'] ?? 'include', + $args['excludeKey'] ?? 'exclude' + ); + } + + public function include(int|float|string|array $value): static + { + $values = ArrayUtil::castValues($value, fn($item) => $this->cast($item)); + + $this->mergeIncluded($values); + $this->excluded = ArrayUtil::removeValues($this->excluded, $values); + + return $this; + } + + public function exclude(int|float|string|array $value): static + { + $values = ArrayUtil::castValues($value, fn($item) => $this->cast($item)); + + $this->excluded = ArrayUtil::mergeUnique($this->excluded, $values); + $this->included = ArrayUtil::removeValues($this->included, $values); + + return $this; + } + + public function toArray(): array + { + $output = parent::toArray(); + + if ([] !== $this->excluded) { + $output[$this->excludeKey] = $this->excluded; + } + + return $output; + } +} diff --git a/src/WordPress/IncludeQueryArg.php b/src/WordPress/IncludeQueryArg.php new file mode 100644 index 0000000..a5dc5f3 --- /dev/null +++ b/src/WordPress/IncludeQueryArg.php @@ -0,0 +1,78 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +use Pentarix\WordPressDatabase\Utils\ArrayUtil; +use Pentarix\WordPressDatabase\Utils\NumberUtil; + +class IncludeQueryArg implements QueryArg +{ + + protected bool $isIdentifier = true; + + protected string $includeKey; + + protected array $included = []; + + public function __construct( + array $queryArray = [], + bool $isIdentifier = true, + string $includeKey = 'included' + ) { + $this->isIdentifier = $isIdentifier; + $this->includeKey = $includeKey; + + if (isset($queryArray[$this->includeKey])) { + $this->include((array) $queryArray[$this->includeKey]); + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static( + $queryArray, + $args['isIdentifier'] ?? true, + $args['includeKey'] ?? 'included' + ); + } + + protected function cast(int|float|string $value): int|string + { + return true === $this->isIdentifier ? NumberUtil::absint($value) : (string) $value; + } + + public function include(int|float|string|array $value): static + { + $this->mergeIncluded( + ArrayUtil::castValues($value, fn($item) => $this->cast($item)) + ); + + return $this; + } + + public function toArray(): array + { + if ([] === $this->included) { + return []; + } + + return [ + $this->includeKey => $this->included, + ]; + } + + protected function mergeIncluded(array $values): void + { + $this->included = ArrayUtil::mergeUnique($this->included, $values); + } +} diff --git a/src/WordPress/MetaQueryArg.php b/src/WordPress/MetaQueryArg.php new file mode 100644 index 0000000..96af4d7 --- /dev/null +++ b/src/WordPress/MetaQueryArg.php @@ -0,0 +1,233 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class MetaQueryArg implements QueryArg +{ + + private array $query = []; + + public function __construct(array $queryArray = []) + { + $this->query = $queryArray; + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function where(string $key, $operator, $value = null, string $type = 'CHAR'): self + { + if (2 === \func_num_args()) { + $value = $operator; + $operator = '='; + } + + return $this->addClause( + [ + 'key' => $key, + 'value' => $value, + 'compare' => $operator, + 'type' => $type, + ], + 'AND' + ); + } + + public function orWhere(string $key, $operator, $value = null, string $type = 'CHAR'): self + { + if (2 === \func_num_args()) { + $value = $operator; + $operator = '='; + } + + return $this->addClause( + [ + 'key' => $key, + 'value' => $value, + 'compare' => $operator, + 'type' => $type, + ], + 'OR' + ); + } + + public function whereIn(string $key, array $values, string $type = 'CHAR'): self + { + return $this->where($key, 'IN', $values, $type); + } + + public function whereNotIn(string $key, array $values, string $type = 'CHAR'): self + { + return $this->where($key, 'NOT IN', $values, $type); + } + + public function whereBetween(string $key, array $values, string $type = 'CHAR'): self + { + return $this->where($key, 'BETWEEN', $values, $type); + } + + public function whereNotBetween(string $key, array $values, string $type = 'CHAR'): self + { + return $this->where($key, 'NOT BETWEEN', $values, $type); + } + + public function whereLike(string $key, string $value): self + { + return $this->where($key, 'LIKE', $value); + } + + public function whereNotLike(string $key, string $value): self + { + return $this->where($key, 'NOT LIKE', $value); + } + + public function whereRegexp(string $key, string $pattern): self + { + return $this->where($key, 'REGEXP', $pattern); + } + + public function whereNotRegexp(string $key, string $pattern): self + { + return $this->where($key, 'NOT REGEXP', $pattern); + } + + public function whereExists(string $key): self + { + return $this->addClause( + [ + 'key' => $key, + 'compare' => 'EXISTS', + ], + 'AND' + ); + } + + public function whereNotExists(string $key): self + { + return $this->addClause( + [ + 'key' => $key, + 'compare' => 'NOT EXISTS', + ], + 'AND' + ); + } + + public function whereMetaKey(string $key, string $comparison = '=', string $type = ''): self + { + $clause = [ + 'key' => $key, + 'compare' => 'EXISTS', + 'compare_key' => $comparison, + ]; + + if ('' !== $type) { + $clause['type_key'] = $type; + } + + return $this->addClause($clause, 'AND'); + } + + public function whereGroup(callable $callback, string $relation = 'AND'): self + { + return $this->addGroup($callback, $relation, 'AND'); + } + + public function orWhereGroup(callable $callback, string $relation = 'AND'): self + { + return $this->addGroup($callback, $relation, 'OR'); + } + + public function whereAny(callable $callback): self + { + return $this->whereGroup($callback, 'OR'); + } + + public function whereAll(callable $callback): self + { + return $this->whereGroup($callback, 'AND'); + } + + public function whereClause(array $clause, string $boolean = 'AND'): self + { + return $this->addClause($clause, $boolean); + } + + private function addClause(array $clause, string $boolean): self + { + $boolean = 'OR' === strtoupper($boolean) ? 'OR' : 'AND'; + + if (empty($this->query)) { + $this->query = [$clause]; + return $this; + } + + if ('OR' === $boolean) { + if (isset($this->query['relation']) && 'OR' === $this->query['relation']) { + $this->query[] = $clause; + } else { + $this->query = [ + 'relation' => 'OR', + $this->query, + $clause, + ]; + } + + return $this; + } + + if (!isset($this->query['relation']) || 'AND' === $this->query['relation']) { + $this->query[] = $clause; + } else { + $this->query = [ + 'relation' => 'AND', + $this->query, + $clause, + ]; + } + + return $this; + } + + private function addGroup(callable $callback, string $relation, string $boolean): self + { + $nested = new self(); + $callback($nested); + + $conditions = $nested->toArray(); + + if (empty($conditions)) { + return $this; + } + + $relation = 'OR' === strtoupper($relation) ? 'OR' : 'AND'; + + if (isset($conditions['relation'])) { + $conditions['relation'] = $relation; + } else { + $conditions = [ + 'relation' => $relation, + $conditions, + ]; + } + + return $this->addClause($conditions, $boolean); + } + + public function toArray(): array + { + return $this->query; + } +} diff --git a/src/WordPress/NetworkQueryArg.php b/src/WordPress/NetworkQueryArg.php new file mode 100644 index 0000000..800376a --- /dev/null +++ b/src/WordPress/NetworkQueryArg.php @@ -0,0 +1,114 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class NetworkQueryArg implements QueryArg +{ + private IncludeExcludeQueryArg $networkIds; + private ?int $networkId = null; + private ?string $domain = null; + private ?string $path = null; + private ?int $number = null; + private ?int $offset = null; + private string|array|null $orderby = null; + private ?string $order = null; + private ?string $fields = null; + private ?bool $count = null; + private ?bool $updateNetworkCache = null; + + public function __construct(array $queryArray = []) + { + $this->networkIds = IncludeExcludeQueryArg::make(['network__in' => $queryArray['network__in'] ?? [], 'network__not_in' => $queryArray['network__not_in'] ?? []], ['includeKey' => 'network__in', 'excludeKey' => 'network__not_in']); + if (array_key_exists('network_id', $queryArray)) { + $this->networkId = (int) $queryArray['network_id']; + } + if (array_key_exists('domain', $queryArray)) { + $this->domain = (string) $queryArray['domain']; + } + if (array_key_exists('path', $queryArray)) { + $this->path = (string) $queryArray['path']; + } + if (array_key_exists('number', $queryArray)) { + $this->number = (int) $queryArray['number']; + } + if (array_key_exists('offset', $queryArray)) { + $this->offset = (int) $queryArray['offset']; + } + if (array_key_exists('orderby', $queryArray)) { + $this->orderby = $queryArray['orderby']; + } + if (array_key_exists('order', $queryArray)) { + $this->order = (string) $queryArray['order']; + } + if (array_key_exists('fields', $queryArray)) { + $this->fields = (string) $queryArray['fields']; + } + if (array_key_exists('count', $queryArray)) { + $this->count = (bool) $queryArray['count']; + } + if (array_key_exists('update_network_cache', $queryArray)) { + $this->updateNetworkCache = (bool) $queryArray['update_network_cache']; + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function includeNetworks(int|array $ids): self + { + $this->networkIds->include($ids); + + return $this; + } + + public function excludeNetworks(int|array $ids): self + { + $this->networkIds->exclude($ids); + + return $this; + } + + public function networkId(int $id): self + { + $this->networkId = $id; + + return $this; + } + + public function toArray(): array + { + $args = []; + $args += $this->networkIds->toArray(); + $values = [ + 'network_id' => $this->networkId, + 'domain' => $this->domain, + 'path' => $this->path, + 'number' => $this->number, + 'offset' => $this->offset, + 'orderby' => $this->orderby, + 'order' => $this->order, + 'fields' => $this->fields, + 'count' => $this->count, + 'update_network_cache' => $this->updateNetworkCache, + ]; + foreach ($values as $key => $value) { + if (null !== $value) { + $args[$key] = $value; + } + } + + return $args; + } +} diff --git a/src/WordPress/PostQueryArg.php b/src/WordPress/PostQueryArg.php new file mode 100644 index 0000000..909ef4f --- /dev/null +++ b/src/WordPress/PostQueryArg.php @@ -0,0 +1,274 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class PostQueryArg implements QueryArg +{ + private IncludeExcludeQueryArg $postIdQuery; + private IncludeExcludeQueryArg $authorQuery; + private IncludeExcludeQueryArg $parentQuery; + private IncludeExcludeIsQueryArg $postTypeQuery; + private IncludeExcludeIsQueryArg $postStatusQuery; + private SearchQueryArg $searchQuery; + private DateQueryArg $dateQuery; + private MetaQueryArg $metaQuery; + private TaxQueryArg $taxQuery; + + private ?int $postsPerPage = null; + private ?int $page = null; + private ?int $offset = null; + private string|array|null $orderBy = null; + private ?string $order = null; + private ?string $postName = null; + private array $postNameIn = []; + private ?int $menuOrder = null; + private ?bool $hasPassword = null; + private ?string $postPassword = null; + private ?bool $ignoreStickyPosts = null; + private ?bool $noFoundRows = null; + private ?bool $suppressFilters = null; + private ?bool $cacheResults = null; + private ?bool $updatePostMetaCache = null; + private ?bool $updatePostTermCache = null; + private ?bool $lazyLoadTermMeta = null; + private ?string $perm = null; + private ?int $commentCount = null; + private ?bool $exact = null; + private ?bool $sentence = null; + private ?string $fields = null; + + public function __construct(array $queryArray = []) + { + $this->postIdQuery = IncludeExcludeQueryArg::make([ + 'post__in' => $queryArray['post__in'] ?? [], + 'post__not_in' => $queryArray['post__not_in'] ?? [], + ], ['includeKey' => 'post__in', 'excludeKey' => 'post__not_in']); + $this->authorQuery = IncludeExcludeQueryArg::make([ + 'author__in' => $queryArray['author__in'] ?? [], + 'author__not_in' => $queryArray['author__not_in'] ?? [], + ], ['includeKey' => 'author__in', 'excludeKey' => 'author__not_in']); + $this->parentQuery = IncludeExcludeQueryArg::make([ + 'post_parent__in' => $queryArray['post_parent__in'] ?? [], + 'post_parent__not_in' => $queryArray['post_parent__not_in'] ?? [], + ], ['includeKey' => 'post_parent__in', 'excludeKey' => 'post_parent__not_in']); + $this->postTypeQuery = IncludeExcludeIsQueryArg::make([ + 'is' => $queryArray['post_type'] ?? null, + ], ['isIdentifier' => false, 'multiValue' => true, 'includeKey' => 'post_type__in', 'isKey' => 'post_type']); + $this->postStatusQuery = IncludeExcludeIsQueryArg::make([ + 'is' => isset($queryArray['post_status']) ? (array) $queryArray['post_status'] : null, + ], ['isIdentifier' => false, 'multiValue' => true, 'isKey' => 'post_status']); + $this->searchQuery = SearchQueryArg::make([ + 'search' => $queryArray['s'] ?? '', + ], ['allowedSearchColumns' => ['post_title', 'post_content', 'post_excerpt']]); + $this->dateQuery = DateQueryArg::make($queryArray['date_query'] ?? []); + $this->metaQuery = MetaQueryArg::make($queryArray['meta_query'] ?? []); + $this->taxQuery = TaxQueryArg::make($queryArray['tax_query'] ?? []); + + if (isset($queryArray['posts_per_page'])) { + + $this->postsPerPage = (int) $queryArray['posts_per_page']; + + } + if (isset($queryArray['paged'])) { + $this->page = max(1, (int) $queryArray['paged']); + } + if (isset($queryArray['offset'])) { + $this->offset = max(0, (int) $queryArray['offset']); + } + if (isset($queryArray['orderby'])) { + $this->orderBy = $queryArray['orderby']; + } + if (isset($queryArray['order'])) { + $this->order = 'DESC' === strtoupper((string) $queryArray['order']) ? 'DESC' : 'ASC'; + } + if (isset($queryArray['name'])) { + $this->postName = (string) $queryArray['name']; + } + if (isset($queryArray['post_name__in'])) { + $this->postNameIn = array_values(array_unique(array_map('strval', (array) $queryArray['post_name__in']))); + } + if (isset($queryArray['menu_order'])) { + $this->menuOrder = (int) $queryArray['menu_order']; + } + if (isset($queryArray['has_password'])) { + $this->hasPassword = (bool) $queryArray['has_password']; + } + if (isset($queryArray['post_password'])) { + $this->postPassword = (string) $queryArray['post_password']; + } + if (isset($queryArray['ignore_sticky_posts'])) { + $this->ignoreStickyPosts = (bool) $queryArray['ignore_sticky_posts']; + } + if (isset($queryArray['no_found_rows'])) { + $this->noFoundRows = (bool) $queryArray['no_found_rows']; + } + if (isset($queryArray['suppress_filters'])) { + $this->suppressFilters = (bool) $queryArray['suppress_filters']; + } + if (isset($queryArray['cache_results'])) { + $this->cacheResults = (bool) $queryArray['cache_results']; + } + if (isset($queryArray['update_post_meta_cache'])) { + $this->updatePostMetaCache = (bool) $queryArray['update_post_meta_cache']; + } + if (isset($queryArray['update_post_term_cache'])) { + $this->updatePostTermCache = (bool) $queryArray['update_post_term_cache']; + } + if (isset($queryArray['lazy_load_term_meta'])) { + $this->lazyLoadTermMeta = (bool) $queryArray['lazy_load_term_meta']; + } + if (isset($queryArray['perm'])) { + $this->perm = (string) $queryArray['perm']; + } + if (isset($queryArray['comment_count'])) { + $this->commentCount = (int) $queryArray['comment_count']; + } + if (isset($queryArray['exact'])) { + $this->exact = (bool) $queryArray['exact']; + } + if (isset($queryArray['sentence'])) { + $this->sentence = (bool) $queryArray['sentence']; + } + if (isset($queryArray['fields'])) { + $this->fields = (string) $queryArray['fields']; + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function includeIds(int|float|string|array $ids): self { $this->postIdQuery->include($ids); + + return $this; } + public function excludeIds(int|float|string|array $ids): self { $this->postIdQuery->exclude($ids); + + return $this; } + public function postType(string|array $types): self { $this->postTypeQuery->is((array) $types); + + return $this; } + public function postTypesIn(string|array $types): self { $this->postTypeQuery->include($types); + + return $this; } + public function postStatus(string|array $statuses): self { $this->postStatusQuery->is((array) $statuses); + + return $this; } + public function postStatusesIn(string|array $statuses): self { $this->postStatusQuery->include($statuses); + + return $this; } + public function postStatusesNotIn(string|array $statuses): self { $this->postStatusQuery->exclude($statuses); + + return $this; } + public function authorsIn(int|float|string|array $ids): self { $this->authorQuery->include($ids); + + return $this; } + public function authorsNotIn(int|float|string|array $ids): self { $this->authorQuery->exclude($ids); + + return $this; } + public function parentsIn(int|float|string|array $ids): self { $this->parentQuery->include($ids); + + return $this; } + public function parentsNotIn(int|float|string|array $ids): self { $this->parentQuery->exclude($ids); + + return $this; } + public function search(string $value): self { $this->searchQuery->search($value); + + return $this; } + public function dateQuery(DateQueryArg|array $query): self { $this->dateQuery = $query instanceof DateQueryArg ? $query : DateQueryArg::make($query); + + return $this; } + public function metaQuery(MetaQueryArg|array $query): self { $this->metaQuery = $query instanceof MetaQueryArg ? $query : MetaQueryArg::make($query); + + return $this; } + public function taxQuery(TaxQueryArg|array $query): self { $this->taxQuery = $query instanceof TaxQueryArg ? $query : TaxQueryArg::make($query); + + return $this; } + public function postsPerPage(int $number): self { $this->postsPerPage = $number; + + return $this; } + public function page(int $page): self { $this->page = max(1, $page); + + return $this; } + public function offset(int $offset): self { $this->offset = max(0, $offset); + + return $this; } + public function orderBy(string|array $column): self { $this->orderBy = $column; + + return $this; } + public function order(string $order): self { $this->order = 'DESC' === strtoupper($order) ? 'DESC' : 'ASC'; + + return $this; } + public function fields(string $fields): self { $this->fields = $fields; + + return $this; } + + public function toArray(): array + { + $args = []; + $args += $this->postIdQuery->toArray(); + $args += $this->authorQuery->toArray(); + $args += $this->parentQuery->toArray(); + $args += $this->postTypeQuery->toArray(); + $args += $this->postStatusQuery->toArray(); + + $search = $this->searchQuery->toArray(); + if (isset($search['search'])) { + $args['s'] = $search['search']; + } + $date = $this->dateQuery->toArray(); + if ([] !== $date) { + $args['date_query'] = $date; + } + $meta = $this->metaQuery->toArray(); + if ([] !== $meta) { + $args['meta_query'] = $meta; + } + $tax = $this->taxQuery->toArray(); + if ([] !== $tax) { + $args['tax_query'] = $tax; + } + $values = [ + 'posts_per_page' => $this->postsPerPage, + 'paged' => $this->page, + 'offset' => $this->offset, + 'orderby' => $this->orderBy, + 'order' => $this->order, + 'name' => $this->postName, + 'menu_order' => $this->menuOrder, + 'has_password' => $this->hasPassword, + 'post_password' => $this->postPassword, + 'ignore_sticky_posts' => $this->ignoreStickyPosts, + 'no_found_rows' => $this->noFoundRows, + 'suppress_filters' => $this->suppressFilters, + 'cache_results' => $this->cacheResults, + 'update_post_meta_cache' => $this->updatePostMetaCache, + 'update_post_term_cache' => $this->updatePostTermCache, + 'lazy_load_term_meta' => $this->lazyLoadTermMeta, + 'perm' => $this->perm, + 'comment_count' => $this->commentCount, + 'exact' => $this->exact, + 'sentence' => $this->sentence, + 'fields' => $this->fields, + ]; + foreach ($values as $key => $value) { + if (null !== $value) { + $args[$key] = $value; + } + } + if ([] !== $this->postNameIn) { + $args['post_name__in'] = $this->postNameIn; + } + return $args; + } +} diff --git a/src/WordPress/QueryArg.php b/src/WordPress/QueryArg.php new file mode 100644 index 0000000..4102cec --- /dev/null +++ b/src/WordPress/QueryArg.php @@ -0,0 +1,20 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +interface QueryArg +{ + public static function make(array $queryArray = [], array $args = []): static; + + public function toArray(): array; +} diff --git a/src/WordPress/SearchQueryArg.php b/src/WordPress/SearchQueryArg.php new file mode 100644 index 0000000..6633639 --- /dev/null +++ b/src/WordPress/SearchQueryArg.php @@ -0,0 +1,117 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class SearchQueryArg implements QueryArg +{ + + protected ?string $search = null; + + protected array $searchColumns = []; + + protected array $allowedSearchColumns; + + public function __construct(array $allowedSearchColumns, array $queryArray = []) + { + foreach ($allowedSearchColumns as $column) { + if (!\is_string($column)) { + throw new \InvalidArgumentException( + 'Every allowed search column must be a string.' + ); + } + } + + $this->allowedSearchColumns = \array_values( + \array_unique($allowedSearchColumns, SORT_STRING) + ); + + if (isset($queryArray['search'])) { + $this->search($queryArray['search']); + } + + if (isset($queryArray['searchColumns'])) { + $this->searchColumns($queryArray['searchColumns']); + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + if (!isset($args['allowedSearchColumns'])) { + throw new \InvalidArgumentException( + 'Allowed search columns must be provided.' + ); + } + + return new static( + $args['allowedSearchColumns'], + $queryArray + ); + } + + public function search(string $value): self + { + $this->search = '' === $value ? null : $value; + + return $this; + } + + public function searchColumns(string|array $value): self + { + $values = \is_array($value) ? $value : [$value]; + + $values = \array_values( + \array_unique( + \array_filter( + $values, + function ($item): bool { + if (!\is_string($item)) { + throw new \InvalidArgumentException('Every search column must be a string.'); + } + + if ('' === $item) { + return false; + } + + if (!\in_array($item, $this->allowedSearchColumns, true)) { + throw new \InvalidArgumentException( + 'Invalid search column: ' . $item + ); + } + + return true; + } + ), + SORT_STRING + ) + ); + + $this->searchColumns = $values; + + return $this; + } + + public function toArray(): array + { + $output = []; + + if (null !== $this->search) { + $output['search'] = $this->search; + } + + if ([] !== $this->searchColumns) { + $output['searchColumns'] = $this->searchColumns; + } + + return $output; + } +} diff --git a/src/WordPress/SiteQueryArg.php b/src/WordPress/SiteQueryArg.php new file mode 100644 index 0000000..3b3974d --- /dev/null +++ b/src/WordPress/SiteQueryArg.php @@ -0,0 +1,159 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class SiteQueryArg implements QueryArg +{ + private IncludeExcludeQueryArg $siteIds; + private IncludeExcludeQueryArg $networkIds; + private ?int $siteId = null; + private ?int $networkId = null; + private ?string $domain = null; + private ?string $path = null; + private ?string $search = null; + private ?int $number = null; + private ?int $offset = null; + private string|array|null $orderby = null; + private ?string $order = null; + private ?string $fields = null; + private ?bool $public = null; + private ?bool $archived = null; + private ?bool $mature = null; + private ?bool $spam = null; + private ?bool $deleted = null; + private ?int $langId = null; + private ?bool $updateSiteCache = null; + + public function __construct(array $queryArray = []) + { + $this->siteIds = IncludeExcludeQueryArg::make(['site__in' => $queryArray['site__in'] ?? [], 'site__not_in' => $queryArray['site__not_in'] ?? []], ['includeKey' => 'site__in', 'excludeKey' => 'site__not_in']); + $this->networkIds = IncludeExcludeQueryArg::make(['network__in' => $queryArray['network__in'] ?? [], 'network__not_in' => $queryArray['network__not_in'] ?? []], ['includeKey' => 'network__in', 'excludeKey' => 'network__not_in']); + if (array_key_exists('ID', $queryArray)) { + $this->siteId = (int) $queryArray['ID']; + } + if (array_key_exists('network_id', $queryArray)) { + $this->networkId = (int) $queryArray['network_id']; + } + if (array_key_exists('domain', $queryArray)) { + $this->domain = (string) $queryArray['domain']; + } + if (array_key_exists('path', $queryArray)) { + $this->path = (string) $queryArray['path']; + } + if (array_key_exists('search', $queryArray)) { + $this->search = (string) $queryArray['search']; + } + if (array_key_exists('number', $queryArray)) { + $this->number = (int) $queryArray['number']; + } + if (array_key_exists('offset', $queryArray)) { + $this->offset = (int) $queryArray['offset']; + } + if (array_key_exists('orderby', $queryArray)) { + $this->orderby = $queryArray['orderby']; + } + if (array_key_exists('order', $queryArray)) { + $this->order = (string) $queryArray['order']; + } + if (array_key_exists('fields', $queryArray)) { + $this->fields = (string) $queryArray['fields']; + } + if (array_key_exists('public', $queryArray)) { + $this->public = (bool) $queryArray['public']; + } + if (array_key_exists('archived', $queryArray)) { + $this->archived = (bool) $queryArray['archived']; + } + if (array_key_exists('mature', $queryArray)) { + $this->mature = (bool) $queryArray['mature']; + } + if (array_key_exists('spam', $queryArray)) { + $this->spam = (bool) $queryArray['spam']; + } + if (array_key_exists('deleted', $queryArray)) { + $this->deleted = (bool) $queryArray['deleted']; + } + if (array_key_exists('lang_id', $queryArray)) { + $this->langId = (int) $queryArray['lang_id']; + } + if (array_key_exists('update_site_cache', $queryArray)) { + $this->updateSiteCache = (bool) $queryArray['update_site_cache']; + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function includeSites(int|array $ids): self + { + $this->siteIds->include($ids); + + return $this; + } + + public function excludeSites(int|array $ids): self + { + $this->siteIds->exclude($ids); + + return $this; + } + + public function networkId(int $id): self + { + $this->networkId = $id; + + return $this; + } + + public function search(string $value): self + { + $this->search = $value; + + return $this; + } + + public function toArray(): array + { + $args = []; + $args += $this->siteIds->toArray(); + $args += $this->networkIds->toArray(); + $values = [ + 'ID' => $this->siteId, + 'network_id' => $this->networkId, + 'domain' => $this->domain, + 'path' => $this->path, + 'search' => $this->search, + 'number' => $this->number, + 'offset' => $this->offset, + 'orderby' => $this->orderby, + 'order' => $this->order, + 'fields' => $this->fields, + 'public' => $this->public, + 'archived' => $this->archived, + 'mature' => $this->mature, + 'spam' => $this->spam, + 'deleted' => $this->deleted, + 'lang_id' => $this->langId, + 'update_site_cache' => $this->updateSiteCache, + ]; + foreach ($values as $key => $value) { + if (null !== $value) { + $args[$key] = $value; + } + } + + return $args; + } +} diff --git a/src/WordPress/TaxQueryArg.php b/src/WordPress/TaxQueryArg.php new file mode 100644 index 0000000..970b6ba --- /dev/null +++ b/src/WordPress/TaxQueryArg.php @@ -0,0 +1,235 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class TaxQueryArg implements QueryArg +{ + + private array $query = []; + + public function __construct(array $queryArray = []) + { + $this->query = $queryArray; + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function where( + string $taxonomy, + int|string|array $terms, + string $field = 'term_id', + string $operator = 'IN', + bool $includeChildren = true + ): self { + return $this->addClause( + [ + 'taxonomy' => $taxonomy, + 'terms' => $terms, + 'field' => $field, + 'operator' => $operator, + 'include_children' => $includeChildren, + ], + 'AND' + ); + } + + public function orWhere( + string $taxonomy, + int|string|array $terms, + string $field = 'term_id', + string $operator = 'IN', + bool $includeChildren = true + ): self { + return $this->addClause( + [ + 'taxonomy' => $taxonomy, + 'terms' => $terms, + 'field' => $field, + 'operator' => $operator, + 'include_children' => $includeChildren, + ], + 'OR' + ); + } + + public function whereIn( + string $taxonomy, + int|string|array $terms, + string $field = 'term_id', + bool $includeChildren = true + ): self { + return $this->where( + $taxonomy, + $terms, + $field, + 'IN', + $includeChildren + ); + } + + public function whereNotIn( + string $taxonomy, + int|string|array $terms, + string $field = 'term_id', + bool $includeChildren = true + ): self { + return $this->where( + $taxonomy, + $terms, + $field, + 'NOT IN', + $includeChildren + ); + } + + public function whereAnd( + string $taxonomy, + int|string|array $terms, + string $field = 'term_id' + ): self { + return $this->where( + $taxonomy, + $terms, + $field, + 'AND', + false + ); + } + + public function whereExists(string $taxonomy): self + { + return $this->addClause( + [ + 'taxonomy' => $taxonomy, + 'operator' => 'EXISTS', + ], + 'AND' + ); + } + + public function whereNotExists(string $taxonomy): self + { + return $this->addClause( + [ + 'taxonomy' => $taxonomy, + 'operator' => 'NOT EXISTS', + ], + 'AND' + ); + } + + public function whereGroup( + callable $callback, + string $relation = 'AND' + ): self { + return $this->addGroup($callback, $relation, 'AND'); + } + + public function orWhereGroup( + callable $callback, + string $relation = 'AND' + ): self { + return $this->addGroup($callback, $relation, 'OR'); + } + + public function whereAny(callable $callback): self + { + return $this->whereGroup($callback, 'OR'); + } + + public function whereAll(callable $callback): self + { + return $this->whereGroup($callback, 'AND'); + } + + public function whereClause( + array $clause, + string $boolean = 'AND' + ): self { + return $this->addClause($clause, $boolean); + } + + private function addClause(array $clause, string $boolean): self + { + $boolean = 'OR' === strtoupper($boolean) ? 'OR' : 'AND'; + + if ([] === $this->query) { + $this->query = [$clause]; + + return $this; + } + + if ('OR' === $boolean) { + if (isset($this->query['relation']) && 'OR' === $this->query['relation']) { + $this->query[] = $clause; + } else { + $this->query = [ + 'relation' => 'OR', + $this->query, + $clause, + ]; + } + + return $this; + } + + if (!isset($this->query['relation']) || 'AND' === $this->query['relation']) { + $this->query[] = $clause; + } else { + $this->query = [ + 'relation' => 'AND', + $this->query, + $clause, + ]; + } + + return $this; + } + + private function addGroup( + callable $callback, + string $relation, + string $boolean + ): self { + $group = new static(); + + $callback($group); + + $query = $group->toArray(); + + if ([] === $query) { + return $this; + } + + $relation = 'OR' === strtoupper($relation) ? 'OR' : 'AND'; + + if (isset($query['relation'])) { + $query['relation'] = $relation; + } else { + $query = [ + 'relation' => $relation, + $query, + ]; + } + + return $this->addClause($query, $boolean); + } + + public function toArray(): array + { + return $this->query; + } +} diff --git a/src/WordPress/TermQueryArg.php b/src/WordPress/TermQueryArg.php new file mode 100644 index 0000000..511b28d --- /dev/null +++ b/src/WordPress/TermQueryArg.php @@ -0,0 +1,169 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class TermQueryArg implements QueryArg +{ + private IncludeExcludeQueryArg $termIds; + private IncludeExcludeQueryArg $parentIds; + private ?int $termId = null; + private ?int $parent = null; + private string|array|null $taxonomy = null; + private ?string $name = null; + private ?string $slug = null; + private ?string $search = null; + private ?string $nameLike = null; + private ?string $descriptionLike = null; + private ?int $number = null; + private ?int $offset = null; + private string|array|null $orderby = null; + private ?string $order = null; + private ?string $fields = null; + private ?bool $hideEmpty = null; + private ?bool $hierarchical = null; + private ?bool $padCounts = null; + private ?bool $get = null; + private ?bool $updateTermMetaCache = null; + private ?string $cacheDomain = null; + + public function __construct(array $queryArray = []) + { + $this->termIds = IncludeExcludeQueryArg::make(['include' => $queryArray['include'] ?? [], 'exclude' => $queryArray['exclude'] ?? []], ['includeKey' => 'include', 'excludeKey' => 'exclude']); + $this->parentIds = IncludeExcludeQueryArg::make(['parent__in' => $queryArray['parent__in'] ?? [], 'parent__not_in' => $queryArray['parent__not_in'] ?? []], ['includeKey' => 'parent__in', 'excludeKey' => 'parent__not_in']); + if (array_key_exists('term_id', $queryArray)) { + $this->termId = (int) $queryArray['term_id']; + } + if (array_key_exists('parent', $queryArray)) { + $this->parent = (int) $queryArray['parent']; + } + if (array_key_exists('taxonomy', $queryArray)) { + $this->taxonomy = is_array($queryArray['taxonomy']) ? array_values(array_map('strval', $queryArray['taxonomy'])) : (string) $queryArray['taxonomy']; + } + if (array_key_exists('name', $queryArray)) { + $this->name = (string) $queryArray['name']; + } + if (array_key_exists('slug', $queryArray)) { + $this->slug = (string) $queryArray['slug']; + } + if (array_key_exists('search', $queryArray)) { + $this->search = (string) $queryArray['search']; + } + if (array_key_exists('name__like', $queryArray)) { + $this->nameLike = (string) $queryArray['name__like']; + } + if (array_key_exists('description__like', $queryArray)) { + $this->descriptionLike = (string) $queryArray['description__like']; + } + if (array_key_exists('number', $queryArray)) { + $this->number = (int) $queryArray['number']; + } + if (array_key_exists('offset', $queryArray)) { + $this->offset = (int) $queryArray['offset']; + } + if (array_key_exists('orderby', $queryArray)) { + $this->orderby = $queryArray['orderby']; + } + if (array_key_exists('order', $queryArray)) { + $this->order = (string) $queryArray['order']; + } + if (array_key_exists('fields', $queryArray)) { + $this->fields = (string) $queryArray['fields']; + } + if (array_key_exists('hide_empty', $queryArray)) { + $this->hideEmpty = (bool) $queryArray['hide_empty']; + } + if (array_key_exists('hierarchical', $queryArray)) { + $this->hierarchical = (bool) $queryArray['hierarchical']; + } + if (array_key_exists('pad_counts', $queryArray)) { + $this->padCounts = (bool) $queryArray['pad_counts']; + } + if (array_key_exists('get', $queryArray)) { + $this->get = (bool) $queryArray['get']; + } + if (array_key_exists('update_term_meta_cache', $queryArray)) { + $this->updateTermMetaCache = (bool) $queryArray['update_term_meta_cache']; + } + if (array_key_exists('cache_domain', $queryArray)) { + $this->cacheDomain = (string) $queryArray['cache_domain']; + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function includeTerms(int|array $ids): self + { + $this->termIds->include($ids); + + return $this; + } + + public function excludeTerms(int|array $ids): self + { + $this->termIds->exclude($ids); + + return $this; + } + + public function taxonomy(string|array $taxonomy): self + { + $this->taxonomy = $taxonomy; + + return $this; + } + + public function search(string $value): self + { + $this->search = $value; + + return $this; + } + + public function toArray(): array + { + $args = []; + $args += $this->termIds->toArray(); + $args += $this->parentIds->toArray(); + $values = [ + 'term_id' => $this->termId, + 'parent' => $this->parent, + 'taxonomy' => $this->taxonomy, + 'name' => $this->name, + 'slug' => $this->slug, + 'search' => $this->search, + 'name__like' => $this->nameLike, + 'description__like' => $this->descriptionLike, + 'number' => $this->number, + 'offset' => $this->offset, + 'orderby' => $this->orderby, + 'order' => $this->order, + 'fields' => $this->fields, + 'hide_empty' => $this->hideEmpty, + 'hierarchical' => $this->hierarchical, + 'pad_counts' => $this->padCounts, + 'get' => $this->get, + 'update_term_meta_cache' => $this->updateTermMetaCache, + 'cache_domain' => $this->cacheDomain, + ]; + foreach ($values as $key => $value) { + if (null !== $value) { + $args[$key] = $value; + } + } + + return $args; + } +} diff --git a/src/WordPress/UserQueryArg.php b/src/WordPress/UserQueryArg.php new file mode 100644 index 0000000..79c85ef --- /dev/null +++ b/src/WordPress/UserQueryArg.php @@ -0,0 +1,836 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\WordPressDatabase\WordPress; + +class UserQueryArg implements QueryArg +{ + public const USER_COLUMNS = [ + 'ID', + 'user_login', + 'user_pass', + 'user_nicename', + 'user_email', + 'user_url', + 'user_registered', + 'user_activation_key', + 'user_status', + 'display_name', + ]; + + private IncludeExcludeQueryArg $idQuery; + + private IncludeExcludeIsQueryArg $roleQuery; + + private IncludeExcludeIsQueryArg $capabilityQuery; + + private IncludeExcludeIsQueryArg $loginQuery; + + private IncludeExcludeIsQueryArg $nicenameQuery; + + private SearchQueryArg $searchQuery; + + private MetaQueryArg $metaQuery; + + private string|array|null $orderBy = null; + + private ?string $order = null; + + private ?int $number = null; + + private ?int $offset = null; + + private string|array|null $fields = null; + + private ?bool $countTotal = null; + + private ?int $blogId = null; + + private ?string $metaKey = null; + + private mixed $metaValue = null; + + private bool $hasMetaValue = false; + + private ?string $metaCompare = null; + + private ?string $metaType = null; + + private ?string $metaCompareKey = null; + + private ?string $metaTypeKey = null; + + private ?int $paged = null; + + private ?string $who = null; + + private bool|array|null $hasPublishedPosts = null; + + private ?bool $cacheResults = null; + + public function __construct(array $queryArray = []) + { + $this->idQuery = IncludeExcludeQueryArg::make( + [ + 'included' => $queryArray['include'] ?? [], + 'excluded' => $queryArray['exclude'] ?? [], + ], + [ + 'includeKey' => 'included', + 'excludeKey' => 'excluded', + ] + ); + + $this->roleQuery = IncludeExcludeIsQueryArg::make( + [ + 'included' => $queryArray['role__in'] ?? [], + 'excluded' => $queryArray['role__not_in'] ?? [], + 'is' => isset($queryArray['role']) + ? (array) $queryArray['role'] + : null, + ], + [ + 'isIdentifier' => false, + 'multiValue' => true, + 'includeKey' => 'included', + 'excludeKey' => 'excluded', + ] + ); + + $this->capabilityQuery = IncludeExcludeIsQueryArg::make( + [ + 'included' => $queryArray['capability__in'] ?? [], + 'excluded' => $queryArray['capability__not_in'] ?? [], + 'is' => isset($queryArray['capability']) + ? (array) $queryArray['capability'] + : null, + ], + [ + 'isIdentifier' => false, + 'multiValue' => true, + 'includeKey' => 'included', + 'excludeKey' => 'excluded', + ] + ); + + $this->loginQuery = IncludeExcludeIsQueryArg::make( + [ + 'included' => $queryArray['login__in'] ?? [], + 'excluded' => $queryArray['login__not_in'] ?? [], + 'is' => $queryArray['login'] ?? null, + ], + [ + 'isIdentifier' => false, + 'includeKey' => 'included', + 'excludeKey' => 'excluded', + ] + ); + + $this->nicenameQuery = IncludeExcludeIsQueryArg::make( + [ + 'included' => $queryArray['nicename__in'] ?? [], + 'excluded' => $queryArray['nicename__not_in'] ?? [], + 'is' => $queryArray['nicename'] ?? null, + ], + [ + 'isIdentifier' => false, + 'includeKey' => 'included', + 'excludeKey' => 'excluded', + ] + ); + + $this->searchQuery = SearchQueryArg::make( + [ + 'search' => $queryArray['search'] ?? '', + 'searchColumns' => $queryArray['search_columns'] ?? [], + ], + [ + 'allowedSearchColumns' => [ + 'ID', + 'user_login', + 'user_email', + 'user_url', + 'user_nicename', + 'display_name', + ], + ] + ); + + $this->metaQuery = MetaQueryArg::make( + $queryArray['meta_query'] ?? [] + ); + + if (isset($queryArray['orderby'])) { + $this->orderBy = $queryArray['orderby']; + } + + if (isset($queryArray['order'])) { + $this->order = 'DESC' === \strtoupper($queryArray['order']) + ? 'DESC' + : 'ASC'; + } + + if (isset($queryArray['number'])) { + $number = (int) $queryArray['number']; + $this->number = -1 === $number ? -1 : \max(0, $number); + } + + if (isset($queryArray['offset'])) { + $this->offset = \max(0, (int) $queryArray['offset']); + } + + if (\array_key_exists('fields', $queryArray)) { + $this->fields = $queryArray['fields']; + } + + if (isset($queryArray['count_total'])) { + $this->countTotal = (bool) $queryArray['count_total']; + } + + if (isset($queryArray['blog_id'])) { + $this->blogId = (int) $queryArray['blog_id']; + } + + if (isset($queryArray['meta_key'])) { + $this->metaKey = (string) $queryArray['meta_key']; + } + + if (\array_key_exists('meta_value', $queryArray)) { + $this->metaValue = $queryArray['meta_value']; + $this->hasMetaValue = true; + } + + if (isset($queryArray['meta_compare'])) { + $this->metaCompare = (string) $queryArray['meta_compare']; + } + + if (isset($queryArray['meta_type'])) { + $this->metaType = (string) $queryArray['meta_type']; + } + + if (isset($queryArray['meta_compare_key'])) { + $this->metaCompareKey = (string) $queryArray['meta_compare_key']; + } + + if (isset($queryArray['meta_type_key'])) { + $this->metaTypeKey = (string) $queryArray['meta_type_key']; + } + + if (isset($queryArray['paged'])) { + $this->paged = \max(1, (int) $queryArray['paged']); + } + + if (isset($queryArray['who'])) { + $this->who = (string) $queryArray['who']; + } + + if (\array_key_exists('has_published_posts', $queryArray)) { + $value = $queryArray['has_published_posts']; + $this->hasPublishedPosts = \is_bool($value) || null === $value + ? $value + : (array) $value; + } + + if (isset($queryArray['cache_results'])) { + $this->cacheResults = (bool) $queryArray['cache_results']; + } + } + + public static function make(array $queryArray = [], array $args = []): static + { + return new static($queryArray); + } + + public function includeIds(int|float|string|array $ids): self + { + $this->idQuery->include($ids); + + return $this; + } + + public function excludeIds(int|float|string|array $ids): self + { + $this->idQuery->exclude($ids); + + return $this; + } + + public function role(string|array $roles): self + { + $this->roleQuery->is( + \is_array($roles) ? $roles : [$roles] + ); + + return $this; + } + + public function rolesIn(string|array $roles): self + { + $this->roleQuery->include($roles); + + return $this; + } + + public function rolesNotIn(string|array $roles): self + { + $this->roleQuery->exclude($roles); + + return $this; + } + + public function capability(string|array $capabilities): self + { + $this->capabilityQuery->is(\is_array($capabilities) ? $capabilities : [$capabilities]); + + return $this; + } + + public function capabilitiesIn(string|array $capabilities): self + { + $this->capabilityQuery->include($capabilities); + + return $this; + } + + public function capabilitiesNotIn(string|array $capabilities): self + { + $this->capabilityQuery->exclude($capabilities); + + return $this; + } + + public function blogId(int $blogId): self + { + $this->blogId = $blogId; + + return $this; + } + + public function metaKey(string $key): self + { + $this->metaKey = $key; + + return $this; + } + + public function metaValue(mixed $value): self + { + $this->metaValue = $value; + $this->hasMetaValue = true; + + return $this; + } + + public function metaCompare(string $comparison): self + { + $this->metaCompare = $comparison; + + return $this; + } + + public function metaType(string $type): self + { + $this->metaType = $type; + + return $this; + } + + public function metaCompareKey(string $comparison): self + { + $this->metaCompareKey = $comparison; + + return $this; + } + + public function metaTypeKey(string $type): self + { + $this->metaTypeKey = $type; + + return $this; + } + + public function paged(int $page): self + { + $this->paged = \max(1, $page); + + return $this; + } + + public function who(string $who): self + { + $this->who = $who; + + return $this; + } + + public function hasPublishedPosts(bool|array|null $postTypes): self + { + $this->hasPublishedPosts = $postTypes; + + return $this; + } + + public function cacheResults(bool $cache = true): self + { + $this->cacheResults = $cache; + + return $this; + } + + public function login(string $login): self + { + $this->loginQuery->is($login); + + return $this; + } + + public function loginsIn(string|array $logins): self + { + $this->loginQuery->include($logins); + + return $this; + } + + public function loginsNotIn(string|array $logins): self + { + $this->loginQuery->exclude($logins); + + return $this; + } + + public function nicename(string $nicename): self + { + $this->nicenameQuery->is($nicename); + + return $this; + } + + public function nicenamesIn(string|array $nicenames): self + { + $this->nicenameQuery->include($nicenames); + + return $this; + } + + public function nicenamesNotIn(string|array $nicenames): self + { + $this->nicenameQuery->exclude($nicenames); + + return $this; + } + + public function search(string $value): self + { + $this->searchQuery->search($value); + + return $this; + } + + public function searchColumns(string|array $columns): self + { + $this->searchQuery->searchColumns($columns); + + return $this; + } + + public function searchByLogin(string $value): self + { + return $this->search($value) + ->searchColumns('user_login'); + } + + public function searchByEmail(string $value): self + { + return $this->search($value) + ->searchColumns('user_email'); + } + + public function searchByUrl(string $value): self + { + return $this->search($value) + ->searchColumns('user_url'); + } + + public function searchByNicename(string $value): self + { + return $this->search($value) + ->searchColumns('user_nicename'); + } + + public function searchByDisplayName(string $value): self + { + return $this->search($value) + ->searchColumns('display_name'); + } + + public function orderBy(string|array $field, string $direction = 'ASC'): self + { + $this->orderBy = $field; + $this->order = 'DESC' === \strtoupper($direction) + ? 'DESC' + : 'ASC'; + + return $this; + } + + public function limit(int $number): self + { + $this->number = -1 === $number ? -1 : \max(0, $number); + + return $this; + } + + public function offset(int $number): self + { + $this->offset = \max(0, $number); + + return $this; + } + + public function select(string|array $fields): self + { + $this->fields = $fields; + + return $this; + } + + public function countTotal(bool $count = true): self + { + $this->countTotal = $count; + + return $this; + } + + public function where( + string $key, + mixed $operator, + mixed $value = null, + string $type = 'CHAR' + ): self { + if (2 === \func_num_args()) { + $this->metaQuery->where($key, $operator); + } elseif (3 === \func_num_args()) { + $this->metaQuery->where($key, $operator, $value); + } else { + $this->metaQuery->where($key, $operator, $value, $type); + } + + return $this; + } + + public function orWhere( + string $key, + mixed $operator, + mixed $value = null, + string $type = 'CHAR' + ): self { + if (2 === \func_num_args()) { + $this->metaQuery->orWhere($key, $operator); + } elseif (3 === \func_num_args()) { + $this->metaQuery->orWhere($key, $operator, $value); + } else { + $this->metaQuery->orWhere($key, $operator, $value, $type); + } + + return $this; + } + + public function whereIn( + string $key, + array $values, + string $type = 'CHAR' + ): self { + $this->metaQuery->whereIn($key, $values, $type); + + return $this; + } + + public function whereNotIn( + string $key, + array $values, + string $type = 'CHAR' + ): self { + $this->metaQuery->whereNotIn($key, $values, $type); + + return $this; + } + + public function whereBetween( + string $key, + array $values, + string $type = 'CHAR' + ): self { + $this->metaQuery->whereBetween($key, $values, $type); + + return $this; + } + + public function whereNotBetween( + string $key, + array $values, + string $type = 'CHAR' + ): self { + $this->metaQuery->whereNotBetween($key, $values, $type); + + return $this; + } + + public function whereLike(string $key, string $value): self + { + $this->metaQuery->whereLike($key, $value); + + return $this; + } + + public function whereNotLike(string $key, string $value): self + { + $this->metaQuery->whereNotLike($key, $value); + + return $this; + } + + public function whereRegexp(string $key, string $pattern): self + { + $this->metaQuery->whereRegexp($key, $pattern); + + return $this; + } + + public function whereNotRegexp(string $key, string $pattern): self + { + $this->metaQuery->whereNotRegexp($key, $pattern); + + return $this; + } + + public function whereExists(string $key): self + { + $this->metaQuery->whereExists($key); + + return $this; + } + + public function whereNotExists(string $key): self + { + $this->metaQuery->whereNotExists($key); + + return $this; + } + + public function whereMetaKey( + string $key, + string $comparison = '=', + string $type = '' + ): self { + $this->metaQuery->whereMetaKey($key, $comparison, $type); + + return $this; + } + + public function whereGroup( + callable $callback, + string $relation = 'AND' + ): self { + $this->metaQuery->whereGroup($callback, $relation); + + return $this; + } + + public function orWhereGroup( + callable $callback, + string $relation = 'AND' + ): self { + $this->metaQuery->orWhereGroup($callback, $relation); + + return $this; + } + + public function whereAny(callable $callback): self + { + $this->metaQuery->whereAny($callback); + + return $this; + } + + public function whereAll(callable $callback): self + { + $this->metaQuery->whereAll($callback); + + return $this; + } + + public function whereClause( + array $clause, + string $boolean = 'AND' + ): self { + $this->metaQuery->whereClause($clause, $boolean); + + return $this; + } + + public function toArray(): array + { + $args = []; + + $idQuery = $this->idQuery->toArray(); + + if (isset($idQuery['included'])) { + $args['include'] = $idQuery['included']; + } + + if (isset($idQuery['excluded'])) { + $args['exclude'] = $idQuery['excluded']; + } + + $roleQuery = $this->roleQuery->toArray(); + + if (isset($roleQuery['included'])) { + $args['role__in'] = $roleQuery['included']; + } + + if (isset($roleQuery['excluded'])) { + $args['role__not_in'] = $roleQuery['excluded']; + } + + if (isset($roleQuery['is'])) { + $args['role'] = $roleQuery['is']; + } + + $capabilityQuery = $this->capabilityQuery->toArray(); + + if (isset($capabilityQuery['included'])) { + $args['capability__in'] = $capabilityQuery['included']; + } + + if (isset($capabilityQuery['excluded'])) { + $args['capability__not_in'] = $capabilityQuery['excluded']; + } + + if (isset($capabilityQuery['is'])) { + $args['capability'] = $capabilityQuery['is']; + } + + $loginQuery = $this->loginQuery->toArray(); + + if (isset($loginQuery['included'])) { + $args['login__in'] = $loginQuery['included']; + } + + if (isset($loginQuery['excluded'])) { + $args['login__not_in'] = $loginQuery['excluded']; + } + + if (isset($loginQuery['is'])) { + $args['login'] = $loginQuery['is']; + } + + $nicenameQuery = $this->nicenameQuery->toArray(); + + if (isset($nicenameQuery['included'])) { + $args['nicename__in'] = $nicenameQuery['included']; + } + + if (isset($nicenameQuery['excluded'])) { + $args['nicename__not_in'] = $nicenameQuery['excluded']; + } + + if (isset($nicenameQuery['is'])) { + $args['nicename'] = $nicenameQuery['is']; + } + + $searchQuery = $this->searchQuery->toArray(); + + if (isset($searchQuery['search'])) { + $args['search'] = $searchQuery['search']; + } + + if (isset($searchQuery['searchColumns'])) { + $args['search_columns'] = $searchQuery['searchColumns']; + } + + if (null !== $this->orderBy) { + $args['orderby'] = $this->orderBy; + } + + if (null !== $this->order) { + $args['order'] = $this->order; + } + + if (null !== $this->number) { + $args['number'] = $this->number; + } + + if (null !== $this->offset) { + $args['offset'] = $this->offset; + } + + if (null !== $this->fields) { + $args['fields'] = $this->fields; + } + + if (null !== $this->countTotal) { + $args['count_total'] = $this->countTotal; + } + + if (null !== $this->blogId) { + $args['blog_id'] = $this->blogId; + } + + if (null !== $this->metaKey) { + $args['meta_key'] = $this->metaKey; + } + + if ($this->hasMetaValue) { + $args['meta_value'] = $this->metaValue; + } + + if (null !== $this->metaCompare) { + $args['meta_compare'] = $this->metaCompare; + } + + if (null !== $this->metaType) { + $args['meta_type'] = $this->metaType; + } + + if (null !== $this->metaCompareKey) { + $args['meta_compare_key'] = $this->metaCompareKey; + } + + if (null !== $this->metaTypeKey) { + $args['meta_type_key'] = $this->metaTypeKey; + } + + if (null !== $this->paged) { + $args['paged'] = $this->paged; + } + + if (null !== $this->who) { + $args['who'] = $this->who; + } + + if (null !== $this->hasPublishedPosts) { + $args['has_published_posts'] = $this->hasPublishedPosts; + } + + if (null !== $this->cacheResults) { + $args['cache_results'] = $this->cacheResults; + } + + $metaQuery = $this->metaQuery->toArray(); + + if ([] !== $metaQuery) { + $args['meta_query'] = $metaQuery; + } + + return $args; + } +} diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php new file mode 100644 index 0000000..a2a517a --- /dev/null +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -0,0 +1,53 @@ +postType('product') + ->postStatus('publish') + ->postsPerPage(10) + ->page(2) + ->metaQuery(MetaQueryArg::make()->where('featured', true)) + ->taxQuery(TaxQueryArg::make()->whereIn('product_cat', [4, 7])) + ->dateQuery(DateQueryArg::make()->year(2025)) + ->toArray(); + + self::assertSame('product', $args['post_type']); + self::assertSame(['publish'], $args['post_status']); + self::assertSame(10, $args['posts_per_page']); + self::assertSame(2, $args['paged']); + self::assertSame([['key'=>'featured','value'=>true,'compare'=>'=','type'=>'CHAR']], $args['meta_query']); + self::assertSame('product_cat', $args['tax_query'][0]['taxonomy']); + self::assertSame(2025, $args['date_query'][0]['year']); + } + + public function testUserBuilderPreservesExplicitNullMetaValue(): void + { + $args = UserQueryArg::make()->metaKey('score')->metaValue(null)->toArray(); + + self::assertSame('score', $args['meta_key']); + self::assertArrayHasKey('meta_value', $args); + self::assertNull($args['meta_value']); + } + + public function testEmptyBuildersReturnEmptyArrays(): void + { + self::assertSame([], PostQueryArg::make()->toArray()); + self::assertSame([], UserQueryArg::make()->toArray()); + self::assertSame([], MetaQueryArg::make()->toArray()); + self::assertSame([], TaxQueryArg::make()->toArray()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..53ab368 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ + Date: Thu, 12 Nov 2026 01:26:13 +0000 Subject: [PATCH 02/70] Fix Composer namespace mappings and test namespaces --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 00100f0..3055fe6 100644 --- a/composer.json +++ b/composer.json @@ -7,16 +7,16 @@ "php": ">=8.0" }, "require-dev": { - "phpunit/phpunit": "^9.6 || ^10.5" + "phpunit/phpunit": "^9.6 || ^10.5 || ^11.5 || ^12.0" }, "autoload": { "psr-4": { - "Pentarix\\\\WordPressDatabase\\\\": "src/" + "Pentarix\\WordPressDatabase\\": "src/" } }, "autoload-dev": { "psr-4": { - "Pentarix\\\\WordPressDatabase\\\\Tests\\\\": "tests/" + "Pentarix\\WordPressDatabase\\Tests\\": "tests/" } }, "scripts": { From 44a251aa9306e9113a2ee20ed667b2f440b49f2c Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 01:26:16 +0000 Subject: [PATCH 03/70] Fix Composer namespace mappings and test namespaces --- tests/WordPress/QueryArgumentBuildersTest.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index a2a517a..6b92b95 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Pentarix\\WordPressDatabase\\Tests\\WordPress; +namespace Pentarix\WordPressDatabase\Tests\WordPress; -use PHPUnit\\Framework\\TestCase; -use Pentarix\\WordPressDatabase\\WordPress\\DateQueryArg; -use Pentarix\\WordPressDatabase\\WordPress\\MetaQueryArg; -use Pentarix\\WordPressDatabase\\WordPress\\PostQueryArg; -use Pentarix\\WordPressDatabase\\WordPress\\TaxQueryArg; -use Pentarix\\WordPressDatabase\\WordPress\\UserQueryArg; +use PHPUnit\Framework\TestCase; +use Pentarix\WordPressDatabase\WordPress\DateQueryArg; +use Pentarix\WordPressDatabase\WordPress\MetaQueryArg; +use Pentarix\WordPressDatabase\WordPress\PostQueryArg; +use Pentarix\WordPressDatabase\WordPress\TaxQueryArg; +use Pentarix\WordPressDatabase\WordPress\UserQueryArg; final class QueryArgumentBuildersTest extends TestCase { @@ -29,7 +29,7 @@ public function testPostBuilderComposesWordPressArguments(): void self::assertSame(['publish'], $args['post_status']); self::assertSame(10, $args['posts_per_page']); self::assertSame(2, $args['paged']); - self::assertSame([['key'=>'featured','value'=>true,'compare'=>'=','type'=>'CHAR']], $args['meta_query']); + self::assertSame([['key' => 'featured', 'value' => true, 'compare' => '=', 'type' => 'CHAR']], $args['meta_query']); self::assertSame('product_cat', $args['tax_query'][0]['taxonomy']); self::assertSame(2025, $args['date_query'][0]['year']); } From 501e1d5d6461c855935343f10e8295baa4c74c4a Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 01:26:17 +0000 Subject: [PATCH 04/70] Fix Composer namespace mappings and test namespaces --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a90638f..0b7a77b 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ This package currently provides the WordPress query argument-building capabiliti composer require pentarix/wordpress-database ``` -Composer maps the `Pentarix\\WordPressDatabase\\` namespace to `src/` using PSR-4. +Composer maps the `Pentarix\WordPressDatabase\` namespace to `src/` using PSR-4. ## Usage ```php -use Pentarix\\WordPressDatabase\\WordPress\\MetaQueryArg; -use Pentarix\\WordPressDatabase\\WordPress\\PostQueryArg; +use Pentarix\WordPressDatabase\WordPress\MetaQueryArg; +use Pentarix\WordPressDatabase\WordPress\PostQueryArg; $args = PostQueryArg::make() ->postType('product') @@ -45,7 +45,7 @@ $query = new \\WP_Query($args); - `TermQueryArg` - `UserQueryArg` -Supporting builders (`IncludeQueryArg`, `IncludeExcludeQueryArg`, `IncludeExcludeIsQueryArg`, and `SearchQueryArg`) are also available under `Pentarix\\WordPressDatabase\\WordPress`. +Supporting builders (`IncludeQueryArg`, `IncludeExcludeQueryArg`, `IncludeExcludeIsQueryArg`, and `SearchQueryArg`) are also available under `Pentarix\WordPressDatabase\WordPress`. ## Scope From f632459b2631d9c56e21dddaf175f6ff594de2b0 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 01:26:31 +0000 Subject: [PATCH 05/70] Port comprehensive query argument builder unit tests --- tests/WordPress/QueryArgumentBuildersTest.php | 535 +++++++++++++++++- 1 file changed, 504 insertions(+), 31 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 6b92b95..68777e7 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -1,53 +1,526 @@ + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ -namespace Pentarix\WordPressDatabase\Tests\WordPress; +declare(strict_types=1); -use PHPUnit\Framework\TestCase; +use Pentarix\WordPressDatabase\WordPress\CommentQueryArg; use Pentarix\WordPressDatabase\WordPress\DateQueryArg; +use Pentarix\WordPressDatabase\WordPress\IncludeExcludeIsQueryArg; +use Pentarix\WordPressDatabase\WordPress\IncludeExcludeQueryArg; +use Pentarix\WordPressDatabase\WordPress\IncludeQueryArg; use Pentarix\WordPressDatabase\WordPress\MetaQueryArg; +use Pentarix\WordPressDatabase\WordPress\NetworkQueryArg; use Pentarix\WordPressDatabase\WordPress\PostQueryArg; +use Pentarix\WordPressDatabase\WordPress\QueryArg; +use Pentarix\WordPressDatabase\WordPress\SearchQueryArg; +use Pentarix\WordPressDatabase\WordPress\SiteQueryArg; use Pentarix\WordPressDatabase\WordPress\TaxQueryArg; +use Pentarix\WordPressDatabase\WordPress\TermQueryArg; use Pentarix\WordPressDatabase\WordPress\UserQueryArg; +use PHPUnit\Framework\TestCase; -final class QueryArgumentBuildersTest extends TestCase +final class WordPressQueryArgTest extends TestCase { - public function testPostBuilderComposesWordPressArguments(): void - { - $args = PostQueryArg::make() - ->postType('product') - ->postStatus('publish') - ->postsPerPage(10) - ->page(2) - ->metaQuery(MetaQueryArg::make()->where('featured', true)) - ->taxQuery(TaxQueryArg::make()->whereIn('product_cat', [4, 7])) - ->dateQuery(DateQueryArg::make()->year(2025)) - ->toArray(); - - self::assertSame('product', $args['post_type']); - self::assertSame(['publish'], $args['post_status']); - self::assertSame(10, $args['posts_per_page']); - self::assertSame(2, $args['paged']); - self::assertSame([['key' => 'featured', 'value' => true, 'compare' => '=', 'type' => 'CHAR']], $args['meta_query']); - self::assertSame('product_cat', $args['tax_query'][0]['taxonomy']); - self::assertSame(2025, $args['date_query'][0]['year']); + public function testQueryArgContractAndIncludeQueryArg(): void + { + $query = IncludeQueryArg::make(['include' => [-1, '2', 2, 3.9]], [ + 'includeKey' => 'include', + ]); + + self::assertInstanceOf(QueryArg::class, $query); + self::assertSame(['include' => [1, 2, 3]], $query->toArray()); + self::assertSame($query, $query->include(['4', -1])); + self::assertSame(['include' => [1, 2, 3, 4]], $query->toArray()); + self::assertSame([], IncludeQueryArg::make()->toArray()); + self::assertSame( + ['names' => ['alpha', 'beta']], + IncludeQueryArg::make(['names' => ['alpha', 'beta', 'alpha']], [ + 'isIdentifier' => false, + 'includeKey' => 'names', + ])->toArray() + ); } - public function testUserBuilderPreservesExplicitNullMetaValue(): void + public function testIncludeExcludeQueryArgKeepsListsDisjoint(): void { - $args = UserQueryArg::make()->metaKey('score')->metaValue(null)->toArray(); + $query = IncludeExcludeQueryArg::make( + ['only' => [1, 2, 2], 'except' => [3, 4]], + ['includeKey' => 'only', 'excludeKey' => 'except'] + ); - self::assertSame('score', $args['meta_key']); - self::assertArrayHasKey('meta_value', $args); - self::assertNull($args['meta_value']); + $query->include([3, 5])->exclude([1, 6]); + + self::assertSame(['only' => [2, 3, 5], 'except' => [4, 1, 6]], $query->toArray()); + self::assertSame($query, $query->exclude(5)); + self::assertSame(['only' => [2, 3], 'except' => [4, 1, 6, 5]], $query->toArray()); } - public function testEmptyBuildersReturnEmptyArrays(): void + public function testIncludeExcludeIsQueryArgSupportsSingleAndMultipleValues(): void { + $single = IncludeExcludeIsQueryArg::make( + ['is' => 'publish', 'included' => [1], 'excluded' => [2]], + ['isIdentifier' => false, 'includeKey' => 'included', 'excludeKey' => 'excluded'] + ); + self::assertSame( + ['included' => ['1'], 'excluded' => ['2'], 'is' => 'publish'], + $single->toArray() + ); + self::assertSame(['included' => ['1'], 'excluded' => ['2']], $single->is('')->toArray()); + + $multi = IncludeExcludeIsQueryArg::make( + ['is' => ['editor', '', 'author', 'editor']], + ['isIdentifier' => false, 'multiValue' => true] + ); + self::assertSame(['is' => ['editor', 'author']], $multi->toArray()); + self::assertSame(['is' => ['subscriber']], $multi->is(['subscriber', 'subscriber'])->toArray()); + self::assertSame([], $multi->is([])->toArray()); + } + + public function testIncludeExcludeIsQueryArgRejectsWrongIsValueShape(): void + { + $single = new IncludeExcludeIsQueryArg([], false); + $multi = new IncludeExcludeIsQueryArg([], false, true); + + $this->expectException(InvalidArgumentException::class); + $single->is(['publish']); + } + + public function testIncludeExcludeIsQueryArgRejectsScalarInMultiValueMode(): void + { + $query = new IncludeExcludeIsQueryArg([], false, true); + + $this->expectException(InvalidArgumentException::class); + $query->is('publish'); + } + + public function testSearchQueryArgValidatesAndNormalizesSearchColumns(): void + { + $query = SearchQueryArg::make( + ['search' => 'needle', 'searchColumns' => ['title', 'content', 'title', '']], + ['allowedSearchColumns' => ['title', 'content', 'title']] + ); + + self::assertSame( + ['search' => 'needle', 'searchColumns' => ['title', 'content']], + $query->toArray() + ); + self::assertSame(['searchColumns' => ['content']], $query->search('')->searchColumns('content')->toArray()); + self::assertSame([], $query->searchColumns([])->toArray()); + } + + public function testSearchQueryArgRequiresAllowedColumns(): void + { + $this->expectException(InvalidArgumentException::class); + SearchQueryArg::make(); + } + + public function testSearchQueryArgRejectsUnknownColumns(): void + { + $query = new SearchQueryArg(['title']); + + $this->expectException(InvalidArgumentException::class); + $query->searchColumns('unknown'); + } + + public function testSearchQueryArgRejectsNonStringColumns(): void + { + $query = new SearchQueryArg(['title']); + + $this->expectException(InvalidArgumentException::class); + $query->searchColumns(['title', 12]); + } + + public function testDateQueryArgBuildsClausesAndBooleanGroups(): void + { + $query = DateQueryArg::make() + ->after('2024-01-01') + ->orWhere(['year' => 2020]) + ->whereGroup( + static fn (DateQueryArg $group) => $group->month(1)->day(15), + 'OR' + ); + + $array = $query->toArray(); + self::assertSame('AND', $array['relation']); + self::assertSame('OR', $array[0]['relation']); + self::assertSame('2024-01-01', $array[0][0][0]['after']); + self::assertSame(2020, $array[0][1]['year']); + self::assertSame('OR', $array[1]['relation']); + self::assertSame(1, $array[1][0][0]['month']); + self::assertSame(15, $array[1][0][1]['day']); + } + + public function testDateQueryArgSupportsDateHelpersAndEmptyGroups(): void + { + $query = DateQueryArg::make() + ->before(['year' => 2023], true, 'post_modified') + ->between('2020-01-01', '2020-12-31') + ->week(3)->hour(10)->minute(20)->second(30) + ->whereAny(static function (DateQueryArg $group): void { + $group->year(2022); + }) + ->whereAll(static function (DateQueryArg $group): void { + }); + + self::assertCount(7, $query->toArray()); + self::assertSame('post_modified', $query->toArray()[0]['column']); + self::assertSame('OR', $query->toArray()[6]['relation']); + } + + public function testMetaQueryArgSupportsComparisonsAndGroups(): void + { + $query = MetaQueryArg::make() + ->where('color', 'blue') + ->whereIn('size', ['s', 'm']) + ->orWhere('price', '>', 10, 'NUMERIC') + ->whereMetaKey('rank', 'LIKE', 'BINARY') + ->whereNotExists('deprecated') + ->whereAny(static function (MetaQueryArg $group): void { + $group->whereLike('label', '%a%')->whereNotLike('label', '%b%'); + }); + + $array = $query->toArray(); + self::assertSame('AND', $array['relation']); + self::assertSame('OR', $array[0]['relation']); + self::assertSame('=', $array[0][0][0]['compare']); + self::assertSame('IN', $array[0][0][1]['compare']); + self::assertNotEmpty($array); + } + + public function testMetaQueryArgSupportsAllComparisonHelpers(): void + { + $query = MetaQueryArg::make() + ->whereNotIn('a', [1, 2]) + ->whereBetween('b', [1, 5]) + ->whereNotBetween('c', [2, 4]) + ->whereRegexp('d', '^x') + ->whereNotRegexp('e', '^y') + ->whereExists('f') + ->whereClause(['key' => 'custom'], 'invalid') + ->whereAll(static function (MetaQueryArg $group): void { + $group->where('nested', null); + }); + + self::assertCount(8, $query->toArray()); + self::assertSame('NOT IN', $query->toArray()[0]['compare']); + self::assertSame('EXISTS', $query->toArray()[5]['compare']); + } + + public function testTaxQueryArgBuildsConditionsAndGroups(): void + { + $query = TaxQueryArg::make() + ->where('category', [1, 2]) + ->orWhere('post_tag', 'news', 'slug', 'IN', false) + ->whereNotIn('genre', ['drama']) + ->whereAnd('actor', [3, 4]) + ->whereExists('language') + ->whereNotExists('obsolete') + ->whereGroup(static function (TaxQueryArg $group): void { + $group->whereIn('topic', [7])->whereExists('format'); + }, 'OR'); + + $array = $query->toArray(); + self::assertSame('AND', $array['relation']); + self::assertSame('OR', $array[0]['relation']); + self::assertSame('category', $array[0][0][0]['taxonomy']); + self::assertSame('NOT IN', $array[1]['operator']); + self::assertFalse($array[0][1]['include_children']); + self::assertSame('EXISTS', $array[3]['operator']); + } + + public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void + { + $date = DateQueryArg::make()->year(2024); + $meta = MetaQueryArg::make()->where('featured', true); + $tax = TaxQueryArg::make()->whereIn('category', [2]); + + $query = PostQueryArg::make([ + 'post__in' => [1, 2], + 'post__not_in' => [3], + 'author__in' => [7], + 'post_type' => ['post', 'page'], + 'post_status' => ['publish'], + 's' => 'hello', + 'posts_per_page' => 10, + 'paged' => 0, + 'offset' => -4, + 'order' => 'desc', + 'name' => 'sample', + 'post_name__in' => ['one', 'one', 2], + 'has_password' => false, + 'cache_results' => true, + ]) + ->includeIds(4) + ->excludeIds(1) + ->postType('book') + ->postTypesIn('page') + ->postStatus('draft') + ->postStatusesNotIn('private') + ->authorsIn('8') + ->parentsNotIn(9) + ->search('updated') + ->dateQuery($date) + ->metaQuery($meta) + ->taxQuery($tax) + ->postsPerPage(5) + ->page(3) + ->offset(2) + ->orderBy(['date' => 'DESC']) + ->order('invalid') + ->fields('ids'); + + $args = $query->toArray(); + self::assertSame([2, 4], $args['post__in']); + self::assertSame([3, 1], $args['post__not_in']); + self::assertSame(['page'], $args['post_type__in']); + self::assertSame(['book'], $args['post_type']); + self::assertSame('updated', $args['s']); + self::assertSame(5, $args['posts_per_page']); + self::assertSame(3, $args['paged']); + self::assertSame(2, $args['offset']); + self::assertSame('ASC', $args['order']); + self::assertSame('ids', $args['fields']); + self::assertSame($date->toArray(), $args['date_query']); + self::assertSame($meta->toArray(), $args['meta_query']); + self::assertSame($tax->toArray(), $args['tax_query']); + self::assertSame(['one', '2'], $args['post_name__in']); + } + + public function testPostQueryArgAcceptsArrayNestedQueriesAndEmptyDefaults(): void + { + $query = PostQueryArg::make() + ->dateQuery(['year' => 2020]) + ->metaQuery([['key' => 'x', 'value' => 1]]) + ->taxQuery([['taxonomy' => 'category', 'terms' => [1]]]); + + $args = $query->toArray(); + self::assertSame(['year' => 2020], $args['date_query']); + self::assertSame([['key' => 'x', 'value' => 1]], $args['meta_query']); + self::assertSame([['taxonomy' => 'category', 'terms' => [1]]], $args['tax_query']); self::assertSame([], PostQueryArg::make()->toArray()); + } + + public function testCommentQueryArgMapsConstructorAndFluentValues(): void + { + $query = CommentQueryArg::make([ + 'comment__in' => [1, 2], + 'comment__not_in' => [3], + 'post__in' => [4], + 'parent__not_in' => [5], + 'user__in' => [6], + 'post_id' => '7', + 'parent' => 8, + 'user_id' => 9, + 'author_email' => 'a@example.com', + 'author_url' => 'https://example.com', + 'author_ip' => '127.0.0.1', + 'type' => 'review', + 'status' => 'approve', + 'number' => 20, + 'offset' => 2, + 'orderby' => 'date', + 'order' => 'DESC', + 'fields' => 'ids', + 'hierarchical' => false, + 'count' => true, + 'no_found_rows' => true, + 'update_comment_meta_cache' => false, + 'update_comment_post_cache' => true, + ])->includeComments([3, 4])->excludeComments(1)->postId(10)->search('term'); + + $args = $query->toArray(); + self::assertSame([2, 3, 4], $args['comment__in']); + self::assertSame([1], $args['comment__not_in']); + self::assertSame(10, $args['post_id']); + self::assertSame('term', $args['search']); + self::assertFalse($args['hierarchical']); + self::assertTrue($args['update_comment_post_cache']); + self::assertSame([], CommentQueryArg::make()->toArray()); + } + + public function testNetworkQueryArgMapsIdsAndOptions(): void + { + $query = NetworkQueryArg::make([ + 'network__in' => [1], + 'network__not_in' => [2], + 'network_id' => '3', + 'domain' => 'example.com', + 'path' => '/', + 'number' => 10, + 'offset' => 2, + 'orderby' => 'id', + 'order' => 'DESC', + 'fields' => 'ids', + 'count' => false, + 'update_network_cache' => true, + ])->includeNetworks(4)->excludeNetworks(1)->networkId(5); + + $args = $query->toArray(); + self::assertSame([4], $args['network__in']); + self::assertSame([2, 1], $args['network__not_in']); + self::assertSame(5, $args['network_id']); + self::assertFalse($args['count']); + self::assertTrue($args['update_network_cache']); + self::assertSame([], NetworkQueryArg::make()->toArray()); + } + + public function testSiteQueryArgMapsIdsAndOptions(): void + { + $query = SiteQueryArg::make([ + 'site__in' => [1, 2], + 'site__not_in' => [3], + 'network__in' => [4], + 'ID' => '5', + 'network_id' => 6, + 'domain' => 'example.org', + 'path' => '/blog/', + 'search' => 'sample', + 'number' => 10, + 'offset' => 1, + 'orderby' => ['name' => 'ASC'], + 'order' => 'DESC', + 'fields' => 'ids', + 'public' => false, + 'archived' => true, + 'mature' => false, + 'spam' => true, + 'deleted' => false, + 'lang_id' => 2, + 'update_site_cache' => true, + ])->includeSites([3, 7])->excludeSites(1)->networkId(8)->search('changed'); + + $args = $query->toArray(); + self::assertSame([2, 3, 7], $args['site__in']); + self::assertSame([1], $args['site__not_in']); + self::assertSame(5, $args['ID']); + self::assertSame(8, $args['network_id']); + self::assertSame('changed', $args['search']); + self::assertFalse($args['public']); + self::assertTrue($args['archived']); + self::assertSame([], SiteQueryArg::make()->toArray()); + } + + public function testTermQueryArgMapsTaxonomyAndTermOptions(): void + { + $query = TermQueryArg::make([ + 'include' => [1, 2], + 'exclude' => [3], + 'parent__in' => [4], + 'term_id' => '5', + 'parent' => 6, + 'taxonomy' => ['category', 'post_tag'], + 'name' => 'News', + 'slug' => 'news', + 'search' => 'news', + 'name__like' => 'new', + 'description__like' => 'latest', + 'number' => 10, + 'offset' => 2, + 'orderby' => 'name', + 'order' => 'ASC', + 'fields' => 'ids', + 'hide_empty' => false, + 'hierarchical' => true, + 'pad_counts' => false, + 'get' => true, + 'update_term_meta_cache' => false, + 'cache_domain' => 'test', + ])->includeTerms(7)->excludeTerms(1)->taxonomy('post')->search('updated'); + + $args = $query->toArray(); + self::assertSame([2, 7], $args['include']); + self::assertSame([3, 1], $args['exclude']); + self::assertSame(5, $args['term_id']); + self::assertSame('post', $args['taxonomy']); + self::assertSame('updated', $args['search']); + self::assertFalse($args['hide_empty']); + self::assertSame('test', $args['cache_domain']); + self::assertSame([], TermQueryArg::make()->toArray()); + } + + public function testUserQueryArgMapsIdentitySearchMetaAndPagination(): void + { + $query = UserQueryArg::make([ + 'include' => [1, 2], + 'exclude' => [3], + 'role' => ['editor'], + 'role__in' => ['author'], + 'role__not_in' => ['subscriber'], + 'capability' => ['edit_posts'], + 'login' => 'alice', + 'login__in' => ['bob'], + 'nicename' => 'alice-name', + 'search' => 'alice', + 'search_columns' => ['user_email'], + 'orderby' => 'login', + 'order' => 'desc', + 'number' => 20, + 'offset' => 2, + 'fields' => ['ID', 'display_name'], + 'count_total' => false, + 'blog_id' => 4, + 'meta_key' => 'level', + 'meta_value' => 0, + 'meta_compare' => '>=', + 'meta_type' => 'NUMERIC', + 'meta_compare_key' => 'LIKE', + 'meta_type_key' => 'BINARY', + 'paged' => 0, + 'who' => 'authors', + 'has_published_posts' => ['post'], + 'cache_results' => true, + ])->includeIds(4)->excludeIds(1) + ->role('administrator')->rolesIn('editor')->rolesNotIn('author') + ->capability('manage_options')->capabilitiesIn('edit_pages')->capabilitiesNotIn('read') + ->login('root')->loginsIn('admin')->loginsNotIn('guest') + ->nicename('root-name')->nicenamesIn('nice')->nicenamesNotIn('not-nice') + ->searchByEmail('root@example.com') + ->orderBy('ID', 'DESC')->limit(5)->offset(3)->select('ID')->countTotal(true) + ->blogId(9)->metaKey('score')->metaValue(null)->metaCompare('=')->metaType('SIGNED') + ->metaCompareKey('=')->metaTypeKey('CHAR')->paged(2)->who('users') + ->hasPublishedPosts(false)->cacheResults(false) + ->where('active', 1)->orWhere('score', '>', 5)->whereExists('flag'); + + $args = $query->toArray(); + self::assertSame([2, 4], $args['include']); + self::assertSame([3, 1], $args['exclude']); + self::assertSame(['administrator'], $args['role']); + self::assertSame(['editor'], $args['role__in']); + self::assertSame(['subscriber', 'author'], $args['role__not_in']); + self::assertSame(['manage_options'], $args['capability']); + self::assertSame('root', $args['login']); + self::assertSame('root-name', $args['nicename']); + self::assertSame('root@example.com', $args['search']); + self::assertSame(['user_email'], $args['search_columns']); + self::assertSame('DESC', $args['order']); + self::assertSame(5, $args['number']); + self::assertSame(9, $args['blog_id']); + self::assertNull($args['meta_value']); + self::assertSame(2, $args['paged']); + self::assertFalse($args['has_published_posts']); + self::assertFalse($args['cache_results']); + self::assertArrayHasKey('meta_query', $args); + } + + public function testUserQueryArgSupportsEmptyAndAlternativeSearchMethods(): void + { self::assertSame([], UserQueryArg::make()->toArray()); - self::assertSame([], MetaQueryArg::make()->toArray()); - self::assertSame([], TaxQueryArg::make()->toArray()); + + $query = UserQueryArg::make() + ->searchByLogin('login') + ->searchByUrl('url') + ->searchByNicename('nice') + ->searchByDisplayName('display'); + + self::assertSame('display', $query->toArray()['search']); + self::assertSame(['display_name'], $query->toArray()['search_columns']); + self::assertContains('user_pass', UserQueryArg::USER_COLUMNS); } } From fd1a95369bd88f520607a376fa7799d79cc0f5e5 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 01:26:49 +0000 Subject: [PATCH 06/70] Add Composer validation and PHPUnit CI --- .github/workflows/tests.yml | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a30e104 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + push: + branches: + - "**" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + tests: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - "8.0" + - "8.1" + - "8.2" + - "8.3" + - "8.4" + - "8.5" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate Composer configuration + run: composer validate --strict + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction + + - name: Run unit tests + run: composer test From b4a7b877557fe2005aef7ceab0a339b3cb99d544 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 01:27:28 +0000 Subject: [PATCH 07/70] Align PHPUnit test class with its file name --- tests/WordPress/QueryArgumentBuildersTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 68777e7..f44608d 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -26,7 +26,7 @@ use Pentarix\WordPressDatabase\WordPress\UserQueryArg; use PHPUnit\Framework\TestCase; -final class WordPressQueryArgTest extends TestCase +final class QueryArgumentBuildersTest extends TestCase { public function testQueryArgContractAndIncludeQueryArg(): void { From 681456b0345c3556516dda7f5c9c65db1b119383 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:36:21 +0000 Subject: [PATCH 08/70] Add extensible WordPress query argument support --- src/WordPress/SupportsQueryArguments.php | 64 ++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/WordPress/SupportsQueryArguments.php diff --git a/src/WordPress/SupportsQueryArguments.php b/src/WordPress/SupportsQueryArguments.php new file mode 100644 index 0000000..b23d955 --- /dev/null +++ b/src/WordPress/SupportsQueryArguments.php @@ -0,0 +1,64 @@ + + * @copyright 2026 Sina Kuhestani (EphpicMan) + * @link https://github.com/ephpicman GitHub + * @link https://www.linkedin.com/in/ephpicman LinkedIn + * @license See the LICENSE file in the project root. + */ + +declare(strict_types=1); + +namespace Pentarix\\WordPressDatabase\\WordPress; + +trait SupportsQueryArguments +{ + private array $originalQueryArgs = []; + + private array $additionalQueryArgs = []; + + protected function initialiseQueryArguments(array $arguments): void + { + $this->originalQueryArgs = $arguments; + } + + /** + * Set or override a WordPress query argument. + */ + public function set(string $key, mixed $value): static + { + if ('' === $key) { + throw new \\InvalidArgumentException('Query argument keys cannot be empty.'); + } + + $this->additionalQueryArgs[$key] = $value; + + return $this; + } + + /** + * Set or override multiple WordPress query arguments. + */ + public function setMany(array $arguments): static + { + foreach ($arguments as $key => $value) { + if (!\\is_string($key) || '' === $key) { + throw new \\InvalidArgumentException('Query argument keys must be non-empty strings.'); + } + + $this->additionalQueryArgs[$key] = $value; + } + + return $this; + } + + protected function mergeQueryArguments(array $arguments): array + { + return \\array_replace( + $this->originalQueryArgs, + $arguments, + $this->additionalQueryArgs + ); + } +} From f837c2004c8ed7a35955d3dfc8e934c309ce3556 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:36:31 +0000 Subject: [PATCH 09/70] Preserve and expose complete WordPress query arguments --- src/WordPress/PostQueryArg.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/WordPress/PostQueryArg.php b/src/WordPress/PostQueryArg.php index 909ef4f..2d17357 100644 --- a/src/WordPress/PostQueryArg.php +++ b/src/WordPress/PostQueryArg.php @@ -14,6 +14,8 @@ class PostQueryArg implements QueryArg { + use SupportsQueryArguments; + private IncludeExcludeQueryArg $postIdQuery; private IncludeExcludeQueryArg $authorQuery; private IncludeExcludeQueryArg $parentQuery; @@ -49,6 +51,7 @@ class PostQueryArg implements QueryArg public function __construct(array $queryArray = []) { + $this->initialiseQueryArguments($queryArray); $this->postIdQuery = IncludeExcludeQueryArg::make([ 'post__in' => $queryArray['post__in'] ?? [], 'post__not_in' => $queryArray['post__not_in'] ?? [], @@ -269,6 +272,6 @@ public function toArray(): array if ([] !== $this->postNameIn) { $args['post_name__in'] = $this->postNameIn; } - return $args; + return $this->mergeQueryArguments($args); } } From bba994df2c05feb4e8e500b96dfeaf48953c48e4 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:36:34 +0000 Subject: [PATCH 10/70] Preserve and expose complete WordPress query arguments --- src/WordPress/UserQueryArg.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/WordPress/UserQueryArg.php b/src/WordPress/UserQueryArg.php index 79c85ef..fc572d9 100644 --- a/src/WordPress/UserQueryArg.php +++ b/src/WordPress/UserQueryArg.php @@ -14,6 +14,8 @@ class UserQueryArg implements QueryArg { + use SupportsQueryArguments; + public const USER_COLUMNS = [ 'ID', 'user_login', @@ -79,6 +81,7 @@ class UserQueryArg implements QueryArg public function __construct(array $queryArray = []) { + $this->initialiseQueryArguments($queryArray); $this->idQuery = IncludeExcludeQueryArg::make( [ 'included' => $queryArray['include'] ?? [], @@ -831,6 +834,6 @@ public function toArray(): array $args['meta_query'] = $metaQuery; } - return $args; + return $this->mergeQueryArguments($args); } } From 9f85913544130bf69a9a5143429f540f4f40cc12 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:36:37 +0000 Subject: [PATCH 11/70] Preserve and expose complete WordPress query arguments --- src/WordPress/CommentQueryArg.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/WordPress/CommentQueryArg.php b/src/WordPress/CommentQueryArg.php index 0f7ffe5..6f561e1 100644 --- a/src/WordPress/CommentQueryArg.php +++ b/src/WordPress/CommentQueryArg.php @@ -14,6 +14,8 @@ class CommentQueryArg implements QueryArg { + use SupportsQueryArguments; + private IncludeExcludeQueryArg $commentIds; private IncludeExcludeQueryArg $postIds; private IncludeExcludeQueryArg $parentIds; @@ -40,10 +42,12 @@ class CommentQueryArg implements QueryArg public function __construct(array $queryArray = []) { + unset($queryArray['user__in'], $queryArray['user__not_in']); + $this->initialiseQueryArguments($queryArray); $this->commentIds = IncludeExcludeQueryArg::make(['comment__in' => $queryArray['comment__in'] ?? [], 'comment__not_in' => $queryArray['comment__not_in'] ?? []], ['includeKey' => 'comment__in', 'excludeKey' => 'comment__not_in']); $this->postIds = IncludeExcludeQueryArg::make(['post__in' => $queryArray['post__in'] ?? [], 'post__not_in' => $queryArray['post__not_in'] ?? []], ['includeKey' => 'post__in', 'excludeKey' => 'post__not_in']); $this->parentIds = IncludeExcludeQueryArg::make(['parent__in' => $queryArray['parent__in'] ?? [], 'parent__not_in' => $queryArray['parent__not_in'] ?? []], ['includeKey' => 'parent__in', 'excludeKey' => 'parent__not_in']); - $this->userIds = IncludeExcludeQueryArg::make(['user__in' => $queryArray['user__in'] ?? [], 'user__not_in' => $queryArray['user__not_in'] ?? []], ['includeKey' => 'user__in', 'excludeKey' => 'user__not_in']); + $this->userIds = IncludeExcludeQueryArg::make(['author__in' => $queryArray['author__in'] ?? [], 'author__not_in' => $queryArray['author__not_in'] ?? []], ['includeKey' => 'author__in', 'excludeKey' => 'author__not_in']); if (array_key_exists('post_id', $queryArray)) { $this->postId = (int) $queryArray['post_id']; } @@ -170,6 +174,6 @@ public function toArray(): array } } - return $args; + return $this->mergeQueryArguments($args); } } From 7b2b9d29159bed9d412cb9eafe7fc27974caa358 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:36:40 +0000 Subject: [PATCH 12/70] Preserve and expose complete WordPress query arguments --- src/WordPress/TermQueryArg.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/WordPress/TermQueryArg.php b/src/WordPress/TermQueryArg.php index 511b28d..f0b0015 100644 --- a/src/WordPress/TermQueryArg.php +++ b/src/WordPress/TermQueryArg.php @@ -14,6 +14,8 @@ class TermQueryArg implements QueryArg { + use SupportsQueryArguments; + private IncludeExcludeQueryArg $termIds; private IncludeExcludeQueryArg $parentIds; private ?int $termId = null; @@ -38,6 +40,7 @@ class TermQueryArg implements QueryArg public function __construct(array $queryArray = []) { + $this->initialiseQueryArguments($queryArray); $this->termIds = IncludeExcludeQueryArg::make(['include' => $queryArray['include'] ?? [], 'exclude' => $queryArray['exclude'] ?? []], ['includeKey' => 'include', 'excludeKey' => 'exclude']); $this->parentIds = IncludeExcludeQueryArg::make(['parent__in' => $queryArray['parent__in'] ?? [], 'parent__not_in' => $queryArray['parent__not_in'] ?? []], ['includeKey' => 'parent__in', 'excludeKey' => 'parent__not_in']); if (array_key_exists('term_id', $queryArray)) { @@ -164,6 +167,6 @@ public function toArray(): array } } - return $args; + return $this->mergeQueryArguments($args); } } From cc49953d2d0a3594256e79c6c91ba2d9892300ab Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:36:42 +0000 Subject: [PATCH 13/70] Preserve and expose complete WordPress query arguments --- src/WordPress/SiteQueryArg.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/WordPress/SiteQueryArg.php b/src/WordPress/SiteQueryArg.php index 3b3974d..73f46ab 100644 --- a/src/WordPress/SiteQueryArg.php +++ b/src/WordPress/SiteQueryArg.php @@ -14,6 +14,8 @@ class SiteQueryArg implements QueryArg { + use SupportsQueryArguments; + private IncludeExcludeQueryArg $siteIds; private IncludeExcludeQueryArg $networkIds; private ?int $siteId = null; @@ -36,6 +38,7 @@ class SiteQueryArg implements QueryArg public function __construct(array $queryArray = []) { + $this->initialiseQueryArguments($queryArray); $this->siteIds = IncludeExcludeQueryArg::make(['site__in' => $queryArray['site__in'] ?? [], 'site__not_in' => $queryArray['site__not_in'] ?? []], ['includeKey' => 'site__in', 'excludeKey' => 'site__not_in']); $this->networkIds = IncludeExcludeQueryArg::make(['network__in' => $queryArray['network__in'] ?? [], 'network__not_in' => $queryArray['network__not_in'] ?? []], ['includeKey' => 'network__in', 'excludeKey' => 'network__not_in']); if (array_key_exists('ID', $queryArray)) { @@ -154,6 +157,6 @@ public function toArray(): array } } - return $args; + return $this->mergeQueryArguments($args); } } From 28223a531ce0a509f2879720839ac6453f6a08ce Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:36:45 +0000 Subject: [PATCH 14/70] Preserve and expose complete WordPress query arguments --- src/WordPress/NetworkQueryArg.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/WordPress/NetworkQueryArg.php b/src/WordPress/NetworkQueryArg.php index 800376a..9a6c3b1 100644 --- a/src/WordPress/NetworkQueryArg.php +++ b/src/WordPress/NetworkQueryArg.php @@ -14,6 +14,8 @@ class NetworkQueryArg implements QueryArg { + use SupportsQueryArguments; + private IncludeExcludeQueryArg $networkIds; private ?int $networkId = null; private ?string $domain = null; @@ -28,6 +30,7 @@ class NetworkQueryArg implements QueryArg public function __construct(array $queryArray = []) { + $this->initialiseQueryArguments($queryArray); $this->networkIds = IncludeExcludeQueryArg::make(['network__in' => $queryArray['network__in'] ?? [], 'network__not_in' => $queryArray['network__not_in'] ?? []], ['includeKey' => 'network__in', 'excludeKey' => 'network__not_in']); if (array_key_exists('network_id', $queryArray)) { $this->networkId = (int) $queryArray['network_id']; @@ -109,6 +112,6 @@ public function toArray(): array } } - return $args; + return $this->mergeQueryArguments($args); } } From 6d375a02d9fb93b4350c56b4fb2ef25b90ddfa65 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:36:54 +0000 Subject: [PATCH 15/70] Fix namespace and global references in query argument trait --- src/WordPress/SupportsQueryArguments.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/WordPress/SupportsQueryArguments.php b/src/WordPress/SupportsQueryArguments.php index b23d955..9590b46 100644 --- a/src/WordPress/SupportsQueryArguments.php +++ b/src/WordPress/SupportsQueryArguments.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace Pentarix\\WordPressDatabase\\WordPress; +namespace Pentarix\WordPressDatabase\WordPress; trait SupportsQueryArguments { @@ -29,7 +29,7 @@ protected function initialiseQueryArguments(array $arguments): void public function set(string $key, mixed $value): static { if ('' === $key) { - throw new \\InvalidArgumentException('Query argument keys cannot be empty.'); + throw new \InvalidArgumentException('Query argument keys cannot be empty.'); } $this->additionalQueryArgs[$key] = $value; @@ -43,8 +43,8 @@ public function set(string $key, mixed $value): static public function setMany(array $arguments): static { foreach ($arguments as $key => $value) { - if (!\\is_string($key) || '' === $key) { - throw new \\InvalidArgumentException('Query argument keys must be non-empty strings.'); + if (!\is_string($key) || '' === $key) { + throw new \InvalidArgumentException('Query argument keys must be non-empty strings.'); } $this->additionalQueryArgs[$key] = $value; @@ -55,7 +55,7 @@ public function setMany(array $arguments): static protected function mergeQueryArguments(array $arguments): array { - return \\array_replace( + return \array_replace( $this->originalQueryArgs, $arguments, $this->additionalQueryArgs From 8b5dfe1ff290966f4aaf90042a536fd0b4d81b21 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:37:03 +0000 Subject: [PATCH 16/70] Complete date query argument helpers --- src/WordPress/DateQueryArg.php | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php index 1c7f80d..5e96717 100644 --- a/src/WordPress/DateQueryArg.php +++ b/src/WordPress/DateQueryArg.php @@ -81,7 +81,7 @@ public function between( ); } - public function year(int $year, string $column = 'post_date'): self + public function year(int|array $year, string $column = 'post_date'): self { return $this->where([ 'year' => $year, @@ -89,7 +89,7 @@ public function year(int $year, string $column = 'post_date'): self ]); } - public function month(int $month, string $column = 'post_date'): self + public function month(int|array $month, string $column = 'post_date'): self { return $this->where([ 'month' => $month, @@ -97,7 +97,7 @@ public function month(int $month, string $column = 'post_date'): self ]); } - public function week(int $week, string $column = 'post_date'): self + public function week(int|array $week, string $column = 'post_date'): self { return $this->where([ 'week' => $week, @@ -105,7 +105,7 @@ public function week(int $week, string $column = 'post_date'): self ]); } - public function day(int $day, string $column = 'post_date'): self + public function day(int|array $day, string $column = 'post_date'): self { return $this->where([ 'day' => $day, @@ -113,7 +113,7 @@ public function day(int $day, string $column = 'post_date'): self ]); } - public function hour(int $hour, string $column = 'post_date'): self + public function hour(int|array $hour, string $column = 'post_date'): self { return $this->where([ 'hour' => $hour, @@ -121,7 +121,7 @@ public function hour(int $hour, string $column = 'post_date'): self ]); } - public function minute(int $minute, string $column = 'post_date'): self + public function minute(int|array $minute, string $column = 'post_date'): self { return $this->where([ 'minute' => $minute, @@ -129,7 +129,7 @@ public function minute(int $minute, string $column = 'post_date'): self ]); } - public function second(int $second, string $column = 'post_date'): self + public function second(int|array $second, string $column = 'post_date'): self { return $this->where([ 'second' => $second, @@ -137,6 +137,26 @@ public function second(int $second, string $column = 'post_date'): self ]); } + public function dayOfYear(int|array $day, string $column = 'post_date'): self + { + return $this->where(['dayofyear' => $day, 'column' => $column]); + } + + public function dayOfWeek(int|array $day, string $column = 'post_date'): self + { + return $this->where(['dayofweek' => $day, 'column' => $column]); + } + + public function dayOfWeekIso(int|array $day, string $column = 'post_date'): self + { + return $this->where(['dayofweek_iso' => $day, 'column' => $column]); + } + + public function compare(string $operator): self + { + return $this->where(['compare' => $operator]); + } + public function whereGroup( callable $callback, string $relation = 'AND' From 4091a51317dd637a1691ab394c03b396ebc271a3 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:37:31 +0000 Subject: [PATCH 17/70] Test full WordPress argument passthrough and date helpers --- tests/WordPress/QueryArgumentBuildersTest.php | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index f44608d..b3ced24 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -314,7 +314,7 @@ public function testCommentQueryArgMapsConstructorAndFluentValues(): void 'comment__not_in' => [3], 'post__in' => [4], 'parent__not_in' => [5], - 'user__in' => [6], + 'author__in' => [6], 'post_id' => '7', 'parent' => 8, 'user_id' => 9, @@ -509,6 +509,89 @@ public function testUserQueryArgMapsIdentitySearchMetaAndPagination(): void self::assertArrayHasKey('meta_query', $args); } + + public function testQueryBuildersPreserveAdditionalWordPressArguments(): void + { + $post = PostQueryArg::make([ + 'p' => 42, + 'author' => 7, + 'search_columns' => ['post_title'], + 'meta_key' => ['colour', 'size'], + ])->set('update_menu_item_cache', false); + + self::assertSame(42, $post->toArray()['p']); + self::assertSame(7, $post->toArray()['author']); + self::assertSame(['post_title'], $post->toArray()['search_columns']); + self::assertSame(['colour', 'size'], $post->toArray()['meta_key']); + self::assertFalse($post->toArray()['update_menu_item_cache']); + + $comment = CommentQueryArg::make([ + 'author__in' => [3, 4], + 'date_query' => [['year' => 2024]], + 'include_unapproved' => [5], + 'meta_query' => [['key' => 'visible', 'value' => 1]], + ]); + + self::assertSame([3, 4], $comment->toArray()['author__in']); + self::assertArrayHasKey('date_query', $comment->toArray()); + self::assertArrayHasKey('include_unapproved', $comment->toArray()); + self::assertArrayHasKey('meta_query', $comment->toArray()); + + $term = TermQueryArg::make([ + 'object_ids' => [10, 11], + 'exclude_tree' => [12], + 'term_taxonomy_id' => [13], + 'childless' => true, + ]); + + self::assertSame([10, 11], $term->toArray()['object_ids']); + self::assertSame([12], $term->toArray()['exclude_tree']); + self::assertSame([13], $term->toArray()['term_taxonomy_id']); + self::assertTrue($term->toArray()['childless']); + + $site = SiteQueryArg::make([ + 'count' => true, + 'date_query' => [['after' => '2024-01-01']], + 'domain__in' => ['example.com'], + 'update_site_meta_cache' => false, + ]); + + self::assertTrue($site->toArray()['count']); + self::assertSame(['example.com'], $site->toArray()['domain__in']); + self::assertFalse($site->toArray()['update_site_meta_cache']); + + $network = NetworkQueryArg::make([ + 'no_found_rows' => true, + 'domain__not_in' => ['blocked.example'], + 'path__in' => ['/network/'], + ])->setMany(['custom_query_var' => 'value', 'number' => 5]); + + self::assertTrue($network->toArray()['no_found_rows']); + self::assertSame(['blocked.example'], $network->toArray()['domain__not_in']); + self::assertSame(['/network/'], $network->toArray()['path__in']); + self::assertSame('value', $network->toArray()['custom_query_var']); + self::assertSame(5, $network->toArray()['number']); + } + + public function testDateQueryArgSupportsCompleteDatePartsAndArrayValues(): void + { + $args = DateQueryArg::make() + ->year([2022, 2024]) + ->month([1, 6]) + ->dayOfYear([10, 20]) + ->dayOfWeek(2) + ->dayOfWeekIso([1, 5]) + ->compare('>=') + ->toArray(); + + self::assertSame([2022, 2024], $args[0]['year']); + self::assertSame([1, 6], $args[1]['month']); + self::assertSame([10, 20], $args[2]['dayofyear']); + self::assertSame(2, $args[3]['dayofweek']); + self::assertSame([1, 5], $args[4]['dayofweek_iso']); + self::assertSame('>=', $args[5]['compare']); + } + public function testUserQueryArgSupportsEmptyAndAlternativeSearchMethods(): void { self::assertSame([], UserQueryArg::make()->toArray()); From 3be89571dc32515394747a48cbdced6c23f7c1df Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:37:57 +0000 Subject: [PATCH 18/70] Document complete query argument passthrough --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 0b7a77b..71549f2 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,30 @@ $query = new \\WP_Query($args); Supporting builders (`IncludeQueryArg`, `IncludeExcludeQueryArg`, `IncludeExcludeIsQueryArg`, and `SearchQueryArg`) are also available under `Pentarix\WordPressDatabase\WordPress`. +## Argument coverage + +The main query builders retain the arguments supplied to their constructors and expose +`set()` and `setMany()` for WordPress query variables that do not have a dedicated +convenience method yet. These methods preserve the supplied key and value without +rewriting WordPress's query contract: + +```php +$args = PostQueryArg::make() + ->set('author', 12) + ->set('post_mime_type', ['image/jpeg', 'image/png']) + ->setMany([ + 'search_columns' => ['post_title', 'post_excerpt'], + 'update_menu_item_cache' => false, + ]) + ->toArray(); +``` + +The builder-specific fluent methods remain the preferred interface for commonly used +arguments and nested query clauses. The generic setters provide pass-through coverage +for additional core arguments and custom query variables supported by WordPress hooks. +Values are passed to WordPress as supplied; WordPress remains responsible for query +interpretation and execution. + ## Scope The builders return arrays compatible with WordPress query APIs, including `WP_Query`, `WP_User_Query`, `WP_Comment_Query`, `WP_Term_Query`, and multisite query APIs. From 28b3f50d3cb3fd93d1ff91e0e212e1138081a9e2 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:38:34 +0000 Subject: [PATCH 19/70] Assert supported comment author ID arguments --- tests/WordPress/QueryArgumentBuildersTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index b3ced24..5556300 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -338,6 +338,7 @@ public function testCommentQueryArgMapsConstructorAndFluentValues(): void $args = $query->toArray(); self::assertSame([2, 3, 4], $args['comment__in']); self::assertSame([1], $args['comment__not_in']); + self::assertSame([6], $args['author__in']); self::assertSame(10, $args['post_id']); self::assertSame('term', $args['search']); self::assertFalse($args['hierarchical']); From 311b34b5945fdf0ed4a77b66020f1d75a7c48941 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:39:13 +0000 Subject: [PATCH 20/70] Apply date comparisons to actual date clauses --- src/WordPress/DateQueryArg.php | 80 +++++++++++++++------------------- 1 file changed, 34 insertions(+), 46 deletions(-) diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php index 5e96717..3a35ca4 100644 --- a/src/WordPress/DateQueryArg.php +++ b/src/WordPress/DateQueryArg.php @@ -81,80 +81,54 @@ public function between( ); } - public function year(int|array $year, string $column = 'post_date'): self + public function year(int|array $year, string $column = 'post_date', string $compare = '='): self { - return $this->where([ - 'year' => $year, - 'column' => $column, - ]); + return $this->datePart('year', $year, $column, $compare); } - public function month(int|array $month, string $column = 'post_date'): self + public function month(int|array $month, string $column = 'post_date', string $compare = '='): self { - return $this->where([ - 'month' => $month, - 'column' => $column, - ]); + return $this->datePart('month', $month, $column, $compare); } - public function week(int|array $week, string $column = 'post_date'): self + public function week(int|array $week, string $column = 'post_date', string $compare = '='): self { - return $this->where([ - 'week' => $week, - 'column' => $column, - ]); + return $this->datePart('week', $week, $column, $compare); } - public function day(int|array $day, string $column = 'post_date'): self + public function day(int|array $day, string $column = 'post_date', string $compare = '='): self { - return $this->where([ - 'day' => $day, - 'column' => $column, - ]); + return $this->datePart('day', $day, $column, $compare); } - public function hour(int|array $hour, string $column = 'post_date'): self + public function hour(int|array $hour, string $column = 'post_date', string $compare = '='): self { - return $this->where([ - 'hour' => $hour, - 'column' => $column, - ]); + return $this->datePart('hour', $hour, $column, $compare); } - public function minute(int|array $minute, string $column = 'post_date'): self + public function minute(int|array $minute, string $column = 'post_date', string $compare = '='): self { - return $this->where([ - 'minute' => $minute, - 'column' => $column, - ]); + return $this->datePart('minute', $minute, $column, $compare); } - public function second(int|array $second, string $column = 'post_date'): self + public function second(int|array $second, string $column = 'post_date', string $compare = '='): self { - return $this->where([ - 'second' => $second, - 'column' => $column, - ]); + return $this->datePart('second', $second, $column, $compare); } - public function dayOfYear(int|array $day, string $column = 'post_date'): self + public function dayOfYear(int|array $day, string $column = 'post_date', string $compare = '='): self { - return $this->where(['dayofyear' => $day, 'column' => $column]); + return $this->datePart('dayofyear', $day, $column, $compare); } - public function dayOfWeek(int|array $day, string $column = 'post_date'): self + public function dayOfWeek(int|array $day, string $column = 'post_date', string $compare = '='): self { - return $this->where(['dayofweek' => $day, 'column' => $column]); + return $this->datePart('dayofweek', $day, $column, $compare); } - public function dayOfWeekIso(int|array $day, string $column = 'post_date'): self + public function dayOfWeekIso(int|array $day, string $column = 'post_date', string $compare = '='): self { - return $this->where(['dayofweek_iso' => $day, 'column' => $column]); - } - - public function compare(string $operator): self - { - return $this->where(['compare' => $operator]); + return $this->datePart('dayofweek_iso', $day, $column, $compare); } public function whereGroup( @@ -188,6 +162,20 @@ public function whereClause( return $this->addClause($clause, $boolean); } + private function datePart(string $key, int|array $value, string $column, string $compare): self + { + $clause = [ + $key => $value, + 'column' => $column, + ]; + + if ('=' !== $compare) { + $clause['compare'] = $compare; + } + + return $this->where($clause); + } + private function addClause(array $clause, string $boolean): self { $boolean = 'OR' === strtoupper($boolean) ? 'OR' : 'AND'; From df7cf7daf609c50e142251b0a4a9b604168ab707 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:39:22 +0000 Subject: [PATCH 21/70] Test comparisons on valid date clauses --- tests/WordPress/QueryArgumentBuildersTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 5556300..fdfade2 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -577,21 +577,20 @@ public function testQueryBuildersPreserveAdditionalWordPressArguments(): void public function testDateQueryArgSupportsCompleteDatePartsAndArrayValues(): void { $args = DateQueryArg::make() - ->year([2022, 2024]) + ->year([2022, 2024], 'post_date', 'IN') ->month([1, 6]) ->dayOfYear([10, 20]) ->dayOfWeek(2) ->dayOfWeekIso([1, 5]) - ->compare('>=') ->toArray(); self::assertSame([2022, 2024], $args[0]['year']); + self::assertSame('IN', $args[0]['compare']); self::assertSame([1, 6], $args[1]['month']); self::assertSame([10, 20], $args[2]['dayofyear']); self::assertSame(2, $args[3]['dayofweek']); self::assertSame([1, 5], $args[4]['dayofweek_iso']); - self::assertSame('>=', $args[5]['compare']); - } + } public function testUserQueryArgSupportsEmptyAndAlternativeSearchMethods(): void { From 92156b2808d840a687f67f6f163f5fdeb1afdd99 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:39:58 +0000 Subject: [PATCH 22/70] Preserve WordPress user meta key array values --- src/WordPress/UserQueryArg.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WordPress/UserQueryArg.php b/src/WordPress/UserQueryArg.php index fc572d9..9257ca8 100644 --- a/src/WordPress/UserQueryArg.php +++ b/src/WordPress/UserQueryArg.php @@ -57,7 +57,7 @@ class UserQueryArg implements QueryArg private ?int $blogId = null; - private ?string $metaKey = null; + private string|array|null $metaKey = null; private mixed $metaValue = null; @@ -204,7 +204,7 @@ public function __construct(array $queryArray = []) } if (isset($queryArray['meta_key'])) { - $this->metaKey = (string) $queryArray['meta_key']; + $this->metaKey = $queryArray['meta_key']; } if (\array_key_exists('meta_value', $queryArray)) { @@ -318,7 +318,7 @@ public function blogId(int $blogId): self return $this; } - public function metaKey(string $key): self + public function metaKey(string|array $key): self { $this->metaKey = $key; From 917a26bc8ec3ccf525a734f42be052550cd6ef90 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:40:06 +0000 Subject: [PATCH 23/70] Test WordPress user meta key array support --- tests/WordPress/QueryArgumentBuildersTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index fdfade2..1d80991 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -592,6 +592,19 @@ public function testDateQueryArgSupportsCompleteDatePartsAndArrayValues(): void self::assertSame([1, 5], $args[4]['dayofweek_iso']); } + public function testUserQueryArgAcceptsMultipleMetaKeys(): void + { + self::assertSame( + ['meta_key' => ['first_key', 'second_key']], + UserQueryArg::make()->metaKey(['first_key', 'second_key'])->toArray() + ); + + self::assertSame( + ['meta_key' => ['first_key', 'second_key']], + UserQueryArg::make(['meta_key' => ['first_key', 'second_key']])->toArray() + ); + } + public function testUserQueryArgSupportsEmptyAndAlternativeSearchMethods(): void { self::assertSame([], UserQueryArg::make()->toArray()); From 4127d9e965024b64ea1f35c54c92865a14cd813c Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:21 +0000 Subject: [PATCH 24/70] Preserve WordPress query argument types and semantics --- src/WordPress/PostQueryArg.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WordPress/PostQueryArg.php b/src/WordPress/PostQueryArg.php index 2d17357..9f883af 100644 --- a/src/WordPress/PostQueryArg.php +++ b/src/WordPress/PostQueryArg.php @@ -44,7 +44,7 @@ class PostQueryArg implements QueryArg private ?bool $updatePostTermCache = null; private ?bool $lazyLoadTermMeta = null; private ?string $perm = null; - private ?int $commentCount = null; + private int|array|null $commentCount = null; private ?bool $exact = null; private ?bool $sentence = null; private ?string $fields = null; @@ -134,7 +134,7 @@ public function __construct(array $queryArray = []) $this->perm = (string) $queryArray['perm']; } if (isset($queryArray['comment_count'])) { - $this->commentCount = (int) $queryArray['comment_count']; + $this->commentCount = $queryArray['comment_count']; } if (isset($queryArray['exact'])) { $this->exact = (bool) $queryArray['exact']; @@ -158,7 +158,7 @@ public function includeIds(int|float|string|array $ids): self { $this->postIdQue public function excludeIds(int|float|string|array $ids): self { $this->postIdQuery->exclude($ids); return $this; } - public function postType(string|array $types): self { $this->postTypeQuery->is((array) $types); + public function postType(string|array $types): self { $this->postTypeQuery->is($types); return $this; } public function postTypesIn(string|array $types): self { $this->postTypeQuery->include($types); From 5656fc380d5be4c0fafd5fbe1c1a85f950f5bc13 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:23 +0000 Subject: [PATCH 25/70] Preserve WordPress query argument types and semantics --- src/WordPress/CommentQueryArg.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/WordPress/CommentQueryArg.php b/src/WordPress/CommentQueryArg.php index 6f561e1..da10e6d 100644 --- a/src/WordPress/CommentQueryArg.php +++ b/src/WordPress/CommentQueryArg.php @@ -27,14 +27,14 @@ class CommentQueryArg implements QueryArg private ?string $authorUrl = null; private ?string $authorIp = null; private ?string $search = null; - private ?string $type = null; - private ?string $status = null; + private string|array|null $type = null; + private string|array|null $status = null; private ?int $number = null; private ?int $offset = null; private string|array|null $orderby = null; private ?string $order = null; private ?string $fields = null; - private ?bool $hierarchical = null; + private bool|string|null $hierarchical = null; private ?bool $count = null; private ?bool $noFoundRows = null; private ?bool $updateCommentMetaCache = null; @@ -42,7 +42,6 @@ class CommentQueryArg implements QueryArg public function __construct(array $queryArray = []) { - unset($queryArray['user__in'], $queryArray['user__not_in']); $this->initialiseQueryArguments($queryArray); $this->commentIds = IncludeExcludeQueryArg::make(['comment__in' => $queryArray['comment__in'] ?? [], 'comment__not_in' => $queryArray['comment__not_in'] ?? []], ['includeKey' => 'comment__in', 'excludeKey' => 'comment__not_in']); $this->postIds = IncludeExcludeQueryArg::make(['post__in' => $queryArray['post__in'] ?? [], 'post__not_in' => $queryArray['post__not_in'] ?? []], ['includeKey' => 'post__in', 'excludeKey' => 'post__not_in']); @@ -70,10 +69,10 @@ public function __construct(array $queryArray = []) $this->search = (string) $queryArray['search']; } if (array_key_exists('type', $queryArray)) { - $this->type = (string) $queryArray['type']; + $this->type = $queryArray['type']; } if (array_key_exists('status', $queryArray)) { - $this->status = (string) $queryArray['status']; + $this->status = $queryArray['status']; } if (array_key_exists('number', $queryArray)) { $this->number = (int) $queryArray['number']; @@ -91,7 +90,7 @@ public function __construct(array $queryArray = []) $this->fields = (string) $queryArray['fields']; } if (array_key_exists('hierarchical', $queryArray)) { - $this->hierarchical = (bool) $queryArray['hierarchical']; + $this->hierarchical = $queryArray['hierarchical']; } if (array_key_exists('count', $queryArray)) { $this->count = (bool) $queryArray['count']; From 10c57db74cb3a4f950c909166323b24d99d3b5ec Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:26 +0000 Subject: [PATCH 26/70] Preserve WordPress query argument types and semantics --- src/WordPress/TermQueryArg.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/WordPress/TermQueryArg.php b/src/WordPress/TermQueryArg.php index f0b0015..fa777ee 100644 --- a/src/WordPress/TermQueryArg.php +++ b/src/WordPress/TermQueryArg.php @@ -17,12 +17,11 @@ class TermQueryArg implements QueryArg use SupportsQueryArguments; private IncludeExcludeQueryArg $termIds; - private IncludeExcludeQueryArg $parentIds; private ?int $termId = null; private ?int $parent = null; private string|array|null $taxonomy = null; - private ?string $name = null; - private ?string $slug = null; + private string|array|null $name = null; + private string|array|null $slug = null; private ?string $search = null; private ?string $nameLike = null; private ?string $descriptionLike = null; @@ -34,7 +33,7 @@ class TermQueryArg implements QueryArg private ?bool $hideEmpty = null; private ?bool $hierarchical = null; private ?bool $padCounts = null; - private ?bool $get = null; + private ?string $get = null; private ?bool $updateTermMetaCache = null; private ?string $cacheDomain = null; @@ -42,7 +41,6 @@ public function __construct(array $queryArray = []) { $this->initialiseQueryArguments($queryArray); $this->termIds = IncludeExcludeQueryArg::make(['include' => $queryArray['include'] ?? [], 'exclude' => $queryArray['exclude'] ?? []], ['includeKey' => 'include', 'excludeKey' => 'exclude']); - $this->parentIds = IncludeExcludeQueryArg::make(['parent__in' => $queryArray['parent__in'] ?? [], 'parent__not_in' => $queryArray['parent__not_in'] ?? []], ['includeKey' => 'parent__in', 'excludeKey' => 'parent__not_in']); if (array_key_exists('term_id', $queryArray)) { $this->termId = (int) $queryArray['term_id']; } @@ -53,10 +51,10 @@ public function __construct(array $queryArray = []) $this->taxonomy = is_array($queryArray['taxonomy']) ? array_values(array_map('strval', $queryArray['taxonomy'])) : (string) $queryArray['taxonomy']; } if (array_key_exists('name', $queryArray)) { - $this->name = (string) $queryArray['name']; + $this->name = is_array($queryArray['name']) ? array_values(array_map('strval', $queryArray['name'])) : (string) $queryArray['name']; } if (array_key_exists('slug', $queryArray)) { - $this->slug = (string) $queryArray['slug']; + $this->slug = is_array($queryArray['slug']) ? array_values(array_map('strval', $queryArray['slug'])) : (string) $queryArray['slug']; } if (array_key_exists('search', $queryArray)) { $this->search = (string) $queryArray['search']; @@ -92,7 +90,7 @@ public function __construct(array $queryArray = []) $this->padCounts = (bool) $queryArray['pad_counts']; } if (array_key_exists('get', $queryArray)) { - $this->get = (bool) $queryArray['get']; + $this->get = (string) $queryArray['get']; } if (array_key_exists('update_term_meta_cache', $queryArray)) { $this->updateTermMetaCache = (bool) $queryArray['update_term_meta_cache']; @@ -139,7 +137,6 @@ public function toArray(): array { $args = []; $args += $this->termIds->toArray(); - $args += $this->parentIds->toArray(); $values = [ 'term_id' => $this->termId, 'parent' => $this->parent, From fee6c6b5d847eac7d4802404ab38d3c8274142d3 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:28 +0000 Subject: [PATCH 27/70] Preserve WordPress query argument types and semantics --- src/WordPress/NetworkQueryArg.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/WordPress/NetworkQueryArg.php b/src/WordPress/NetworkQueryArg.php index 9a6c3b1..016bf69 100644 --- a/src/WordPress/NetworkQueryArg.php +++ b/src/WordPress/NetworkQueryArg.php @@ -17,7 +17,6 @@ class NetworkQueryArg implements QueryArg use SupportsQueryArguments; private IncludeExcludeQueryArg $networkIds; - private ?int $networkId = null; private ?string $domain = null; private ?string $path = null; private ?int $number = null; @@ -32,9 +31,6 @@ public function __construct(array $queryArray = []) { $this->initialiseQueryArguments($queryArray); $this->networkIds = IncludeExcludeQueryArg::make(['network__in' => $queryArray['network__in'] ?? [], 'network__not_in' => $queryArray['network__not_in'] ?? []], ['includeKey' => 'network__in', 'excludeKey' => 'network__not_in']); - if (array_key_exists('network_id', $queryArray)) { - $this->networkId = (int) $queryArray['network_id']; - } if (array_key_exists('domain', $queryArray)) { $this->domain = (string) $queryArray['domain']; } @@ -83,19 +79,11 @@ public function excludeNetworks(int|array $ids): self return $this; } - public function networkId(int $id): self - { - $this->networkId = $id; - - return $this; - } - public function toArray(): array { $args = []; $args += $this->networkIds->toArray(); $values = [ - 'network_id' => $this->networkId, 'domain' => $this->domain, 'path' => $this->path, 'number' => $this->number, From ea7678b14d25deb6e1a34824428b77f7a63e8381 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:31 +0000 Subject: [PATCH 28/70] Preserve WordPress query argument types and semantics --- src/WordPress/IncludeExcludeIsQueryArg.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/WordPress/IncludeExcludeIsQueryArg.php b/src/WordPress/IncludeExcludeIsQueryArg.php index 2d4c840..364a1a0 100644 --- a/src/WordPress/IncludeExcludeIsQueryArg.php +++ b/src/WordPress/IncludeExcludeIsQueryArg.php @@ -56,18 +56,14 @@ public static function make(array $queryArray = [], array $args = []): static public function is(string|array $value): static { - if (false === $this->multiValue) { - if (!\is_string($value)) { - throw new InvalidArgumentException('Is value must be a string in single-value mode.'); - } - + if (\is_string($value)) { $this->is = '' === $value ? null : $value; return $this; } if (!\is_array($value)) { - throw new InvalidArgumentException('Is value must be an array in multi-value mode.'); + throw new InvalidArgumentException('Is value must be a string or an array.'); } $value = ArrayUtil::normalizeUniqueStrings($value); From 4fc75a0afacbd35dcc28100c637bfef969e6d86b Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:33 +0000 Subject: [PATCH 29/70] Preserve WordPress query argument types and semantics --- src/WordPress/IncludeQueryArg.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/WordPress/IncludeQueryArg.php b/src/WordPress/IncludeQueryArg.php index a5dc5f3..8128391 100644 --- a/src/WordPress/IncludeQueryArg.php +++ b/src/WordPress/IncludeQueryArg.php @@ -33,7 +33,7 @@ public function __construct( $this->includeKey = $includeKey; if (isset($queryArray[$this->includeKey])) { - $this->include((array) $queryArray[$this->includeKey]); + $this->include($queryArray[$this->includeKey]); } } @@ -53,6 +53,10 @@ protected function cast(int|float|string $value): int|string public function include(int|float|string|array $value): static { + if ($this->isIdentifier && \is_string($value) && str_contains($value, ',')) { + $value = array_map('trim', explode(',', $value)); + } + $this->mergeIncluded( ArrayUtil::castValues($value, fn($item) => $this->cast($item)) ); From 29c59a4980c6e43aa5c8939841711afd2ced32d2 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:36 +0000 Subject: [PATCH 30/70] Preserve WordPress query argument types and semantics --- src/WordPress/IncludeExcludeQueryArg.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/WordPress/IncludeExcludeQueryArg.php b/src/WordPress/IncludeExcludeQueryArg.php index 45cdf04..d93dae2 100644 --- a/src/WordPress/IncludeExcludeQueryArg.php +++ b/src/WordPress/IncludeExcludeQueryArg.php @@ -58,6 +58,10 @@ public function include(int|float|string|array $value): static public function exclude(int|float|string|array $value): static { + if ($this->isIdentifier && \is_string($value) && str_contains($value, ',')) { + $value = array_map('trim', explode(',', $value)); + } + $values = ArrayUtil::castValues($value, fn($item) => $this->cast($item)); $this->excluded = ArrayUtil::mergeUnique($this->excluded, $values); From 3e1843eaa9a7e6427e0cc66ca6d46c150d5e13b0 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:38 +0000 Subject: [PATCH 31/70] Preserve WordPress query argument types and semantics --- src/WordPress/DateQueryArg.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php index 3a35ca4..26bdb44 100644 --- a/src/WordPress/DateQueryArg.php +++ b/src/WordPress/DateQueryArg.php @@ -40,7 +40,7 @@ public function orWhere(array $clause): self public function after( string|array $date, bool $inclusive = false, - string $column = 'post_date' + ?string $column = null ): self { return $this->where( [ @@ -166,9 +166,12 @@ private function datePart(string $key, int|array $value, string $column, string { $clause = [ $key => $value, - 'column' => $column, ]; + if (null !== $column) { + $clause['column'] = $column; + } + if ('=' !== $compare) { $clause['compare'] = $compare; } From cf0edad45c3634a4e0cbabd52212fc16d26950c8 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:57:54 +0000 Subject: [PATCH 32/70] Defer date column defaults to WordPress query context --- src/WordPress/DateQueryArg.php | 53 ++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php index 26bdb44..011cb1b 100644 --- a/src/WordPress/DateQueryArg.php +++ b/src/WordPress/DateQueryArg.php @@ -42,13 +42,16 @@ public function after( bool $inclusive = false, ?string $column = null ): self { - return $this->where( - [ - 'after' => $date, - 'inclusive' => $inclusive, - 'column' => $column, - ] - ); + $clause = [ + 'after' => $date, + 'inclusive' => $inclusive, + ]; + + if (null !== $column) { + $clause['column'] = $column; + } + + return $this->where($clause); } public function before( @@ -56,13 +59,16 @@ public function before( bool $inclusive = false, string $column = 'post_date' ): self { - return $this->where( - [ - 'before' => $date, - 'inclusive' => $inclusive, - 'column' => $column, - ] - ); + $clause = [ + 'before' => $date, + 'inclusive' => $inclusive, + ]; + + if (null !== $column) { + $clause['column'] = $column; + } + + return $this->where($clause); } public function between( @@ -71,14 +77,17 @@ public function between( bool $inclusive = true, string $column = 'post_date' ): self { - return $this->where( - [ - 'after' => $after, - 'before' => $before, - 'inclusive' => $inclusive, - 'column' => $column, - ] - ); + $clause = [ + 'after' => $after, + 'before' => $before, + 'inclusive' => $inclusive, + ]; + + if (null !== $column) { + $clause['column'] = $column; + } + + return $this->where($clause); } public function year(int|array $year, string $column = 'post_date', string $compare = '='): self From f946504dcbbad82cf995d55ce5d3fe2174b4cfde Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:58:12 +0000 Subject: [PATCH 33/70] Add regression tests for WordPress argument compatibility --- tests/WordPress/QueryArgumentBuildersTest.php | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 1d80991..be3541c 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -83,21 +83,12 @@ public function testIncludeExcludeIsQueryArgSupportsSingleAndMultipleValues(): v self::assertSame([], $multi->is([])->toArray()); } - public function testIncludeExcludeIsQueryArgRejectsWrongIsValueShape(): void - { - $single = new IncludeExcludeIsQueryArg([], false); - $multi = new IncludeExcludeIsQueryArg([], false, true); - - $this->expectException(InvalidArgumentException::class); - $single->is(['publish']); - } - - public function testIncludeExcludeIsQueryArgRejectsScalarInMultiValueMode(): void + public function testIncludeExcludeIsQueryArgPreservesScalarAndArrayValuesInMultiValueMode(): void { $query = new IncludeExcludeIsQueryArg([], false, true); - $this->expectException(InvalidArgumentException::class); - $query->is('publish'); + self::assertSame(['is' => 'any'], $query->is('any')->toArray()); + self::assertSame(['is' => ['post', 'page']], $query->is(['post', 'page'])->toArray()); } public function testSearchQueryArgValidatesAndNormalizesSearchColumns(): void @@ -246,6 +237,7 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void 'post__not_in' => [3], 'author__in' => [7], 'post_type' => ['post', 'page'], + 'comment_count' => ['value' => 2, 'compare' => '>='], 'post_status' => ['publish'], 's' => 'hello', 'posts_per_page' => 10, @@ -291,6 +283,7 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void self::assertSame($meta->toArray(), $args['meta_query']); self::assertSame($tax->toArray(), $args['tax_query']); self::assertSame(['one', '2'], $args['post_name__in']); + self::assertSame(['value' => 2, 'compare' => '>='], $args['comment_count']); } public function testPostQueryArgAcceptsArrayNestedQueriesAndEmptyDefaults(): void @@ -328,7 +321,7 @@ public function testCommentQueryArgMapsConstructorAndFluentValues(): void 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', - 'hierarchical' => false, + 'hierarchical' => 'threaded', 'count' => true, 'no_found_rows' => true, 'update_comment_meta_cache' => false, @@ -341,9 +334,15 @@ public function testCommentQueryArgMapsConstructorAndFluentValues(): void self::assertSame([6], $args['author__in']); self::assertSame(10, $args['post_id']); self::assertSame('term', $args['search']); - self::assertFalse($args['hierarchical']); + self::assertSame('threaded', $args['hierarchical']); self::assertTrue($args['update_comment_post_cache']); self::assertSame([], CommentQueryArg::make()->toArray()); + self::assertSame(['type' => ['review', 'rating'], 'status' => ['approve', 'hold'], 'hierarchical' => 'threaded'], CommentQueryArg::make([ + 'type' => ['review', 'rating'], + 'status' => ['approve', 'hold'], + 'hierarchical' => 'threaded', + 'user__in' => [2, 3], + ])->toArray()); } public function testNetworkQueryArgMapsIdsAndOptions(): void @@ -361,12 +360,12 @@ public function testNetworkQueryArgMapsIdsAndOptions(): void 'fields' => 'ids', 'count' => false, 'update_network_cache' => true, - ])->includeNetworks(4)->excludeNetworks(1)->networkId(5); + ])->includeNetworks(4)->excludeNetworks(1); $args = $query->toArray(); self::assertSame([4], $args['network__in']); self::assertSame([2, 1], $args['network__not_in']); - self::assertSame(5, $args['network_id']); + self::assertArrayNotHasKey('network_id', $args); self::assertFalse($args['count']); self::assertTrue($args['update_network_cache']); self::assertSame([], NetworkQueryArg::make()->toArray()); @@ -417,8 +416,8 @@ public function testTermQueryArgMapsTaxonomyAndTermOptions(): void 'term_id' => '5', 'parent' => 6, 'taxonomy' => ['category', 'post_tag'], - 'name' => 'News', - 'slug' => 'news', + 'name' => ['News', 'Updates'], + 'slug' => ['news', 'updates'], 'search' => 'news', 'name__like' => 'new', 'description__like' => 'latest', @@ -430,7 +429,7 @@ public function testTermQueryArgMapsTaxonomyAndTermOptions(): void 'hide_empty' => false, 'hierarchical' => true, 'pad_counts' => false, - 'get' => true, + 'get' => 'all', 'update_term_meta_cache' => false, 'cache_domain' => 'test', ])->includeTerms(7)->excludeTerms(1)->taxonomy('post')->search('updated'); @@ -440,10 +439,14 @@ public function testTermQueryArgMapsTaxonomyAndTermOptions(): void self::assertSame([3, 1], $args['exclude']); self::assertSame(5, $args['term_id']); self::assertSame('post', $args['taxonomy']); + self::assertSame(['News', 'Updates'], $args['name']); + self::assertSame(['news', 'updates'], $args['slug']); + self::assertSame('all', $args['get']); self::assertSame('updated', $args['search']); self::assertFalse($args['hide_empty']); self::assertSame('test', $args['cache_domain']); self::assertSame([], TermQueryArg::make()->toArray()); + self::assertSame(['include' => [1, 2, 3]], TermQueryArg::make(['include' => '1,2,3'])->toArray()); } public function testUserQueryArgMapsIdentitySearchMetaAndPagination(): void From 62d4d920b9979d7c9e15c0cfa8629b1321ed6d74 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:58:23 +0000 Subject: [PATCH 34/70] Cover scalar post types and context-aware date defaults --- tests/WordPress/QueryArgumentBuildersTest.php | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index be3541c..b9283aa 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -165,6 +165,18 @@ public function testDateQueryArgSupportsDateHelpersAndEmptyGroups(): void self::assertSame('OR', $query->toArray()[6]['relation']); } + public function testDateQueryArgLeavesDefaultColumnToWordPress(): void + { + self::assertSame( + [['after' => '2024-01-01', 'inclusive' => false]], + DateQueryArg::make()->after('2024-01-01')->toArray() + ); + self::assertSame( + [['year' => 2024]], + DateQueryArg::make()->year(2024)->toArray() + ); + } + public function testMetaQueryArgSupportsComparisonsAndGroups(): void { $query = MetaQueryArg::make() @@ -286,6 +298,12 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void self::assertSame(['value' => 2, 'compare' => '>='], $args['comment_count']); } + public function testPostQueryArgPreservesScalarPostTypeAndSpecialValues(): void + { + self::assertSame(['post_type' => 'post'], PostQueryArg::make(['post_type' => 'post'])->toArray()); + self::assertSame(['post_type' => 'any'], PostQueryArg::make()->postType('any')->toArray()); + } + public function testPostQueryArgAcceptsArrayNestedQueriesAndEmptyDefaults(): void { $query = PostQueryArg::make() @@ -337,7 +355,7 @@ public function testCommentQueryArgMapsConstructorAndFluentValues(): void self::assertSame('threaded', $args['hierarchical']); self::assertTrue($args['update_comment_post_cache']); self::assertSame([], CommentQueryArg::make()->toArray()); - self::assertSame(['type' => ['review', 'rating'], 'status' => ['approve', 'hold'], 'hierarchical' => 'threaded'], CommentQueryArg::make([ + self::assertSame(['type' => ['review', 'rating'], 'status' => ['approve', 'hold'], 'hierarchical' => 'threaded', 'user__in' => [2, 3]], CommentQueryArg::make([ 'type' => ['review', 'rating'], 'status' => ['approve', 'hold'], 'hierarchical' => 'threaded', From d04f6483b05c60c8604ce4c37a86321c829cdbb6 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:58:36 +0000 Subject: [PATCH 35/70] Parse comma-separated exclusion identifiers consistently --- src/WordPress/IncludeExcludeQueryArg.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WordPress/IncludeExcludeQueryArg.php b/src/WordPress/IncludeExcludeQueryArg.php index d93dae2..7388897 100644 --- a/src/WordPress/IncludeExcludeQueryArg.php +++ b/src/WordPress/IncludeExcludeQueryArg.php @@ -32,7 +32,7 @@ public function __construct( $this->excludeKey = $excludeKey; if (isset($queryArray[$this->excludeKey])) { - $this->exclude((array) $queryArray[$this->excludeKey]); + $this->exclude($queryArray[$this->excludeKey]); } } From 9fdbf88f17c81590fdf5b854e31683d6cd6611e1 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:58:39 +0000 Subject: [PATCH 36/70] Correct regression expectations for scalar post types and comma-separated IDs --- tests/WordPress/QueryArgumentBuildersTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index b9283aa..14665d8 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -284,7 +284,7 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void self::assertSame([2, 4], $args['post__in']); self::assertSame([3, 1], $args['post__not_in']); self::assertSame(['page'], $args['post_type__in']); - self::assertSame(['book'], $args['post_type']); + self::assertSame('book', $args['post_type']); self::assertSame('updated', $args['s']); self::assertSame(5, $args['posts_per_page']); self::assertSame(3, $args['paged']); @@ -464,7 +464,7 @@ public function testTermQueryArgMapsTaxonomyAndTermOptions(): void self::assertFalse($args['hide_empty']); self::assertSame('test', $args['cache_domain']); self::assertSame([], TermQueryArg::make()->toArray()); - self::assertSame(['include' => [1, 2, 3]], TermQueryArg::make(['include' => '1,2,3'])->toArray()); + self::assertSame(['include' => [1, 2, 3], 'exclude' => [4, 5]], TermQueryArg::make(['include' => '1,2,3', 'exclude' => '4,5'])->toArray()); } public function testUserQueryArgMapsIdentitySearchMetaAndPagination(): void From dba97e4c1aec07dc15202151193381d089293494 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 02:59:08 +0000 Subject: [PATCH 37/70] Remove unsupported network ID from Network Query builder test --- tests/WordPress/QueryArgumentBuildersTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 14665d8..2ffd6b9 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -368,7 +368,6 @@ public function testNetworkQueryArgMapsIdsAndOptions(): void $query = NetworkQueryArg::make([ 'network__in' => [1], 'network__not_in' => [2], - 'network_id' => '3', 'domain' => 'example.com', 'path' => '/', 'number' => 10, From 622fe35363ab471bc75d46c49bef3e3598b29e36 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:00:54 +0000 Subject: [PATCH 38/70] Use WordPress context defaults for every date query helper --- src/WordPress/DateQueryArg.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php index 011cb1b..dd63dfc 100644 --- a/src/WordPress/DateQueryArg.php +++ b/src/WordPress/DateQueryArg.php @@ -57,7 +57,7 @@ public function after( public function before( string|array $date, bool $inclusive = false, - string $column = 'post_date' + ?string $column = null ): self { $clause = [ 'before' => $date, @@ -171,7 +171,7 @@ public function whereClause( return $this->addClause($clause, $boolean); } - private function datePart(string $key, int|array $value, string $column, string $compare): self + private function datePart(string $key, int|array $value, ?string $column, string $compare): self { $clause = [ $key => $value, From 00e54cc0974e74c3d3d71a6d64ce1d482060df19 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:00:57 +0000 Subject: [PATCH 39/70] Parse comma-separated term IDs in include and exclude helpers --- src/WordPress/IncludeExcludeQueryArg.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/WordPress/IncludeExcludeQueryArg.php b/src/WordPress/IncludeExcludeQueryArg.php index 7388897..0f4a795 100644 --- a/src/WordPress/IncludeExcludeQueryArg.php +++ b/src/WordPress/IncludeExcludeQueryArg.php @@ -48,6 +48,10 @@ public static function make(array $queryArray = [], array $args = []): static public function include(int|float|string|array $value): static { + if ($this->isIdentifier && \\is_string($value) && str_contains($value, ',')) { + $value = array_map('trim', explode(',', $value)); + } + $values = ArrayUtil::castValues($value, fn($item) => $this->cast($item)); $this->mergeIncluded($values); From 4a5e73885d64f9d8bb760ba1a23e92ca78449c62 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:07:51 +0000 Subject: [PATCH 40/70] Fix escaped function name in include/exclude builder --- src/WordPress/IncludeExcludeQueryArg.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WordPress/IncludeExcludeQueryArg.php b/src/WordPress/IncludeExcludeQueryArg.php index 0f4a795..5515eed 100644 --- a/src/WordPress/IncludeExcludeQueryArg.php +++ b/src/WordPress/IncludeExcludeQueryArg.php @@ -10,9 +10,9 @@ declare(strict_types=1); -namespace Pentarix\WordPressDatabase\WordPress; +namespace Pentarix\\WordPressDatabase\\WordPress; -use Pentarix\WordPressDatabase\Utils\ArrayUtil; +use Pentarix\\WordPressDatabase\\Utils\\ArrayUtil; class IncludeExcludeQueryArg extends IncludeQueryArg { @@ -62,7 +62,7 @@ public function include(int|float|string|array $value): static public function exclude(int|float|string|array $value): static { - if ($this->isIdentifier && \is_string($value) && str_contains($value, ',')) { + if ($this->isIdentifier && \\is_string($value) && str_contains($value, ',')) { $value = array_map('trim', explode(',', $value)); } From 10cfa044b74b58e046f04ee16a22b65f1117b459 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:07:53 +0000 Subject: [PATCH 41/70] Leave default date columns to WordPress --- src/WordPress/DateQueryArg.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php index dd63dfc..0d01320 100644 --- a/src/WordPress/DateQueryArg.php +++ b/src/WordPress/DateQueryArg.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace Pentarix\WordPressDatabase\WordPress; +namespace Pentarix\\WordPressDatabase\\WordPress; class DateQueryArg implements QueryArg { @@ -75,7 +75,7 @@ public function between( string|array $after, string|array $before, bool $inclusive = true, - string $column = 'post_date' + ?string $column = null ): self { $clause = [ 'after' => $after, @@ -90,52 +90,52 @@ public function between( return $this->where($clause); } - public function year(int|array $year, string $column = 'post_date', string $compare = '='): self + public function year(int|array $year, ?string $column = null, string $compare = '='): self { return $this->datePart('year', $year, $column, $compare); } - public function month(int|array $month, string $column = 'post_date', string $compare = '='): self + public function month(int|array $month, ?string $column = null, string $compare = '='): self { return $this->datePart('month', $month, $column, $compare); } - public function week(int|array $week, string $column = 'post_date', string $compare = '='): self + public function week(int|array $week, ?string $column = null, string $compare = '='): self { return $this->datePart('week', $week, $column, $compare); } - public function day(int|array $day, string $column = 'post_date', string $compare = '='): self + public function day(int|array $day, ?string $column = null, string $compare = '='): self { return $this->datePart('day', $day, $column, $compare); } - public function hour(int|array $hour, string $column = 'post_date', string $compare = '='): self + public function hour(int|array $hour, ?string $column = null, string $compare = '='): self { return $this->datePart('hour', $hour, $column, $compare); } - public function minute(int|array $minute, string $column = 'post_date', string $compare = '='): self + public function minute(int|array $minute, ?string $column = null, string $compare = '='): self { return $this->datePart('minute', $minute, $column, $compare); } - public function second(int|array $second, string $column = 'post_date', string $compare = '='): self + public function second(int|array $second, ?string $column = null, string $compare = '='): self { return $this->datePart('second', $second, $column, $compare); } - public function dayOfYear(int|array $day, string $column = 'post_date', string $compare = '='): self + public function dayOfYear(int|array $day, ?string $column = null, string $compare = '='): self { return $this->datePart('dayofyear', $day, $column, $compare); } - public function dayOfWeek(int|array $day, string $column = 'post_date', string $compare = '='): self + public function dayOfWeek(int|array $day, ?string $column = null, string $compare = '='): self { return $this->datePart('dayofweek', $day, $column, $compare); } - public function dayOfWeekIso(int|array $day, string $column = 'post_date', string $compare = '='): self + public function dayOfWeekIso(int|array $day, ?string $column = null, string $compare = '='): self { return $this->datePart('dayofweek_iso', $day, $column, $compare); } From e558975486081604f61084c31535d74eb4f90fc4 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:08:06 +0000 Subject: [PATCH 42/70] Correct PHP namespace and function separators --- src/WordPress/IncludeExcludeQueryArg.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/WordPress/IncludeExcludeQueryArg.php b/src/WordPress/IncludeExcludeQueryArg.php index 5515eed..57b01be 100644 --- a/src/WordPress/IncludeExcludeQueryArg.php +++ b/src/WordPress/IncludeExcludeQueryArg.php @@ -10,9 +10,9 @@ declare(strict_types=1); -namespace Pentarix\\WordPressDatabase\\WordPress; +namespace Pentarix\WordPressDatabase\WordPress; -use Pentarix\\WordPressDatabase\\Utils\\ArrayUtil; +use Pentarix\WordPressDatabase\Utils\ArrayUtil; class IncludeExcludeQueryArg extends IncludeQueryArg { @@ -48,7 +48,7 @@ public static function make(array $queryArray = [], array $args = []): static public function include(int|float|string|array $value): static { - if ($this->isIdentifier && \\is_string($value) && str_contains($value, ',')) { + if ($this->isIdentifier && \is_string($value) && str_contains($value, ',')) { $value = array_map('trim', explode(',', $value)); } @@ -62,7 +62,7 @@ public function include(int|float|string|array $value): static public function exclude(int|float|string|array $value): static { - if ($this->isIdentifier && \\is_string($value) && str_contains($value, ',')) { + if ($this->isIdentifier && \is_string($value) && str_contains($value, ',')) { $value = array_map('trim', explode(',', $value)); } From fa564c52d3e7f080f5f8e0b2208e0fdbde17dcaa Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:08:08 +0000 Subject: [PATCH 43/70] Correct PHP namespace separators --- src/WordPress/DateQueryArg.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php index 0d01320..42d5893 100644 --- a/src/WordPress/DateQueryArg.php +++ b/src/WordPress/DateQueryArg.php @@ -10,7 +10,7 @@ declare(strict_types=1); -namespace Pentarix\\WordPressDatabase\\WordPress; +namespace Pentarix\WordPressDatabase\WordPress; class DateQueryArg implements QueryArg { From bf2d1ee2d7c2aad2ee4132c5508f01b8e6c82cbe Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:10:38 +0000 Subject: [PATCH 44/70] Preserve scalar post status query values --- src/WordPress/PostQueryArg.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WordPress/PostQueryArg.php b/src/WordPress/PostQueryArg.php index 9f883af..e6f07db 100644 --- a/src/WordPress/PostQueryArg.php +++ b/src/WordPress/PostQueryArg.php @@ -68,7 +68,7 @@ public function __construct(array $queryArray = []) 'is' => $queryArray['post_type'] ?? null, ], ['isIdentifier' => false, 'multiValue' => true, 'includeKey' => 'post_type__in', 'isKey' => 'post_type']); $this->postStatusQuery = IncludeExcludeIsQueryArg::make([ - 'is' => isset($queryArray['post_status']) ? (array) $queryArray['post_status'] : null, + 'is' => $queryArray['post_status'] ?? null, ], ['isIdentifier' => false, 'multiValue' => true, 'isKey' => 'post_status']); $this->searchQuery = SearchQueryArg::make([ 'search' => $queryArray['s'] ?? '', @@ -164,7 +164,7 @@ public function postType(string|array $types): self { $this->postTypeQuery->is($ public function postTypesIn(string|array $types): self { $this->postTypeQuery->include($types); return $this; } - public function postStatus(string|array $statuses): self { $this->postStatusQuery->is((array) $statuses); + public function postStatus(string|array $statuses): self { $this->postStatusQuery->is($statuses); return $this; } public function postStatusesIn(string|array $statuses): self { $this->postStatusQuery->include($statuses); From bf43e477ce8e8030079c7ecb0cd2d0bb80dcbb77 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:10:41 +0000 Subject: [PATCH 45/70] Use integer site status query flags --- src/WordPress/SiteQueryArg.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/WordPress/SiteQueryArg.php b/src/WordPress/SiteQueryArg.php index 73f46ab..1421780 100644 --- a/src/WordPress/SiteQueryArg.php +++ b/src/WordPress/SiteQueryArg.php @@ -28,11 +28,11 @@ class SiteQueryArg implements QueryArg private string|array|null $orderby = null; private ?string $order = null; private ?string $fields = null; - private ?bool $public = null; - private ?bool $archived = null; - private ?bool $mature = null; - private ?bool $spam = null; - private ?bool $deleted = null; + private ?int $public = null; + private ?int $archived = null; + private ?int $mature = null; + private ?int $spam = null; + private ?int $deleted = null; private ?int $langId = null; private ?bool $updateSiteCache = null; @@ -72,19 +72,19 @@ public function __construct(array $queryArray = []) $this->fields = (string) $queryArray['fields']; } if (array_key_exists('public', $queryArray)) { - $this->public = (bool) $queryArray['public']; + $this->public = null === $queryArray['public'] ? null : (int) $queryArray['public']; } if (array_key_exists('archived', $queryArray)) { - $this->archived = (bool) $queryArray['archived']; + $this->archived = null === $queryArray['archived'] ? null : (int) $queryArray['archived']; } if (array_key_exists('mature', $queryArray)) { - $this->mature = (bool) $queryArray['mature']; + $this->mature = null === $queryArray['mature'] ? null : (int) $queryArray['mature']; } if (array_key_exists('spam', $queryArray)) { - $this->spam = (bool) $queryArray['spam']; + $this->spam = null === $queryArray['spam'] ? null : (int) $queryArray['spam']; } if (array_key_exists('deleted', $queryArray)) { - $this->deleted = (bool) $queryArray['deleted']; + $this->deleted = null === $queryArray['deleted'] ? null : (int) $queryArray['deleted']; } if (array_key_exists('lang_id', $queryArray)) { $this->langId = (int) $queryArray['lang_id']; From 2c4b1c72341498c693946daa8b84851995800fec Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:10:43 +0000 Subject: [PATCH 46/70] Cover WordPress post status and site flag compatibility --- tests/WordPress/QueryArgumentBuildersTest.php | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 2ffd6b9..e6b7293 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -298,10 +298,13 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void self::assertSame(['value' => 2, 'compare' => '>='], $args['comment_count']); } - public function testPostQueryArgPreservesScalarPostTypeAndSpecialValues(): void + public function testPostQueryArgPreservesScalarPostTypeAndStatusValues(): void { self::assertSame(['post_type' => 'post'], PostQueryArg::make(['post_type' => 'post'])->toArray()); self::assertSame(['post_type' => 'any'], PostQueryArg::make()->postType('any')->toArray()); + self::assertSame(['post_status' => 'publish'], PostQueryArg::make(['post_status' => 'publish'])->toArray()); + self::assertSame(['post_status' => 'draft'], PostQueryArg::make()->postStatus('draft')->toArray()); + self::assertSame(['post_status' => ['draft', 'private']], PostQueryArg::make()->postStatus(['draft', 'private'])->toArray()); } public function testPostQueryArgAcceptsArrayNestedQueriesAndEmptyDefaults(): void @@ -424,6 +427,28 @@ public function testSiteQueryArgMapsIdsAndOptions(): void self::assertSame([], SiteQueryArg::make()->toArray()); } + public function testSiteQueryArgNormalisesStatusFlagsToWordPressIntegers(): void + { + self::assertSame( + ['public' => 0, 'archived' => 1, 'mature' => 0, 'spam' => 1, 'deleted' => 0], + SiteQueryArg::make([ + 'public' => false, + 'archived' => true, + 'mature' => false, + 'spam' => true, + 'deleted' => false, + ])->toArray() + ); + + self::assertSame([], SiteQueryArg::make([ + 'public' => null, + 'archived' => null, + 'mature' => null, + 'spam' => null, + 'deleted' => null, + ])->toArray()); + } + public function testTermQueryArgMapsTaxonomyAndTermOptions(): void { $query = TermQueryArg::make([ From 094a55eb6f5bcab923a3b4bfc62fd9b8737104e1 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:10:53 +0000 Subject: [PATCH 47/70] Assert integer values for site query flags --- tests/WordPress/QueryArgumentBuildersTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index e6b7293..5f3a591 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -422,8 +422,8 @@ public function testSiteQueryArgMapsIdsAndOptions(): void self::assertSame(5, $args['ID']); self::assertSame(8, $args['network_id']); self::assertSame('changed', $args['search']); - self::assertFalse($args['public']); - self::assertTrue($args['archived']); + self::assertSame(0, $args['public']); + self::assertSame(1, $args['archived']); self::assertSame([], SiteQueryArg::make()->toArray()); } From c3879e5502fc73427f7855573eafe44fadbbbea1 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:11:23 +0000 Subject: [PATCH 48/70] Remove unsupported term ID query argument --- src/WordPress/TermQueryArg.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/WordPress/TermQueryArg.php b/src/WordPress/TermQueryArg.php index fa777ee..66236e4 100644 --- a/src/WordPress/TermQueryArg.php +++ b/src/WordPress/TermQueryArg.php @@ -17,7 +17,6 @@ class TermQueryArg implements QueryArg use SupportsQueryArguments; private IncludeExcludeQueryArg $termIds; - private ?int $termId = null; private ?int $parent = null; private string|array|null $taxonomy = null; private string|array|null $name = null; @@ -41,9 +40,6 @@ public function __construct(array $queryArray = []) { $this->initialiseQueryArguments($queryArray); $this->termIds = IncludeExcludeQueryArg::make(['include' => $queryArray['include'] ?? [], 'exclude' => $queryArray['exclude'] ?? []], ['includeKey' => 'include', 'excludeKey' => 'exclude']); - if (array_key_exists('term_id', $queryArray)) { - $this->termId = (int) $queryArray['term_id']; - } if (array_key_exists('parent', $queryArray)) { $this->parent = (int) $queryArray['parent']; } @@ -138,7 +134,6 @@ public function toArray(): array $args = []; $args += $this->termIds->toArray(); $values = [ - 'term_id' => $this->termId, 'parent' => $this->parent, 'taxonomy' => $this->taxonomy, 'name' => $this->name, From 3818d262b151297975c81cef441c13eee0e93c0f Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:11:25 +0000 Subject: [PATCH 49/70] Remove unsupported term query arguments from core tests --- tests/WordPress/QueryArgumentBuildersTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 5f3a591..4d0462b 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -454,8 +454,6 @@ public function testTermQueryArgMapsTaxonomyAndTermOptions(): void $query = TermQueryArg::make([ 'include' => [1, 2], 'exclude' => [3], - 'parent__in' => [4], - 'term_id' => '5', 'parent' => 6, 'taxonomy' => ['category', 'post_tag'], 'name' => ['News', 'Updates'], @@ -479,7 +477,6 @@ public function testTermQueryArgMapsTaxonomyAndTermOptions(): void $args = $query->toArray(); self::assertSame([2, 7], $args['include']); self::assertSame([3, 1], $args['exclude']); - self::assertSame(5, $args['term_id']); self::assertSame('post', $args['taxonomy']); self::assertSame(['News', 'Updates'], $args['name']); self::assertSame(['news', 'updates'], $args['slug']); From a6f70f903ab408d26be68d08fc51b38bb99f8696 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:11:55 +0000 Subject: [PATCH 50/70] Match WordPress post order fallback --- src/WordPress/PostQueryArg.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WordPress/PostQueryArg.php b/src/WordPress/PostQueryArg.php index e6f07db..30eb7c4 100644 --- a/src/WordPress/PostQueryArg.php +++ b/src/WordPress/PostQueryArg.php @@ -92,7 +92,7 @@ public function __construct(array $queryArray = []) $this->orderBy = $queryArray['orderby']; } if (isset($queryArray['order'])) { - $this->order = 'DESC' === strtoupper((string) $queryArray['order']) ? 'DESC' : 'ASC'; + $this->order = 'ASC' === strtoupper((string) $queryArray['order']) ? 'ASC' : 'DESC'; } if (isset($queryArray['name'])) { $this->postName = (string) $queryArray['name']; @@ -209,7 +209,7 @@ public function offset(int $offset): self { $this->offset = max(0, $offset); public function orderBy(string|array $column): self { $this->orderBy = $column; return $this; } - public function order(string $order): self { $this->order = 'DESC' === strtoupper($order) ? 'DESC' : 'ASC'; + public function order(string $order): self { $this->order = 'ASC' === strtoupper($order) ? 'ASC' : 'DESC'; return $this; } public function fields(string $fields): self { $this->fields = $fields; From f9a38fce90234e2c2c6a38430fe90301b230362c Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:11:57 +0000 Subject: [PATCH 51/70] Match WordPress user order fallback --- src/WordPress/UserQueryArg.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/WordPress/UserQueryArg.php b/src/WordPress/UserQueryArg.php index 9257ca8..ea8df6e 100644 --- a/src/WordPress/UserQueryArg.php +++ b/src/WordPress/UserQueryArg.php @@ -177,9 +177,9 @@ public function __construct(array $queryArray = []) } if (isset($queryArray['order'])) { - $this->order = 'DESC' === \strtoupper($queryArray['order']) - ? 'DESC' - : 'ASC'; + $this->order = 'ASC' === \strtoupper($queryArray['order']) + ? 'ASC' + : 'DESC'; } if (isset($queryArray['number'])) { @@ -478,9 +478,9 @@ public function searchByDisplayName(string $value): self public function orderBy(string|array $field, string $direction = 'ASC'): self { $this->orderBy = $field; - $this->order = 'DESC' === \strtoupper($direction) - ? 'DESC' - : 'ASC'; + $this->order = 'ASC' === \strtoupper($direction) + ? 'ASC' + : 'DESC'; return $this; } From cae7621774f3e1fe5ea31a8cee7694a07b8aceca Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:11:59 +0000 Subject: [PATCH 52/70] Test WordPress order fallback for post and user queries --- tests/WordPress/QueryArgumentBuildersTest.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 4d0462b..8c1fe35 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -289,7 +289,7 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void self::assertSame(5, $args['posts_per_page']); self::assertSame(3, $args['paged']); self::assertSame(2, $args['offset']); - self::assertSame('ASC', $args['order']); + self::assertSame('DESC', $args['order']); self::assertSame('ids', $args['fields']); self::assertSame($date->toArray(), $args['date_query']); self::assertSame($meta->toArray(), $args['meta_query']); @@ -298,6 +298,13 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void self::assertSame(['value' => 2, 'compare' => '>='], $args['comment_count']); } + public function testPostQueryArgUsesWordPressOrderFallback(): void + { + self::assertSame(['order' => 'DESC'], PostQueryArg::make(['order' => 'invalid'])->toArray()); + self::assertSame(['order' => 'DESC'], PostQueryArg::make()->order('invalid')->toArray()); + self::assertSame(['order' => 'ASC'], PostQueryArg::make()->order('asc')->toArray()); + } + public function testPostQueryArgPreservesScalarPostTypeAndStatusValues(): void { self::assertSame(['post_type' => 'post'], PostQueryArg::make(['post_type' => 'post'])->toArray()); @@ -634,6 +641,13 @@ public function testDateQueryArgSupportsCompleteDatePartsAndArrayValues(): void self::assertSame([1, 5], $args[4]['dayofweek_iso']); } + public function testUserQueryArgUsesWordPressOrderFallback(): void + { + self::assertSame('DESC', UserQueryArg::make(['order' => 'invalid'])->toArray()['order']); + self::assertSame('DESC', UserQueryArg::make()->orderBy('ID', 'invalid')->toArray()['order']); + self::assertSame('ASC', UserQueryArg::make()->orderBy('ID', 'asc')->toArray()['order']); + } + public function testUserQueryArgAcceptsMultipleMetaKeys(): void { self::assertSame( From 53b8852a741cf14d9000418691be0ca76381921f Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:12:22 +0000 Subject: [PATCH 53/70] Preserve null site query flags in compatibility test --- tests/WordPress/QueryArgumentBuildersTest.php | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 8c1fe35..d4040e0 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -447,13 +447,22 @@ public function testSiteQueryArgNormalisesStatusFlagsToWordPressIntegers(): void ])->toArray() ); - self::assertSame([], SiteQueryArg::make([ - 'public' => null, - 'archived' => null, - 'mature' => null, - 'spam' => null, - 'deleted' => null, - ])->toArray()); + self::assertSame( + [ + 'public' => null, + 'archived' => null, + 'mature' => null, + 'spam' => null, + 'deleted' => null, + ], + SiteQueryArg::make([ + 'public' => null, + 'archived' => null, + 'mature' => null, + 'spam' => null, + 'deleted' => null, + ])->toArray() + ); } public function testTermQueryArgMapsTaxonomyAndTermOptions(): void From d309c18541dfedcca92bf629b4b74bb357e197c8 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:12:59 +0000 Subject: [PATCH 54/70] Parse WordPress comma and space separated ID lists --- src/WordPress/IncludeQueryArg.php | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/WordPress/IncludeQueryArg.php b/src/WordPress/IncludeQueryArg.php index 8128391..d3cfdde 100644 --- a/src/WordPress/IncludeQueryArg.php +++ b/src/WordPress/IncludeQueryArg.php @@ -51,12 +51,29 @@ protected function cast(int|float|string $value): int|string return true === $this->isIdentifier ? NumberUtil::absint($value) : (string) $value; } - public function include(int|float|string|array $value): static + protected function normaliseInput(int|float|string|array $value): int|float|string|array { - if ($this->isIdentifier && \is_string($value) && str_contains($value, ',')) { - $value = array_map('trim', explode(',', $value)); + if (!$this->isIdentifier || !\is_string($value)) { + return $value; + } + + $value = \trim($value); + + if ('' === $value) { + return []; + } + + if (\preg_match('/[,\\s]/', $value)) { + return \preg_split('/[,\\s]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; } + return $value; + } + + public function include(int|float|string|array $value): static + { + $value = $this->normaliseInput($value); + $this->mergeIncluded( ArrayUtil::castValues($value, fn($item) => $this->cast($item)) ); From 22595f948ad53ce8a2ac2b76f7aa99f5744c5fd8 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:13:01 +0000 Subject: [PATCH 55/70] Reuse ID list normalisation for exclusions --- src/WordPress/IncludeExcludeQueryArg.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/WordPress/IncludeExcludeQueryArg.php b/src/WordPress/IncludeExcludeQueryArg.php index 57b01be..ef9b8b3 100644 --- a/src/WordPress/IncludeExcludeQueryArg.php +++ b/src/WordPress/IncludeExcludeQueryArg.php @@ -48,9 +48,7 @@ public static function make(array $queryArray = [], array $args = []): static public function include(int|float|string|array $value): static { - if ($this->isIdentifier && \is_string($value) && str_contains($value, ',')) { - $value = array_map('trim', explode(',', $value)); - } + $value = $this->normaliseInput($value); $values = ArrayUtil::castValues($value, fn($item) => $this->cast($item)); @@ -62,9 +60,7 @@ public function include(int|float|string|array $value): static public function exclude(int|float|string|array $value): static { - if ($this->isIdentifier && \is_string($value) && str_contains($value, ',')) { - $value = array_map('trim', explode(',', $value)); - } + $value = $this->normaliseInput($value); $values = ArrayUtil::castValues($value, fn($item) => $this->cast($item)); From 12bbecedae22ef7d2228cbecb331e2164361998c Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:13:03 +0000 Subject: [PATCH 56/70] Test comma and whitespace separated term ID lists --- tests/WordPress/QueryArgumentBuildersTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index d4040e0..46fbe46 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -60,6 +60,15 @@ public function testIncludeExcludeQueryArgKeepsListsDisjoint(): void self::assertSame(['only' => [2, 3, 5], 'except' => [4, 1, 6]], $query->toArray()); self::assertSame($query, $query->exclude(5)); self::assertSame(['only' => [2, 3], 'except' => [4, 1, 6, 5]], $query->toArray()); + + self::assertSame( + ['include' => [1, 2, 3], 'exclude' => [4, 5]], + IncludeExcludeQueryArg::make([ + 'include' => '1 2, 3', + 'exclude' => '4 5', + ])->toArray() + ); + self::assertSame([], IncludeQueryArg::make(['include' => ''])->toArray()); } public function testIncludeExcludeIsQueryArgSupportsSingleAndMultipleValues(): void From ca829710375ac56328ce7f2dced05753344e7aa3 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:23:02 +0000 Subject: [PATCH 57/70] Preserve WordPress post order arrays and correct status helpers --- src/WordPress/PostQueryArg.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/WordPress/PostQueryArg.php b/src/WordPress/PostQueryArg.php index 30eb7c4..6d992ae 100644 --- a/src/WordPress/PostQueryArg.php +++ b/src/WordPress/PostQueryArg.php @@ -30,7 +30,7 @@ class PostQueryArg implements QueryArg private ?int $page = null; private ?int $offset = null; private string|array|null $orderBy = null; - private ?string $order = null; + private string|array|null $order = null; private ?string $postName = null; private array $postNameIn = []; private ?int $menuOrder = null; @@ -92,7 +92,9 @@ public function __construct(array $queryArray = []) $this->orderBy = $queryArray['orderby']; } if (isset($queryArray['order'])) { - $this->order = 'ASC' === strtoupper((string) $queryArray['order']) ? 'ASC' : 'DESC'; + $this->order = is_array($queryArray['order']) + ? $queryArray['order'] + : ('ASC' === strtoupper((string) $queryArray['order']) ? 'ASC' : 'DESC'); } if (isset($queryArray['name'])) { $this->postName = (string) $queryArray['name']; @@ -167,12 +169,7 @@ public function postTypesIn(string|array $types): self { $this->postTypeQuery->i public function postStatus(string|array $statuses): self { $this->postStatusQuery->is($statuses); return $this; } - public function postStatusesIn(string|array $statuses): self { $this->postStatusQuery->include($statuses); - - return $this; } - public function postStatusesNotIn(string|array $statuses): self { $this->postStatusQuery->exclude($statuses); - - return $this; } + public function postStatusesIn(string|array $statuses): self { return $this->postStatus($statuses); } public function authorsIn(int|float|string|array $ids): self { $this->authorQuery->include($ids); return $this; } @@ -209,7 +206,7 @@ public function offset(int $offset): self { $this->offset = max(0, $offset); public function orderBy(string|array $column): self { $this->orderBy = $column; return $this; } - public function order(string $order): self { $this->order = 'ASC' === strtoupper($order) ? 'ASC' : 'DESC'; + public function order(string|array $order): self { $this->order = is_array($order) ? $order : ('ASC' === strtoupper($order) ? 'ASC' : 'DESC'); return $this; } public function fields(string $fields): self { $this->fields = $fields; From 7d290b266e8c74b5bf760a87ef3ad981bbdbfe84 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:23:05 +0000 Subject: [PATCH 58/70] Normalise existing date clauses before adding fluent conditions --- src/WordPress/DateQueryArg.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php index 42d5893..58c2e5e 100644 --- a/src/WordPress/DateQueryArg.php +++ b/src/WordPress/DateQueryArg.php @@ -192,6 +192,10 @@ private function addClause(array $clause, string $boolean): self { $boolean = 'OR' === strtoupper($boolean) ? 'OR' : 'AND'; + if ($this->isFirstOrderClause($this->query)) { + $this->query = [$this->query]; + } + if ([] === $this->query) { $this->query = [$clause]; @@ -225,6 +229,28 @@ private function addClause(array $clause, string $boolean): self return $this; } + private function isFirstOrderClause(array $query): bool + { + $dateKeys = [ + 'after', + 'before', + 'year', + 'month', + 'monthnum', + 'week', + 'w', + 'dayofyear', + 'day', + 'dayofweek', + 'dayofweek_iso', + 'hour', + 'minute', + 'second', + ]; + + return [] !== array_intersect($dateKeys, array_keys($query)); + } + private function addGroup( callable $callback, string $relation, From cfe9e53b4c8b5025f3a586a89389870fdbd28d1a Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:23:07 +0000 Subject: [PATCH 59/70] Enforce WordPress term orderby string contract --- src/WordPress/TermQueryArg.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/WordPress/TermQueryArg.php b/src/WordPress/TermQueryArg.php index 66236e4..e3bff99 100644 --- a/src/WordPress/TermQueryArg.php +++ b/src/WordPress/TermQueryArg.php @@ -12,6 +12,8 @@ namespace Pentarix\WordPressDatabase\WordPress; +use InvalidArgumentException; + class TermQueryArg implements QueryArg { use SupportsQueryArguments; @@ -26,7 +28,7 @@ class TermQueryArg implements QueryArg private ?string $descriptionLike = null; private ?int $number = null; private ?int $offset = null; - private string|array|null $orderby = null; + private ?string $orderby = null; private ?string $order = null; private ?string $fields = null; private ?bool $hideEmpty = null; @@ -68,6 +70,10 @@ public function __construct(array $queryArray = []) $this->offset = (int) $queryArray['offset']; } if (array_key_exists('orderby', $queryArray)) { + if (null !== $queryArray['orderby'] && !is_string($queryArray['orderby'])) { + throw new InvalidArgumentException('The term query orderby argument must be a string.'); + } + $this->orderby = $queryArray['orderby']; } if (array_key_exists('order', $queryArray)) { @@ -122,6 +128,13 @@ public function taxonomy(string|array $taxonomy): self return $this; } + public function orderBy(string $orderby): self + { + $this->orderby = $orderby; + + return $this; + } + public function search(string $value): self { $this->search = $value; From 6766b0dff249d77b31eb06fa1488854d90cf04a9 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:23:18 +0000 Subject: [PATCH 60/70] Add regression tests for WordPress query argument contracts --- tests/WordPress/QueryArgumentBuildersTest.php | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 46fbe46..73dae2d 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -274,8 +274,7 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void ->excludeIds(1) ->postType('book') ->postTypesIn('page') - ->postStatus('draft') - ->postStatusesNotIn('private') + ->postStatusesIn(['draft', 'private']) ->authorsIn('8') ->parentsNotIn(9) ->search('updated') @@ -299,6 +298,7 @@ public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void self::assertSame(3, $args['paged']); self::assertSame(2, $args['offset']); self::assertSame('DESC', $args['order']); + self::assertSame(['draft', 'private'], $args['post_status']); self::assertSame('ids', $args['fields']); self::assertSame($date->toArray(), $args['date_query']); self::assertSame($meta->toArray(), $args['meta_query']); @@ -312,6 +312,10 @@ public function testPostQueryArgUsesWordPressOrderFallback(): void self::assertSame(['order' => 'DESC'], PostQueryArg::make(['order' => 'invalid'])->toArray()); self::assertSame(['order' => 'DESC'], PostQueryArg::make()->order('invalid')->toArray()); self::assertSame(['order' => 'ASC'], PostQueryArg::make()->order('asc')->toArray()); + + $orders = ['title' => 'DESC', 'menu_order' => 'ASC']; + self::assertSame(['order' => $orders], PostQueryArg::make(['order' => $orders])->toArray()); + self::assertSame(['order' => $orders], PostQueryArg::make()->order($orders)->toArray()); } public function testPostQueryArgPreservesScalarPostTypeAndStatusValues(): void @@ -513,6 +517,18 @@ public function testTermQueryArgMapsTaxonomyAndTermOptions(): void self::assertSame(['include' => [1, 2, 3], 'exclude' => [4, 5]], TermQueryArg::make(['include' => '1,2,3', 'exclude' => '4,5'])->toArray()); } + public function testTermQueryArgRequiresAStringOrderByValue(): void + { + self::expectException(\\InvalidArgumentException::class); + + TermQueryArg::make(['orderby' => ['name' => 'ASC']]); + } + + public function testTermQueryArgSupportsFluentOrderBy(): void + { + self::assertSame(['orderby' => 'name'], TermQueryArg::make()->orderBy('name')->toArray()); + } + public function testUserQueryArgMapsIdentitySearchMetaAndPagination(): void { $query = UserQueryArg::make([ @@ -659,6 +675,19 @@ public function testDateQueryArgSupportsCompleteDatePartsAndArrayValues(): void self::assertSame([1, 5], $args[4]['dayofweek_iso']); } + public function testDateQueryArgWrapsAnExistingSingleClauseBeforeAppending(): void + { + self::assertSame( + [['year' => 2020], ['month' => 6]], + DateQueryArg::make(['year' => 2020])->month(6)->toArray() + ); + + self::assertSame( + ['relation' => 'OR', ['year' => 2020], ['month' => 6]], + DateQueryArg::make(['year' => 2020])->orWhere(['month' => 6])->toArray() + ); + } + public function testUserQueryArgUsesWordPressOrderFallback(): void { self::assertSame('DESC', UserQueryArg::make(['order' => 'invalid'])->toArray()['order']); From c4ddc4070f5e14c0b1321db7c18a1bfea6c7c4c5 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 03:23:29 +0000 Subject: [PATCH 61/70] Correct regression test expectations for nested date clauses --- tests/WordPress/QueryArgumentBuildersTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 73dae2d..50245ca 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -519,7 +519,7 @@ public function testTermQueryArgMapsTaxonomyAndTermOptions(): void public function testTermQueryArgRequiresAStringOrderByValue(): void { - self::expectException(\\InvalidArgumentException::class); + self::expectException(\InvalidArgumentException::class); TermQueryArg::make(['orderby' => ['name' => 'ASC']]); } @@ -683,7 +683,7 @@ public function testDateQueryArgWrapsAnExistingSingleClauseBeforeAppending(): vo ); self::assertSame( - ['relation' => 'OR', ['year' => 2020], ['month' => 6]], + ['relation' => 'OR', [['year' => 2020]], ['month' => 6]], DateQueryArg::make(['year' => 2020])->orWhere(['month' => 6])->toArray() ); } From 1b8f895b7a10ba529202cbd7f1f80a01a02bcccc Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 04:07:57 +0000 Subject: [PATCH 62/70] Fix post order argument type --- src/WordPress/PostQueryArg.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/WordPress/PostQueryArg.php b/src/WordPress/PostQueryArg.php index 6d992ae..9fa1953 100644 --- a/src/WordPress/PostQueryArg.php +++ b/src/WordPress/PostQueryArg.php @@ -30,7 +30,7 @@ class PostQueryArg implements QueryArg private ?int $page = null; private ?int $offset = null; private string|array|null $orderBy = null; - private string|array|null $order = null; + private ?string $order = null; private ?string $postName = null; private array $postNameIn = []; private ?int $menuOrder = null; @@ -92,9 +92,11 @@ public function __construct(array $queryArray = []) $this->orderBy = $queryArray['orderby']; } if (isset($queryArray['order'])) { - $this->order = is_array($queryArray['order']) - ? $queryArray['order'] - : ('ASC' === strtoupper((string) $queryArray['order']) ? 'ASC' : 'DESC'); + if (!is_string($queryArray['order'])) { + throw new \InvalidArgumentException('The order argument must be a string.'); + } + + $this->order = 'ASC' === strtoupper($queryArray['order']) ? 'ASC' : 'DESC'; } if (isset($queryArray['name'])) { $this->postName = (string) $queryArray['name']; @@ -206,7 +208,7 @@ public function offset(int $offset): self { $this->offset = max(0, $offset); public function orderBy(string|array $column): self { $this->orderBy = $column; return $this; } - public function order(string|array $order): self { $this->order = is_array($order) ? $order : ('ASC' === strtoupper($order) ? 'ASC' : 'DESC'); + public function order(string $order): self { $this->order = 'ASC' === strtoupper($order) ? 'ASC' : 'DESC'; return $this; } public function fields(string $fields): self { $this->fields = $fields; From c1841de3492b6921f08233859591c2c78d274dea Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 04:08:00 +0000 Subject: [PATCH 63/70] Preserve initial meta query clauses --- src/WordPress/MetaQueryArg.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/WordPress/MetaQueryArg.php b/src/WordPress/MetaQueryArg.php index 96af4d7..004397a 100644 --- a/src/WordPress/MetaQueryArg.php +++ b/src/WordPress/MetaQueryArg.php @@ -174,6 +174,10 @@ private function addClause(array $clause, string $boolean): self return $this; } + if ($this->isFirstOrderClause($this->query)) { + $this->query = [$this->query]; + } + if ('OR' === $boolean) { if (isset($this->query['relation']) && 'OR' === $this->query['relation']) { $this->query[] = $clause; @@ -226,6 +230,11 @@ private function addGroup(callable $callback, string $relation, string $boolean) return $this->addClause($conditions, $boolean); } + private function isFirstOrderClause(array $query): bool + { + return isset($query['key']) || isset($query['value']); + } + public function toArray(): array { return $this->query; From 5fca4d1d684c5c1d024efdf445194b3171896d9f Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 04:08:03 +0000 Subject: [PATCH 64/70] Preserve initial tax query clauses --- src/WordPress/TaxQueryArg.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/WordPress/TaxQueryArg.php b/src/WordPress/TaxQueryArg.php index 970b6ba..41e8150 100644 --- a/src/WordPress/TaxQueryArg.php +++ b/src/WordPress/TaxQueryArg.php @@ -172,6 +172,10 @@ private function addClause(array $clause, string $boolean): self return $this; } + if ($this->isFirstOrderClause($this->query)) { + $this->query = [$this->query]; + } + if ('OR' === $boolean) { if (isset($this->query['relation']) && 'OR' === $this->query['relation']) { $this->query[] = $clause; @@ -228,6 +232,11 @@ private function addGroup( return $this->addClause($query, $boolean); } + private function isFirstOrderClause(array $query): bool + { + return isset($query['taxonomy']) || isset($query['terms']); + } + public function toArray(): array { return $this->query; From 9f40b68316b66e7e3eed51b7756384095576116e Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 04:08:06 +0000 Subject: [PATCH 65/70] Test query argument compatibility fixes --- tests/WordPress/QueryArgumentBuildersTest.php | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 50245ca..42a25b3 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -247,6 +247,61 @@ public function testTaxQueryArgBuildsConditionsAndGroups(): void self::assertSame('EXISTS', $array[3]['operator']); } + public function testMetaQueryArgWrapsAnExistingFirstOrderClauseBeforeAppending(): void + { + self::assertSame( + [ + ['key' => 'visibility', 'value' => 1], + ['key' => 'featured', 'value' => 1, 'compare' => '=', 'type' => 'CHAR'], + ], + MetaQueryArg::make(['key' => 'visibility', 'value' => 1]) + ->where('featured', 1) + ->toArray() + ); + + self::assertSame( + [ + 'relation' => 'OR', + ['key' => 'visibility', 'value' => 1], + ['key' => 'featured', 'value' => 1, 'compare' => '=', 'type' => 'CHAR'], + ], + MetaQueryArg::make(['key' => 'visibility', 'value' => 1]) + ->orWhere('featured', 1) + ->toArray() + ); + } + + public function testTaxQueryArgWrapsAnExistingFirstOrderClauseBeforeAppending(): void + { + $initialClause = [ + 'taxonomy' => 'category', + 'terms' => [1], + 'field' => 'term_id', + ]; + + $additionalClause = [ + 'taxonomy' => 'post_tag', + 'terms' => ['news'], + 'field' => 'slug', + 'operator' => 'IN', + 'include_children' => true, + ]; + + self::assertSame( + [$initialClause, $additionalClause], + TaxQueryArg::make($initialClause) + ->where('post_tag', 'news', 'slug') + ->toArray() + ); + + self::assertSame( + ['relation' => 'OR', $initialClause, $additionalClause], + TaxQueryArg::make($initialClause) + ->orWhere('post_tag', 'news', 'slug') + ->toArray() + ); + } + public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void { $date = DateQueryArg::make()->year(2024); @@ -312,10 +367,13 @@ public function testPostQueryArgUsesWordPressOrderFallback(): void self::assertSame(['order' => 'DESC'], PostQueryArg::make(['order' => 'invalid'])->toArray()); self::assertSame(['order' => 'DESC'], PostQueryArg::make()->order('invalid')->toArray()); self::assertSame(['order' => 'ASC'], PostQueryArg::make()->order('asc')->toArray()); + } + + public function testPostQueryArgRejectsArrayOrderArguments(): void + { + $this->expectException(\InvalidArgumentException::class); - $orders = ['title' => 'DESC', 'menu_order' => 'ASC']; - self::assertSame(['order' => $orders], PostQueryArg::make(['order' => $orders])->toArray()); - self::assertSame(['order' => $orders], PostQueryArg::make()->order($orders)->toArray()); + PostQueryArg::make(['order' => ['title' => 'DESC']]); } public function testPostQueryArgPreservesScalarPostTypeAndStatusValues(): void From 7997e2447d8eefa77657d3636b0258bb2d11ce07 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 04:08:22 +0000 Subject: [PATCH 66/70] Match WordPress tax clause detection --- src/WordPress/TaxQueryArg.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/WordPress/TaxQueryArg.php b/src/WordPress/TaxQueryArg.php index 41e8150..69119a6 100644 --- a/src/WordPress/TaxQueryArg.php +++ b/src/WordPress/TaxQueryArg.php @@ -234,7 +234,12 @@ private function addGroup( private function isFirstOrderClause(array $query): bool { - return isset($query['taxonomy']) || isset($query['terms']); + return [] === $query + || array_key_exists('terms', $query) + || array_key_exists('taxonomy', $query) + || array_key_exists('include_children', $query) + || array_key_exists('field', $query) + || array_key_exists('operator', $query); } public function toArray(): array From 181041b3e6fed596dfcedc3acc2c81aa58d1e5ad Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 04:08:27 +0000 Subject: [PATCH 67/70] Cover all WordPress tax clause forms --- tests/WordPress/QueryArgumentBuildersTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index 42a25b3..bb8bcba 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -300,6 +300,18 @@ public function testTaxQueryArgWrapsAnExistingFirstOrderClauseBeforeAppending(): ->orWhere('post_tag', 'news', 'slug') ->toArray() ); + + $fieldOnlyClause = [ + 'field' => 'term_taxonomy_id', + 'operator' => 'EXISTS', + ]; + + self::assertSame( + [$fieldOnlyClause, $additionalClause], + TaxQueryArg::make($fieldOnlyClause) + ->where('post_tag', 'news', 'slug') + ->toArray() + ); } public function testPostQueryArgMapsNestedQueriesAndFluentMethods(): void From 10065bb4e4a3aa9b32d537195bb9a7adf9d55d24 Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 07:38:31 +0000 Subject: [PATCH 68/70] Fix OR composition for initial meta query clauses --- src/WordPress/MetaQueryArg.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/WordPress/MetaQueryArg.php b/src/WordPress/MetaQueryArg.php index 004397a..2e104a5 100644 --- a/src/WordPress/MetaQueryArg.php +++ b/src/WordPress/MetaQueryArg.php @@ -174,13 +174,21 @@ private function addClause(array $clause, string $boolean): self return $this; } - if ($this->isFirstOrderClause($this->query)) { + $isFirstOrderClause = $this->isFirstOrderClause($this->query); + + if ($isFirstOrderClause) { $this->query = [$this->query]; } if ('OR' === $boolean) { if (isset($this->query['relation']) && 'OR' === $this->query['relation']) { $this->query[] = $clause; + } elseif ($isFirstOrderClause) { + $this->query = array_merge( + ['relation' => 'OR'], + $this->query, + [$clause] + ); } else { $this->query = [ 'relation' => 'OR', From 34285d8d79b58d66502045c21dde9d90be62e0ca Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 07:38:33 +0000 Subject: [PATCH 69/70] Fix OR composition for initial tax query clauses --- src/WordPress/TaxQueryArg.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/WordPress/TaxQueryArg.php b/src/WordPress/TaxQueryArg.php index 69119a6..7c68ebc 100644 --- a/src/WordPress/TaxQueryArg.php +++ b/src/WordPress/TaxQueryArg.php @@ -172,13 +172,21 @@ private function addClause(array $clause, string $boolean): self return $this; } - if ($this->isFirstOrderClause($this->query)) { + $isFirstOrderClause = $this->isFirstOrderClause($this->query); + + if ($isFirstOrderClause) { $this->query = [$this->query]; } if ('OR' === $boolean) { if (isset($this->query['relation']) && 'OR' === $this->query['relation']) { $this->query[] = $clause; + } elseif ($isFirstOrderClause) { + $this->query = array_merge( + ['relation' => 'OR'], + $this->query, + [$clause] + ); } else { $this->query = [ 'relation' => 'OR', From 1d2a1d076b15ec56a9b4f0cbb8d5bf32ba202d5c Mon Sep 17 00:00:00 2001 From: Sina Kuhestani Date: Thu, 12 Nov 2026 07:38:35 +0000 Subject: [PATCH 70/70] Correct scalar taxonomy terms expectation --- tests/WordPress/QueryArgumentBuildersTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php index bb8bcba..2f35499 100644 --- a/tests/WordPress/QueryArgumentBuildersTest.php +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -281,7 +281,7 @@ public function testTaxQueryArgWrapsAnExistingFirstOrderClauseBeforeAppending(): $additionalClause = [ 'taxonomy' => 'post_tag', - 'terms' => ['news'], + 'terms' => 'news', 'field' => 'slug', 'operator' => 'IN', 'include_children' => true,