Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ jobs:
run: composer install --no-interaction --prefer-dist --optimize-autoloader

- name: Tests
run: ./vendor/bin/pest
run: ./vendor/bin/pest --exclude-group=docs

- name: Documentation examples
run: ./vendor/bin/pest --group=docs

- name: Pint
run: ./vendor/bin/pint
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Changelog

All notable changes to this package are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package follows [Semantic Versioning](https://semver.org/).

Earlier releases (1.0 to 1.0.3) are listed on the [releases page](https://github.com/Pollora/Query/releases).

## [2.0.0] - Unreleased

A major release: Query now covers every query class of WordPress, and several arguments that were wrong or silently ignored are fixed. A few fixes change the results of a query that used to run, so read **[Upgrading](docs/upgrading.md)** before updating.

### Added

- **Query classes**: [`TermQuery`](docs/term-query.md) (`WP_Term_Query`), [`UserQuery`](docs/user-query.md) (`WP_User_Query`), [`CommentQuery`](docs/comment-query.md) (`WP_Comment_Query`), [`SiteQuery`](docs/site-network-query.md) (`WP_Site_Query`) and [`NetworkQuery`](docs/site-network-query.md) (`WP_Network_Query`). Every documented argument is covered.
- **Terminal methods**, on every class: `first()`, `ids()`, `count()` and `exists()`, which run on a copy of the builder and leave it untouched. `TermQuery` adds `names()` and `slugs()`, `UserQuery` adds `pluck()`. See [Getting results](docs/results.md).
- **`PostQuery` arguments**: `pageId()`, `pagename()`, `title()`, `menuOrder()`; the date fields `year()`, `month()`, `day()`, `hour()`, `minute()`, `second()`, `week()` and `yearMonth()`; the top-level meta arguments `metaKey()`, `metaValue()`, `metaCompare()`, `metaType()`, `metaCompareKey()` and `metaTypeKey()`; `orderByMeta()`; `exactSearch()`, `searchSentence()` and `searchColumns()`; `noFoundRows()`, `suppressFilters()` and `lazyLoadTermMeta()`.
- **Meta clauses**: `regexp()`, `notRegexp()`, `rlike()`, `keyCompare()` and `keyType()`.
- **Tax clauses**: `containsAll()` (`operator => 'AND'`).
- **Date clauses**: `registered()`, and `inclusive()` on a single clause.
- `Query::tax()`, next to `Query::meta()` and `Query::date()`.
- **Documentation** split into pages under [`docs/`](docs/concepts.md), with a page per query class. Its examples are run by the test suite, and CI fails when one no longer matches the code.

### Changed

- **`where()` followed by `orWhere()` is grouped like Eloquent.** `where(a)->where(b)->orWhere(c)` is `(a AND b) OR c`; it used to turn the whole group into `a OR b OR c`.
- **`DateQuery::inclusive()` works.** It was written where WordPress does not read it, so it had no effect; it is now written on each date clause.
- **Dates use a 24-hour clock.** A date string was converted with the 12-hour format (`'2022-01-01'` gave `hour => 12`, `'… 15:10:00'` gave `hour => 3`). The parts of a converted date are now integers.
- **Invalid values throw a `QueryException`** instead of being silently ignored: `fields()`, `userPermission()` and the direction of `orderBy()`.
- **`date()` without a column no longer sends one**, so WordPress uses the default of the queried table (`post_date`, `user_registered`, `comment_date`, `registered`).
- **Defaults chosen so that nothing is silently left out**: `TermQuery` returns empty terms (`hide_empty => false`) and `SiteQuery` has no cap of 100 sites (`number => 0`). `PostQuery` keeps `post_type => 'any'`. `CommentQuery` keeps WordPress' own defaults on purpose (no spam, no trash). See [Concepts](docs/concepts.md#defaults).

### Fixed

- `DateQueryBuilder::within()` always threw a `TypeError`.
- A debug `var_export` printed to the output when a date string was converted.
- A numeric (timestamp) string given to `before()` or `after()` threw a `TypeError`.
- `categoryAnd()` sent `category_and`, which WordPress ignores; it now sends `category__and`.
- `postType()` did not accept an array.
- `orderBy()` with an array, and `inRandomOrder()` followed by `orderBy()` or `latest()`, threw a `TypeError`.
- `ofType()` was ignored, and the type of a list was detected from its first value only (`between(1, 50)` was `BINARY`, `in([])` raised a warning). One type now fits every value.
- Calling `metaQuery()`, `taxQuery()` or `dateQuery()` twice repeated the clauses of the first call.

### Removed

- The public `initQuery()`, `query()` and `genericQuery()` methods of the query classes. They came from an internal trait by accident and were not documented.

[2.0.0]: https://github.com/Pollora/Query/compare/1.0.3...HEAD
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ The sub-queries are shared: [`TaxQuery`](docs/tax-query.md) (posts), [`MetaQuery
- [Concepts](docs/concepts.md): how the builders work, defaults, errors, `where()` / `orWhere()`, raw arguments. **Start here.**
- [Getting results](docs/results.md): `get()`, `first()`, `ids()`, `count()`, `exists()` and friends.
- [Tax queries](docs/tax-query.md), [meta queries](docs/meta-query.md), [date queries](docs/date-query.md).
- [Upgrading](docs/upgrading.md): what changed since 1.0.
- [Upgrading](docs/upgrading.md): what changed since 1.0, and what to check. The full list is in the [changelog](CHANGELOG.md).

## Why use it

Expand Down Expand Up @@ -234,6 +234,15 @@ The same query as a `WP_Query` array is much harder to read and to get right.
vendor/bin/pest
```

The examples in this documentation are tests too: each one that shows the arguments a query builds, or the exception it throws, is run and compared with the output written in the page. Links between the pages and the list of documented methods are checked as well.

```bash
vendor/bin/pest --group=docs # only the documentation
UPDATE_DOCS=1 vendor/bin/pest --group=docs # rewrite the outputs after a change of behaviour, then review the diff
```

When you add a public method, mention it in the page of its class (the test lists the ones that are missing).

## Contributing

Contributions are welcome: see the [contributing guide](https://github.com/Pollora/.github/blob/main/CONTRIBUTING.md). Report security issues privately, as described in the [security policy](https://github.com/Pollora/.github/blob/main/SECURITY.md).
Expand Down
122 changes: 122 additions & 0 deletions tests/Docs/DocumentationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

use Pollora\Query\QueryException;

/*
|--------------------------------------------------------------------------
| The documentation is tested
|--------------------------------------------------------------------------
|
| Every example of README.md and docs/*.md that shows the arguments a query builds (or the exception it
| throws) is run, and its output compared with the one written in the page.
|
| When a behaviour changes on purpose, refresh the pages with:
|
| UPDATE_DOCS=1 vendor/bin/pest --group=docs
|
| and review the diff of the Markdown files.
*/

dataset('documentation examples', function () {
foreach (docs_files() as $file) {
foreach (docs_examples($file) as $example) {
yield basename($file).':'.$example['line'] => [$example];
}
}
});

it('documents the arguments and the exceptions that the code produces', function (array $example) {
if ($example['kind'] === 'exception') {
try {
docs_run($example['code']);
$actual = 'no exception was thrown';
} catch (QueryException $exception) {
$actual = 'QueryException: '.$exception->getMessage();
}
} else {
$actual = docs_export(docs_run($example['code']));
}

if (getenv('UPDATE_DOCS') && $actual !== $example['expected']) {
docs_update($example, $actual);

return;
}

expect($actual)->toBe($example['expected']);
})->with('documentation examples')->group('docs');

it('has examples to run', function () {
expect(iterator_count((function () {
foreach (docs_files() as $file) {
yield from docs_examples($file);
}
})()))->toBeGreaterThan(50);
})->group('docs');

it('documents every public method of the query classes', function () {
$text = implode("\n", array_map('file_get_contents', docs_files()));

$classes = [
'PostQuery', 'TermQuery', 'UserQuery', 'CommentQuery', 'SiteQuery', 'NetworkQuery', 'Query',
'MetaQuery', 'TaxQuery', 'DateQuery',
'QueryBuilder\\MetaQueryBuilder', 'QueryBuilder\\TaxQueryBuilder', 'QueryBuilder\\DateQueryBuilder',
];
$internal = ['getRawArgs', 'extractArgumentFromProperties'];

$missing = [];

foreach ($classes as $class) {
foreach ((new ReflectionClass('Pollora\\Query\\'.$class))->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
$name = $method->getName();

if ($method->isConstructor() || in_array($name, $internal, true)) {
continue;
}

if (! str_contains($text, $name.'(')) {
$missing[] = $class.'::'.$name;
}
}
}

expect($missing)->toBe([]);
})->group('docs');

it('only links to files and sections that exist', function () {
$anchors = function (string $file): array {
$found = [];

foreach (explode("\n", file_get_contents($file)) as $line) {
if (preg_match('/^#{1,6}\s+(.*)$/', $line, $heading)) {
$title = strtolower(str_replace(['`', '*'], '', trim($heading[1])));
$found[] = str_replace(' ', '-', preg_replace('/[^\w\s\-]/u', '', $title));
}
}

return $found;
};

$broken = [];

foreach (docs_files() as $file) {
preg_match_all('/\]\(([^)\s]+)\)/', file_get_contents($file), $links);

foreach ($links[1] as $link) {
if (preg_match('~^(https?:|mailto:)~', $link)) {
continue;
}

[$path, $anchor] = array_pad(explode('#', $link, 2), 2, null);
$target = $path === '' ? $file : realpath(dirname($file).'/'.$path);

if ($target === false || ! file_exists($target)) {
$broken[] = basename($file).' -> '.$link.' (no such file)';
} elseif ($anchor !== null && str_ends_with($target, '.md') && ! in_array($anchor, $anchors($target), true)) {
$broken[] = basename($file).' -> '.$link.' (no such section)';
}
}
}

expect($broken)->toBe([]);
})->group('docs');
1 change: 1 addition & 0 deletions tests/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

include __DIR__.'/helpers'.DIRECTORY_SEPARATOR.'wp.php';
include __DIR__.'/helpers'.DIRECTORY_SEPARATOR.'docs.php';

// uses(Tests\TestCase::class)->in('Feature');

Expand Down
117 changes: 117 additions & 0 deletions tests/helpers/docs.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

/**
* Support for the tests that run the examples of the documentation (tests/Docs).
*
* An example is a ```php block, followed either by
* - a "`WP_Query` receives:" / "`getArguments()` returns:" line and a ```php block holding the expected arguments,
* - or, inside the block, a last "// QueryException: message" line.
*/

/**
* @return array<string> Absolute paths of the Markdown files that are checked.
*/
function docs_files(): array
{
$root = dirname(__DIR__, 2);

return [$root.'/README.md', $root.'/CHANGELOG.md', ...glob($root.'/docs/*.md')];
}

/**
* Prints a value as short-array PHP, the way the documentation shows the arguments.
*/
function docs_export(mixed $value, int $level = 0): string
{
if (is_array($value)) {
if ($value === []) {
return '[]';
}

$pad = str_repeat(' ', $level + 1);
$next = 0;
$lines = [];

foreach ($value as $key => $item) {
$label = '';

if (is_int($key) && $key === $next) {
$next++;
} else {
$label = (is_int($key) ? $key : "'".addcslashes($key, "'\\")."'").' => ';
$next = is_int($key) ? $key + 1 : $next;
}

$lines[] = $pad.$label.docs_export($item, $level + 1).',';
}

return "[\n".implode("\n", $lines)."\n".str_repeat(' ', $level).']';
}

return match (true) {
is_string($value) => "'".addcslashes($value, "'\\")."'",
is_bool($value) => $value ? 'true' : 'false',
$value === null => 'null',
default => var_export($value, true),
};
}

/**
* @return array<int, array{file: string, line: int, kind: string, code: string, expected: string, block: string}>
*/
function docs_examples(string $file): array
{
$markdown = file_get_contents($file);
$examples = [];

$found = [
'arguments' => '~(?<block>```php\n(?<code>(?:(?!```).)*?)\n```\n\n`[^`\n]+` (?:receives|returns):\n\n```php\n(?<expected>(?:(?!```).)*?)\n```)~s',
'exception' => '~(?<block>```php\n(?<code>(?:(?!```).)*?)\n// (?<expected>\w*Exception: [^\n]*)\n```)~s',
];

foreach ($found as $kind => $pattern) {
preg_match_all($pattern, $markdown, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);

foreach ($matches as $match) {
$examples[] = [
'file' => $file,
'line' => substr_count(substr($markdown, 0, $match['block'][1]), "\n") + 1,
'kind' => $kind,
'code' => $match['code'][0],
'expected' => $match['expected'][0],
'block' => $match['block'][0],
];
}
}

usort($examples, fn (array $a, array $b): int => [$a['file'], $a['line']] <=> [$b['file'], $b['line']]);

return $examples;
}

/**
* Runs the code of an example. Examples are one expression: a trailing `->get()` is read as `->getArguments()`
* so that no WordPress query runs.
*/
function docs_run(string $code): mixed
{
$expression = rtrim(trim($code), ';');
$expression = preg_replace('/->get\(\)$/', '->getArguments()', $expression);

$imports = 'use Pollora\Query\{PostQuery, TermQuery, UserQuery, CommentQuery, SiteQuery, NetworkQuery, Query, MetaQuery, TaxQuery, DateQuery, QueryException};';

return eval($imports.' return '.$expression.';');
}

/**
* Rewrites the expected output of an example in its file (UPDATE_DOCS=1).
*/
function docs_update(array $example, string $actual): void
{
$contents = file_get_contents($example['file']);
$rewritten = $example['kind'] === 'exception'
? str_replace($example['block'], preg_replace('~// \w*Exception: [^\n]*\n```$~', '// '.$actual."\n```", $example['block']), $contents)
: str_replace($example['block'], substr($example['block'], 0, -strlen($example['expected']) - 4).$actual."\n```", $contents);

file_put_contents($example['file'], $rewritten);
}
Loading