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 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..71549f2 100644 --- a/README.md +++ b/README.md @@ -1 +1,89 @@ -# 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`. + +## 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. + +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..3055fe6 --- /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 || ^11.5 || ^12.0" + }, + "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..da10e6d --- /dev/null +++ b/src/WordPress/CommentQueryArg.php @@ -0,0 +1,178 @@ + + * @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 +{ + use SupportsQueryArguments; + + 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|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|string|null $hierarchical = null; + private ?bool $count = null; + private ?bool $noFoundRows = null; + private ?bool $updateCommentMetaCache = null; + private ?bool $updateCommentPostCache = null; + + public function __construct(array $queryArray = []) + { + $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(['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']; + } + 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 = $queryArray['type']; + } + if (array_key_exists('status', $queryArray)) { + $this->status = $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 = $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 $this->mergeQueryArguments($args); + } +} diff --git a/src/WordPress/DateQueryArg.php b/src/WordPress/DateQueryArg.php new file mode 100644 index 0000000..58c2e5e --- /dev/null +++ b/src/WordPress/DateQueryArg.php @@ -0,0 +1,287 @@ + + * @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 = null + ): self { + $clause = [ + 'after' => $date, + 'inclusive' => $inclusive, + ]; + + if (null !== $column) { + $clause['column'] = $column; + } + + return $this->where($clause); + } + + public function before( + string|array $date, + bool $inclusive = false, + ?string $column = null + ): self { + $clause = [ + 'before' => $date, + 'inclusive' => $inclusive, + ]; + + if (null !== $column) { + $clause['column'] = $column; + } + + return $this->where($clause); + } + + public function between( + string|array $after, + string|array $before, + bool $inclusive = true, + ?string $column = null + ): self { + $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 = null, string $compare = '='): self + { + return $this->datePart('year', $year, $column, $compare); + } + + 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 = null, string $compare = '='): self + { + return $this->datePart('week', $week, $column, $compare); + } + + 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 = null, string $compare = '='): self + { + return $this->datePart('hour', $hour, $column, $compare); + } + + 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 = null, string $compare = '='): self + { + return $this->datePart('second', $second, $column, $compare); + } + + 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 = null, string $compare = '='): self + { + return $this->datePart('dayofweek', $day, $column, $compare); + } + + public function dayOfWeekIso(int|array $day, ?string $column = null, string $compare = '='): self + { + return $this->datePart('dayofweek_iso', $day, $column, $compare); + } + + 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 datePart(string $key, int|array $value, ?string $column, string $compare): self + { + $clause = [ + $key => $value, + ]; + + if (null !== $column) { + $clause['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'; + + if ($this->isFirstOrderClause($this->query)) { + $this->query = [$this->query]; + } + + 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 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, + 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..364a1a0 --- /dev/null +++ b/src/WordPress/IncludeExcludeIsQueryArg.php @@ -0,0 +1,86 @@ + + * @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 (\is_string($value)) { + $this->is = '' === $value ? null : $value; + + return $this; + } + + if (!\is_array($value)) { + throw new InvalidArgumentException('Is value must be a string or an array.'); + } + + $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..ef9b8b3 --- /dev/null +++ b/src/WordPress/IncludeExcludeQueryArg.php @@ -0,0 +1,83 @@ + + * @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($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 + { + $value = $this->normaliseInput($value); + + $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 + { + $value = $this->normaliseInput($value); + + $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..d3cfdde --- /dev/null +++ b/src/WordPress/IncludeQueryArg.php @@ -0,0 +1,99 @@ + + * @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($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; + } + + protected function normaliseInput(int|float|string|array $value): int|float|string|array + { + 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)) + ); + + 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..2e104a5 --- /dev/null +++ b/src/WordPress/MetaQueryArg.php @@ -0,0 +1,250 @@ + + * @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; + } + + $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', + $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); + } + + private function isFirstOrderClause(array $query): bool + { + return isset($query['key']) || isset($query['value']); + } + + 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..016bf69 --- /dev/null +++ b/src/WordPress/NetworkQueryArg.php @@ -0,0 +1,105 @@ + + * @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 +{ + use SupportsQueryArguments; + + private IncludeExcludeQueryArg $networkIds; + 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->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('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 toArray(): array + { + $args = []; + $args += $this->networkIds->toArray(); + $values = [ + '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 $this->mergeQueryArguments($args); + } +} diff --git a/src/WordPress/PostQueryArg.php b/src/WordPress/PostQueryArg.php new file mode 100644 index 0000000..9fa1953 --- /dev/null +++ b/src/WordPress/PostQueryArg.php @@ -0,0 +1,276 @@ + + * @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 +{ + use SupportsQueryArguments; + + 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|array|null $commentCount = null; + private ?bool $exact = null; + private ?bool $sentence = null; + private ?string $fields = null; + + public function __construct(array $queryArray = []) + { + $this->initialiseQueryArguments($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' => $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'])) { + 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']; + } + 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 = $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($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($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; } + 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 = 'ASC' === strtoupper($order) ? 'ASC' : 'DESC'; + + 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 $this->mergeQueryArguments($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..1421780 --- /dev/null +++ b/src/WordPress/SiteQueryArg.php @@ -0,0 +1,162 @@ + + * @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 +{ + use SupportsQueryArguments; + + 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 ?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; + + 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)) { + $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 = null === $queryArray['public'] ? null : (int) $queryArray['public']; + } + if (array_key_exists('archived', $queryArray)) { + $this->archived = null === $queryArray['archived'] ? null : (int) $queryArray['archived']; + } + if (array_key_exists('mature', $queryArray)) { + $this->mature = null === $queryArray['mature'] ? null : (int) $queryArray['mature']; + } + if (array_key_exists('spam', $queryArray)) { + $this->spam = null === $queryArray['spam'] ? null : (int) $queryArray['spam']; + } + if (array_key_exists('deleted', $queryArray)) { + $this->deleted = null === $queryArray['deleted'] ? null : (int) $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 $this->mergeQueryArguments($args); + } +} diff --git a/src/WordPress/SupportsQueryArguments.php b/src/WordPress/SupportsQueryArguments.php new file mode 100644 index 0000000..9590b46 --- /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 + ); + } +} diff --git a/src/WordPress/TaxQueryArg.php b/src/WordPress/TaxQueryArg.php new file mode 100644 index 0000000..7c68ebc --- /dev/null +++ b/src/WordPress/TaxQueryArg.php @@ -0,0 +1,257 @@ + + * @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; + } + + $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', + $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); + } + + private function isFirstOrderClause(array $query): bool + { + 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 + { + return $this->query; + } +} diff --git a/src/WordPress/TermQueryArg.php b/src/WordPress/TermQueryArg.php new file mode 100644 index 0000000..e3bff99 --- /dev/null +++ b/src/WordPress/TermQueryArg.php @@ -0,0 +1,177 @@ + + * @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 InvalidArgumentException; + +class TermQueryArg implements QueryArg +{ + use SupportsQueryArguments; + + private IncludeExcludeQueryArg $termIds; + private ?int $parent = null; + private string|array|null $taxonomy = 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; + private ?int $number = null; + private ?int $offset = null; + private ?string $orderby = null; + private ?string $order = null; + private ?string $fields = null; + private ?bool $hideEmpty = null; + private ?bool $hierarchical = null; + private ?bool $padCounts = null; + private ?string $get = null; + private ?bool $updateTermMetaCache = null; + private ?string $cacheDomain = null; + + 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('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 = is_array($queryArray['name']) ? array_values(array_map('strval', $queryArray['name'])) : (string) $queryArray['name']; + } + if (array_key_exists('slug', $queryArray)) { + $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']; + } + 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)) { + 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)) { + $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 = (string) $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 orderBy(string $orderby): self + { + $this->orderby = $orderby; + + return $this; + } + + public function search(string $value): self + { + $this->search = $value; + + return $this; + } + + public function toArray(): array + { + $args = []; + $args += $this->termIds->toArray(); + $values = [ + '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 $this->mergeQueryArguments($args); + } +} diff --git a/src/WordPress/UserQueryArg.php b/src/WordPress/UserQueryArg.php new file mode 100644 index 0000000..ea8df6e --- /dev/null +++ b/src/WordPress/UserQueryArg.php @@ -0,0 +1,839 @@ + + * @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 +{ + use SupportsQueryArguments; + + 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|array|null $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->initialiseQueryArguments($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 = 'ASC' === \strtoupper($queryArray['order']) + ? 'ASC' + : 'DESC'; + } + + 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 = $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|array $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 = 'ASC' === \strtoupper($direction) + ? 'ASC' + : 'DESC'; + + 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 $this->mergeQueryArguments($args); + } +} diff --git a/tests/WordPress/QueryArgumentBuildersTest.php b/tests/WordPress/QueryArgumentBuildersTest.php new file mode 100644 index 0000000..2f35499 --- /dev/null +++ b/tests/WordPress/QueryArgumentBuildersTest.php @@ -0,0 +1,795 @@ + + * @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); + +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 +{ + 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 testIncludeExcludeQueryArgKeepsListsDisjoint(): void + { + $query = IncludeExcludeQueryArg::make( + ['only' => [1, 2, 2], 'except' => [3, 4]], + ['includeKey' => 'only', 'excludeKey' => 'except'] + ); + + $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()); + + 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 + { + $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 testIncludeExcludeIsQueryArgPreservesScalarAndArrayValuesInMultiValueMode(): void + { + $query = new IncludeExcludeIsQueryArg([], false, true); + + self::assertSame(['is' => 'any'], $query->is('any')->toArray()); + self::assertSame(['is' => ['post', 'page']], $query->is(['post', 'page'])->toArray()); + } + + 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 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() + ->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 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() + ); + + $fieldOnlyClause = [ + 'field' => 'term_taxonomy_id', + 'operator' => 'EXISTS', + ]; + + self::assertSame( + [$fieldOnlyClause, $additionalClause], + TaxQueryArg::make($fieldOnlyClause) + ->where('post_tag', 'news', 'slug') + ->toArray() + ); + } + + 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'], + 'comment_count' => ['value' => 2, 'compare' => '>='], + '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') + ->postStatusesIn(['draft', '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('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']); + 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 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); + + PostQueryArg::make(['order' => ['title' => 'DESC']]); + } + + 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 + { + $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], + 'author__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' => 'threaded', + '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([6], $args['author__in']); + self::assertSame(10, $args['post_id']); + self::assertSame('term', $args['search']); + 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', 'user__in' => [2, 3]], CommentQueryArg::make([ + 'type' => ['review', 'rating'], + 'status' => ['approve', 'hold'], + 'hierarchical' => 'threaded', + 'user__in' => [2, 3], + ])->toArray()); + } + + public function testNetworkQueryArgMapsIdsAndOptions(): void + { + $query = NetworkQueryArg::make([ + 'network__in' => [1], + 'network__not_in' => [2], + 'domain' => 'example.com', + 'path' => '/', + 'number' => 10, + 'offset' => 2, + 'orderby' => 'id', + 'order' => 'DESC', + 'fields' => 'ids', + 'count' => false, + 'update_network_cache' => true, + ])->includeNetworks(4)->excludeNetworks(1); + + $args = $query->toArray(); + self::assertSame([4], $args['network__in']); + self::assertSame([2, 1], $args['network__not_in']); + self::assertArrayNotHasKey('network_id', $args); + 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::assertSame(0, $args['public']); + self::assertSame(1, $args['archived']); + 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( + [ + '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 + { + $query = TermQueryArg::make([ + 'include' => [1, 2], + 'exclude' => [3], + 'parent' => 6, + 'taxonomy' => ['category', 'post_tag'], + 'name' => ['News', 'Updates'], + 'slug' => ['news', 'updates'], + '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' => 'all', + '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('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], '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([ + '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 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], 'post_date', 'IN') + ->month([1, 6]) + ->dayOfYear([10, 20]) + ->dayOfWeek(2) + ->dayOfWeekIso([1, 5]) + ->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']); + } + + 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']); + 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( + ['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()); + + $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); + } +} 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 @@ +