diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c1bdcf..b42bae3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c598ce8 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f6449a0..c069af1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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). diff --git a/tests/Docs/DocumentationTest.php b/tests/Docs/DocumentationTest.php new file mode 100644 index 0000000..d6dbfa6 --- /dev/null +++ b/tests/Docs/DocumentationTest.php @@ -0,0 +1,122 @@ + [$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'); diff --git a/tests/Pest.php b/tests/Pest.php index d3a7f2d..1118254 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,6 +12,7 @@ */ include __DIR__.'/helpers'.DIRECTORY_SEPARATOR.'wp.php'; +include __DIR__.'/helpers'.DIRECTORY_SEPARATOR.'docs.php'; // uses(Tests\TestCase::class)->in('Feature'); diff --git a/tests/helpers/docs.php b/tests/helpers/docs.php new file mode 100644 index 0000000..8ca17b7 --- /dev/null +++ b/tests/helpers/docs.php @@ -0,0 +1,117 @@ + 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 + */ +function docs_examples(string $file): array +{ + $markdown = file_get_contents($file); + $examples = []; + + $found = [ + 'arguments' => '~(?```php\n(?(?:(?!```).)*?)\n```\n\n`[^`\n]+` (?:receives|returns):\n\n```php\n(?(?:(?!```).)*?)\n```)~s', + 'exception' => '~(?```php\n(?(?:(?!```).)*?)\n// (?\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); +}