diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc415a3..8a932b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, v2] pull_request: - branches: [main] + branches: [main, v2] jobs: syntax: @@ -22,4 +22,30 @@ jobs: coverage: none - name: Check PHP syntax - run: find src config -name '*.php' -print0 | xargs -0 -n1 php -l + run: find src config database tests -name '*.php' -print0 | xargs -0 -n1 php -l + + tests: + name: Tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + + strategy: + fail-fast: false + matrix: + php: ['8.4', '8.5'] + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_sqlite, sqlite3 + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: vendor/bin/phpunit --no-coverage diff --git a/.gitignore b/.gitignore index f8f3f0f..9c51aa6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /.phpunit.cache/ .DS_Store *.lock +/tests/__fixtures__/dev-null/* +!/tests/__fixtures__/dev-null/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index d840d86..94ac71d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,99 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.0.0] - 2026-08-06 + +Invalidation is now derived from what pages actually read, instead of from rules +describing what they might read. There is no configuration to write. + +The 1.x design kept a hand-maintained map of block types and field handles that +had to mirror the templates. It drifted silently — a URL that no longer resolved +invalidated nothing, and a relation rendered outside the `pagebuilder` field was +invisible to the block index by construction. The template already knows what it +renders; v2 observes it rather than restating it. + +### Added + +- A `url -> tags` dependency graph, recorded while a page renders and written when + it enters the static cache. Works identically on the `half` and `full` + strategies. +- Read recording for entries, terms, globals and forms, hooked at the query + builder, repository and augmentation level rather than in templates. +- Item tags versus list tags: a query pinned to ids records only those items, while + any other query also records its collection or taxonomy, so an entry created + later still invalidates listings that have never seen it. +- Storage drivers: `sqlite` (default, owns its own connection and schema, needs no + database configured), `database` (opt-in, with a migration), and `null` (records + nothing, so every save clears everything). +- A safety net: any cached URL absent from the graph is treated as depending on + everything. Covers pages cached before install, a lost graph, and recorder bugs, + so the failure mode is over-invalidation that heals after one render rather than + a page that stays stale with no symptom. +- `cache-invalidation:why`, `:affected`, `:stats` and `:doctor`. `affected` answers + "what clears if I save this?" before saving — the question the 1.x design could + not be asked. `doctor` exits non-zero when invalidation cannot work, so a broken + environment fails a deploy. +- An `X-Cache-Tags` header behind `CACHE_INVALIDATION_DEBUG`. +- A public API for data this addon cannot observe — an HTTP call, a custom Eloquent + model, a file. `CacheTags::add()` (or `@cachetags(...)`) declares the dependency + where it is rendered, `CacheTags::invalidate()` clears it wherever that data + changes, and `cache-invalidation:clear` does the same from the command line. + Unlike a content save this does not sweep up untracked URLs, so a targeted call + stays targeted right after a deploy. +- A test suite: 74 tests over Testbench, asserting recording through real queries + against real content and invalidation through real save events. Mutation-checked, + and verified to fail against the bugs it covers. + ### Changed -- Relicensed from proprietary to the MIT License. Copyright remains with Rox - Digital and the notice must be retained in redistributions, while the licence - disclaims all warranty and liability. `composer.json` now declares `MIT` and a - `LICENSE` file has been added. +- The whole cache is no longer flushed for globals, navigations, form blueprints or + collection trees. URLs are invalidated individually, so `nocache` regions and the + graph survive. +- Navigations are tagged where they render, so one used on a handful of pages clears + only those. A nav in the shared layout still reaches every page, but as a + consequence of where it is used rather than a special case. +- Two kinds of read are excluded from recording, because both made almost every save + clear almost everything. Statamic's URL resolution, which for a structured + collection validates the whole collection tree; and navigation menus, where the + nav is recorded as `nav:{handle}` rather than as an `entry:` tag per menu item. + + The navigation exclusion is a deliberate trade-off: renaming a page leaves its + menu label stale on already-cached pages until they clear for another reason, and + saving the navigation clears them. A page save clears where that page is rendered + as content. + + Measured on a 215-page site: 31.9 tags per URL where recording everything gave + 68.6, and a page save clears a median of 1 URL where before it cleared all of + them. 19 of 25 sampled pages clear 0–5 URLs. Saving a navigation clears all 215, + because all 215 render it. +- Globals invalidate only where they are read. A set rendered in the layout still + reaches every page; one rendered by a single block reaches that block's pages. +- Form blueprint saves clear the pages rendering that form instead of the entire + site. +- The addon now claims Statamic's invalidator when the configured class is one of + its own, not only when the config is null. Sites pin it by name, and a 1.x pin + would otherwise fatal on a class that no longer exists. + +### Fixed + +- `Invalidator::refresh()` is honoured. `DefaultInvalidator` flips its `$refreshing` + flag before delegating to `invalidate()`, which 1.x overrode without checking, so + `statamic.static_caching.background_recache` hard-purged instead of refreshing. +- Relations rendered outside the `pagebuilder` field — an entry's `author`, + `category`, a hero fieldset, entry links inside Bard — now invalidate. The 1.x + block index only read `$entry->get('pagebuilder')`, and the + `['collection' => …, 'field' => …]` rule existed to patch that hole by walking a + whole collection on every save. + +### Removed + +- Every rule key: `pagebuilder_collections`, `collection_entry_rules`, + `collection_urls`, `globals_flush_all`, `navs_flush_all`, + `collection_trees_flush_all`, `forms_flush_all`, `global_target_blocks`, + `global_urls`, `taxonomy_target_blocks`, `taxonomy_urls`. + `cache-invalidation:doctor` reports any still present in a published config. +- The block index and its supporting classes, along with `customEntryUrls()`. The + relations that hook existed for are now observed. ## [1.2.0] - 2026-07-29 diff --git a/README.md b/README.md index 9f5892c..3467589 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,26 @@ # Cache Invalidation -Targeted static and half-measure cache invalidation for Statamic sites built with a pagebuilder. +Targeted static cache invalidation for Statamic, with no configuration. -Instead of flushing the entire cache on every save, this addon builds a block-index of your pages and invalidates only the URLs that reference the changed content — by entry, global, navigation, or taxonomy term. +Pages record what they read while they render. Saving content clears exactly the +cached pages that read it — no block index, no rule lists, nothing to maintain +when you add a pagebuilder block. [![Latest Release](https://img.shields.io/github/v/release/roxdigital/cache-invalidation)](https://github.com/roxdigital/cache-invalidation/releases) [![PHP](https://img.shields.io/badge/PHP-8.4%2B-blue)](https://www.php.net) [![Statamic](https://img.shields.io/badge/Statamic-6.x-FF269E)](https://statamic.com) [![License: MIT](https://img.shields.io/badge/License-MIT-green)](LICENSE) -## Requirements +Requires PHP `^8.4`, Laravel `^12.0 || ^13.0`, Statamic `^6.0`. Works with both the +`half` and `full` static caching strategies. -| Dependency | Version | -|------------|---------| -| PHP | `^8.4` | -| Laravel | `^12.0 \|\| ^13.0` | -| Statamic | `^6.0` | - ---- +Recording extends Statamic's Stache repositories and query builders, so this assumes +the standard flat-file content driver. A site running `statamic/eloquent-driver` +replaces those, and has not been tested. ## Installation -### 1. Add the repository - -Add the GitHub VCS source to your project's `composer.json`: +Add the VCS source to your project's `composer.json`: ```json { @@ -36,174 +33,281 @@ Add the GitHub VCS source to your project's `composer.json`: } ``` -### 2. Require and publish - ```bash composer require roxdigital/cache-invalidation -php artisan vendor:publish --tag=cache-invalidation-config ``` -### 3. Configure Statamic +That's it. Nothing to publish, no migration, no configuration. The addon registers +itself as Statamic's invalidator and creates its own storage on first use. Verify +with: -Nothing to do in most cases: the addon registers itself as the invalidator when `statamic.static_caching.invalidation.class` is unset, which is Statamic's default. Set it explicitly only to point at your own subclass: +```bash +php artisan cache-invalidation:doctor +``` + +## How it works + +While a page renders, the addon watches what it reads and stores a set of tags +against its URL when the page enters the static cache: -```php -'invalidation' => [ - 'class' => \App\StaticCaching\SiteInvalidator::class, - 'rules' => [], -], +``` +https://site.test/over-ons + entry:9f2c… an entry it rendered + collection:articles a query it ran against a collection + term:departments::sales + global:footer + form:contact ``` -> **Note:** This addon replaces Statamic's rule-based invalidator. Keep `rules` as an empty array. +On save, the changed item is turned into the same tags and every URL carrying one +of them is cleared. The template is the only thing that decides what a page reads, +so there is no second copy of that knowledge to drift out of date. + +**Item tags vs list tags.** A query pinned to ids — `Entry::find($id)`, an +`entries` field being augmented — records only those items, so a reusable block on +three pages clears only those three. Any other query also records a list tag for +its scope, because an entry created tomorrow has an id that is in no tag set yet; +`collection:articles` is what clears a "latest three articles" carousel. + +**Nesting is free.** A reusable block's reads are the embedding page's reads, at any +depth. If page C embeds a block that pulls in a global and another entry, page C +carries all three dependencies and clears when any of them is saved. + +### How reads are observed + +Every piece of content in Statamic is fetched through a repository or a query +builder resolved from the container. The addon replaces those with subclasses, so +each read passes through it on the way to your template — nothing in your code +changes, and there is no scanning or parsing. + +| Content | Where it is recorded | +|---|---| +| Entries | `EntryQueryBuilder::getFilteredKeys()` and `getItems()` | +| Terms | `TermQueryBuilder`, via a replaced `TermRepository::query()` | +| Globals | `Variables::newAugmentedInstance()`, on first value read | +| Forms | `FormRepository::find()` | +| Navigations | `NavigationRepository`, `NavTreeRepository`, and the `nav` tag | + +Entries hook `getFilteredKeys()` rather than `get()` because `count()` and +`pluck()` bypass `get()` entirely; `getItems()` then adds item tags for the entries +that survived `limit` and `offset`. A request-scoped recorder collects the tags, and +the cacher writes them against the URL as the page is stored. + +Because the hooks sit at that level, how a template asks does not matter — a PHP +query in a `@php` block or view model, an Antlers tag (``, +``, ``, ``), an augmented field (`$block->entry`), or a +global off the cascade (`$footer->phone`) all pass through. What does not is content +fetched outside Statamic entirely; see [Data from outside Statamic](#data-from-outside-statamic). + +This only runs on a cache miss — during a render you are already paying for. A +cached hit never reaches it. + +### What a save clears + +| Saved | Cleared | +|---|---| +| Entry | Its own URL and descendants, pages carrying `entry:{id}`, pages carrying its collection's list tag | +| Term | Pages carrying `term:{taxonomy}::{slug}` or `taxonomy:{handle}` | +| Global set | Pages that read it — every page, if it is read in your layout | +| Form or forms blueprint | Pages rendering that form | +| Collection tree | Pages carrying that collection's list tag, plus the URLs Statamic reports as moved | +| Navigation | Pages that render it — every page, if it is in your layout | + +The cache is never flushed wholesale — URLs are invalidated individually, so +`nocache` regions survive and pages come back without a global re-render. Nothing +is special-cased either: a navigation or global that reaches every page does so +because it is recorded on every page, not because of a rule. + +**Rendered output only.** Two kinds of read are deliberately excluded, because +recording them makes almost every save clear almost everything: + +- **URL resolution.** Statamic reads content to work out which entry a URL belongs + to, and for a structured collection that includes validating the whole collection + tree. The page does not display that. +- **Navigation menus.** A nav is recorded as `nav:{handle}`, not as an `entry:` tag + per menu item. Otherwise a menu in your layout would make every page depend on + every page in it, and renaming one would clear the site. + +The second is a trade-off worth stating plainly: rename a page and its menu label +stays stale on already-cached pages until they clear for another reason. Saving the +navigation clears them. A page save clears where that page is rendered *as content*. + +**Safety net.** A URL that is cached but absent from the graph is treated as +depending on everything and cleared by the next save. That covers pages cached +before install and any recorder bug, so the failure mode is over-invalidation that +heals after one render rather than a page that stays stale unnoticed. Right after a +deploy that is every page; it drops to zero as pages render. + +## Commands ---- +```bash +# What does this page depend on? +php artisan cache-invalidation:why https://site.test/over-ons -## Local development +# What would clear if I saved this? Takes an entry id, term id, +# global handle, form handle, or a raw tag. +php artisan cache-invalidation:affected 9f2c1b4e-… +php artisan cache-invalidation:affected collection:articles -Use a Composer path repository to work against a local clone: +# Graph size and coverage of the static cache. +php artisan cache-invalidation:stats -```json -{ - "repositories": [ - { - "type": "path", - "url": "addons/roxdigital/cache-invalidation" - } - ] -} -``` +# Clear pages carrying a tag. The counterpart to `affected`: +# preview with that, clear with this. +php artisan cache-invalidation:clear api:reviews -```bash -composer require roxdigital/cache-invalidation:@dev +# Deploy check — exits non-zero when invalidation cannot work. +php artisan cache-invalidation:doctor ``` ---- +`CACHE_INVALIDATION_DEBUG=true` adds an `X-Cache-Tags` header as pages are cached, +so you can read a page's dependencies in devtools. ## Configuration -`config/cache_invalidation.php` documents every key inline. In short: +Nothing needs setting. Publish it only to change a default: -| Key | Effect | -|-----|--------| -| `pagebuilder_collections` | Collections with a pagebuilder field. Only these are indexed. | -| `globals_flush_all` | Global handles that flush the whole cache on save. | -| `navs_flush_all` | Nav handles that flush the whole cache — structure edits and reorders. | -| `collection_trees_flush_all` | Collections whose tree order drives shared output (a nav or breadcrumbs built from the page tree). Empty by default. | -| `forms_flush_all` | `true` by default: a form blueprint save flushes the whole cache, since the changed fields render on every page embedding the form. | -| `global_target_blocks` | `'global' => ['block_type', ...]` — invalidate pages containing those blocks. | -| `global_urls` | `'global' => ['/url', ...]` | -| `collection_entry_rules` | Per-collection entry rules — see below. | -| `collection_urls` | `'collection' => ['/url', ...]` | -| `taxonomy_target_blocks` | `'taxonomy' => ['block_type', ...]` | -| `taxonomy_urls` | `'taxonomy' => ['/url', ...]` | - -### Entry rules +```bash +php artisan vendor:publish --tag=cache-invalidation-config +``` -Map a collection to `'all'` — invalidate every cached URL on any save — or to a list of rules: +| Key | Default | Effect | +|-----|---------|--------| +| `driver` | `sqlite` | `sqlite`, `database` or `null` | +| `sqlite_path` | `storage/statamic/cache-invalidation.sqlite` | | +| `database_connection` | `null` | Connection for the `database` driver | +| `debug` | `false` | `X-Cache-Tags` header | -```php -'collection_entry_rules' => [ +`sqlite` owns its own connection and creates its file and schema on first write, so +it needs no `DB_CONNECTION`. `database` keeps the graph in your app database +instead and needs `php artisan migrate`. `null` records nothing, which clears the +whole cache on every save — a conservative fallback, not a production driver. - // Pages containing this block type. - 'articles' => [['block' => 'article_carousel']], +> The graph must be visible to every process that renders or invalidates pages. On +> a single server that is automatic; if web and queue run on separate filesystems, +> use `database`. - // Pages where that block's field references the saved entry. - 'reusable_blocks' => [['block' => 'reusable_block', 'field' => 'entry']], +## Data from outside Statamic - // The saved entry's parent page, for a structured collection whose parent - // template lists its children. Opt-in per collection: in a collection - // mounted at the site root, a top-level entry's parent is the root itself. - 'vacancies' => [['parent' => true]], +Entries, terms, globals and forms are observed automatically. Data that reaches a +template from somewhere else — an HTTP call, a custom Eloquent model, a file — is +not: nothing sees the read, and nothing knows when it changes. Declare both halves. - // Entries in another collection whose field references the saved entry — - // the inverse of a block rule, for relations rendered by a template (an - // article showing its author). Walks that collection on every save. - 'employees' => [['collection' => 'articles', 'field' => 'author']], +Where it is rendered: -], +```blade +@cachetags('api:reviews') ``` -The two `block` rules resolve through the block index, so they only reach what a pagebuilder block renders. `parent` and `collection` rules do not use the index, and are inert unless configured. +Or from PHP, in a ViewModel or component: ---- - -## How it works - -On save, the addon resolves which cached URLs to clear: +```php +use RoxDigital\CacheInvalidation\Facades\CacheTags; -| Trigger | Behaviour | -|---------|-----------| -| Global in `globals_flush_all` | Full flush + clear block index | -| Nav in `navs_flush_all` | Full flush + clear block index | -| Form blueprint saved, when `forms_flush_all` | Full flush + clear block index | -| Collection tree saved | Clear block index; full flush if the collection is in `collection_trees_flush_all` | -| Global or taxonomy term | Pages matching `*_target_blocks`, plus `*_urls` | -| Entry in a collection mapped to `'all'` | Every currently-cached URL | -| Entry matching `collection_entry_rules` | Block-index matches, parent page, referencing entries | -| Entry — always | Its own URL, `collection_urls`, and `customEntryUrls()` | +CacheTags::add('api:reviews'); +``` -A **full flush** goes through `StaticCache::flush()`, the same path as `php artisan statamic:static:clear`: cached pages, `nocache` regions and cached error pages such as a shared 404. Flushing the cacher alone would leave nocache regions behind to be restored into freshly rendered pages. +And wherever that data changes — the job that refetched it, say: -### Block index +```php +CacheTags::invalidate('api:reviews'); // returns the number of URLs cleared +CacheTags::urlsFor('api:reviews'); // preview, without clearing +``` -The addon maintains a `url → blocks[]` index in your Laravel cache. Each block is stored as a slim record containing only its `type` and the fields your rules reference — rich text, images and other large values are discarded at build time. +Tags are arbitrary strings; namespace them (`api:reviews`, not `reviews`) so they +cannot collide with a built-in. `CacheTags::invalidate()` also works with built-in +tags, so `CacheTags::invalidate('collection:articles')` clears every page listing +articles. -The index is built on first access and stored forever. It is cleared when: +Unlike a content save, this does not sweep up cached URLs that are missing from the +graph. That safety net exists so routine editing can never leave a page stale; +applying it here would make a targeted call clear everything right after a deploy. -- The full cache is flushed. -- Any collection tree is saved — a move changes the URLs the index is keyed on, and a reorder dispatches no move event at all. -- An entry is saved in a `pagebuilder_collections` collection (page layout may have changed), or in one with a `reusable_block` rule (embedded content may have changed). +## Upgrading from 1.x -Its cache key is fingerprinted with `collection_entry_rules` and `pagebuilder_collections`, so editing either config takes effect immediately instead of matching nothing against an index built under the old rules. +Delete the rule keys from `config/cache_invalidation.php` — all of them are gone. +`cache-invalidation:doctor` lists any still present. A +`statamic.static_caching.invalidation.class` pointing at +`ContentDependencyInvalidator` can stay: the addon recognises its own class names +and upgrades the pin. Your own subclass is still respected, but +`customEntryUrls()` is gone — the relations it existed for are now observed. -### Reusable blocks +Expect one round of broad invalidation after deploying while the graph fills. -Blocks of type `reusable_block` are expanded inline when the index is built, so pages embedding one are invalidated when that entry changes. Circular references are detected and skipped. +## Local development -### Extending +Clone the addon inside a site and point Composer at the clone. Path repositories +symlink by default, so edits in `src/` take effect on the next request. -Most template-rendered relations are covered by the `parent` and `collection` rules above. For anything they cannot express, override `customEntryUrls()` — it is merged for every entry, whether or not its collection has rules: +```bash +git clone git@github.com:roxdigital/cache-invalidation.git addons/roxdigital/cache-invalidation +``` -```php -class SiteInvalidator extends \RoxDigital\CacheInvalidation\ContentDependencyInvalidator +```json { - protected function customEntryUrls(\Statamic\Entries\Entry $entry): \Illuminate\Support\Collection - { - return $entry->collectionHandle() === 'team' ? collect(['/about']) : collect(); - } + "repositories": [ + { "type": "path", "url": "addons/roxdigital/cache-invalidation" } + ] } ``` -Point the config at your subclass; the addon registers its `$rules` binding for whichever class is configured: - -```php -'invalidation' => [ - 'class' => \App\StaticCaching\SiteInvalidator::class, - 'rules' => [], -], +```bash +composer require roxdigital/cache-invalidation:@dev ``` -`urlsFor()`, `urlsForGlobal()`, `urlsForEntry()` and `urlsForTaxonomyTerm()` are `protected` if you need to go further. - ---- - -## Performance - -- **Use a queue driver in production.** Statamic dispatches invalidation jobs to the queue. Without a queue driver, invalidation runs synchronously inside the CP save request. -- **Use Redis (or another fast cache driver).** The block index is stored as a single serialised entry. A fast driver reduces index rebuild time. -- The index is built once per cache miss. Subsequent invalidations reuse the cached index with no database queries. - ---- - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md). +A class in a new subdirectory needs `composer dump-autoload` if the site uses an +optimised autoloader. To test a branch as a consumer would get it, require the +branch alias instead — `2.x-dev` for branch `v2`, not `dev-v2`. + +The addon's own suite runs from its directory with `composer install && composer test`. + +## Deploying + +- **Clear the cache when you deploy code.** Templates, translations and PHP are not + content, so nothing dispatches an event for them — change a Blade file and the + cache keeps serving the old markup. `php artisan statamic:static:clear` belongs in + your deploy script; this addon narrows content invalidation, not code deploys. +- Run `cache-invalidation:doctor` as a deploy step. It exits non-zero when + invalidation cannot work, so a broken environment fails the pipeline instead of + quietly serving stale pages. +- Restart your queue workers (`php artisan queue:restart`). A worker holds an open + handle on the graph, and a deploy that replaces `storage/` leaves it writing to a + file that no longer exists. The safety net turns that into over-invalidation + rather than staleness, but a restart avoids it. +- Expect one broad invalidation after deploying. Every cached URL is untracked + until it has been rendered again, so the first save clears more than usual. + `cache-invalidation:stats` shows the graph filling up. +- On full measure, `statamic:static:warm` fills the graph promptly instead of + lazily. + +## Not covered + +Statamic only dispatches invalidation events for content, and only some of it, so a +few changes clear nothing. None of these are silent in a surprising way — they are +listed so you know where the edges are. + +- **Assets.** Replacing an image clears nothing. `AssetSaved` does reach the + invalidator, but asset reads are not recorded, so there is no tag to match. +- **Blueprints and fieldsets outside forms.** Adding a field to a collection + blueprint can change every page of that collection; Statamic only routes the + `forms` namespace to invalidation. +- **Users.** A user save dispatches nothing. If a template renders a user's name, + rename them and it stays. Authors kept as entries are unaffected. +- **Templates, translations and code.** See Deploying above. + +## Good to know + +- Invalidation is one indexed lookup plus the deletes. Nothing walks content, which + matters with a single queue worker or `QUEUE_CONNECTION=sync`, where it runs inside + the editor's save request. +- A page with more than 2,000 dependencies is treated as depending on everything. +- Globals are not scoped per site, so on a multisite install saving one clears the + pages that read it across every site. ## License -Released under the [MIT License](LICENSE). Copyright © 2026 Rox Digital. +Released under the [MIT License](LICENSE). Copyright © 2026 Rox Digital. Provided +**as is**, without warranty — invalidation decides what your visitors see, so +verify it against your own site before relying on it in production. -Free to use, modify and distribute, including commercially, provided the copyright -notice is kept intact. Provided **as is**, without warranty of any kind — Rox Digital -accepts no liability. Invalidation decides what your visitors see: verify it against -your own site and caching strategy before relying on it in production. +See [CHANGELOG.md](CHANGELOG.md) for release notes. diff --git a/composer.json b/composer.json index ec2fdca..373d031 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "roxdigital/cache-invalidation", - "description": "Configurable Statamic static/half cache invalidation for pagebuilder-driven sites.", + "description": "Zero-config Statamic static cache invalidation, driven by what pages actually read.", "type": "statamic-addon", "license": "MIT", "autoload": { @@ -8,6 +8,11 @@ "RoxDigital\\CacheInvalidation\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "RoxDigital\\CacheInvalidation\\Tests\\": "tests/" + } + }, "extra": { "laravel": { "providers": [ @@ -16,7 +21,7 @@ }, "statamic": { "name": "Cache Invalidation", - "description": "Configurable static/half cache invalidation for pagebuilder-driven sites." + "description": "Zero-config static cache invalidation, driven by what pages actually read." } }, "require": { @@ -24,6 +29,17 @@ "php": "^8.4", "statamic/cms": "^6.0" }, + "require-dev": { + "mockery/mockery": "^1.6.10", + "orchestra/testbench": "^10.8 || ^11.0", + "phpunit/phpunit": "^12.5" + }, + "scripts": { + "test": "phpunit" + }, + "config": { + "sort-packages": true + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/cache_invalidation.php b/config/cache_invalidation.php index edd0466..afdb91e 100644 --- a/config/cache_invalidation.php +++ b/config/cache_invalidation.php @@ -6,172 +6,69 @@ /* |-------------------------------------------------------------------------- - | Pagebuilder collections + | Dependency graph driver |-------------------------------------------------------------------------- | - | Only entries from these collections are scanned when determining which - | cached page URLs to clear. List collections whose entries have a - | pagebuilder replicator field. + | Invalidation is driven by a url -> tags graph recorded while pages render. + | The graph has to share a lifetime with the static cache and be visible to + | every process that writes or clears it. If the cache outlives the graph, + | lookups stop matching and pages go stale; if web and worker processes see + | different copies, invalidation clears nothing. | - */ - - 'pagebuilder_collections' => [ - 'pages', - ], - - /* - |-------------------------------------------------------------------------- - | Globals that flush the entire static cache - |-------------------------------------------------------------------------- - | - | Use this for globals rendered in shared layout or SEO output. - | - */ - - 'globals_flush_all' => [ - 'redirects', - ], - - /* - |-------------------------------------------------------------------------- - | Navigations that flush the entire static cache - |-------------------------------------------------------------------------- - */ - - 'navs_flush_all' => [ - 'navigation', - ], - - /* - |-------------------------------------------------------------------------- - | Collection trees that flush the entire static cache - |-------------------------------------------------------------------------- - | - | Saving a collection tree always clears the block index, because a move - | changes entry URLs and the index is keyed on them. List a collection here - | as well when its tree drives shared output — a nav or breadcrumbs built - | from the page tree, say — since reordering it changes every cached page - | and no block rule can express that. Empty by default. - | - */ - - 'collection_trees_flush_all' => [ - // - ], - - /* - |-------------------------------------------------------------------------- - | Flush the entire static cache when a form blueprint is saved - |-------------------------------------------------------------------------- - | - | A form blueprint change alters the fields rendered by every page that - | embeds that form, and those pages cannot be resolved from the block - | index, so the whole cache is flushed. - | - */ - - 'forms_flush_all' => true, - - /* - |-------------------------------------------------------------------------- - | Globals that target pagebuilder block types - |-------------------------------------------------------------------------- - | - | Format: 'global_handle' => ['block_type', ...] - | - */ - - 'global_target_blocks' => [ - // - ], - - /* - |-------------------------------------------------------------------------- - | Globals that clear explicit URLs - |-------------------------------------------------------------------------- - | - | Format: 'global_handle' => ['/url', ...] - | - */ - - 'global_urls' => [ - // - ], - - /* - |-------------------------------------------------------------------------- - | Collection entry rules - |-------------------------------------------------------------------------- - | - | Rule without field: ['block' => 'block_type'] - | Rule with field: ['block' => 'block_type', 'field' => 'field_handle'] - | Flush all cached URLs for a collection: 'collection' => 'all' + | Supported drivers: | - | The two rules above resolve pages through the block index, so they only - | reach what a pagebuilder block renders. For relations rendered by a - | collection's own template there are two more: + | "sqlite" Default. Needs nothing from the host app: the addon registers + | its own connection and creates the file on first write, so it + | works on sites with no DB_CONNECTION configured. Correct for + | single-server deploys, which is where a file-backed static + | cache works in the first place. | - | Parent page: ['parent' => true] - | Clears the saved entry's parent page. For a structured collection - | whose parent template lists its children. - | Opt-in per collection, not automatic: in a collection mounted at the - | site root a top-level entry's parent is the root itself, so applying - | this everywhere would clear the home page on every save. + | "database" Uses the application's database. Pick this when the app already + | has one and you would rather keep the graph there. Requires + | `php artisan migrate`. | - | Referencing entries: ['collection' => 'handle', 'field' => 'field_handle'] - | Clears the URL of every entry in that collection whose field - | references the saved entry — the inverse of a block rule. Use it when - | the referencing markup is in a template rather than a block, e.g. an - | article detail page rendering its author from the employees - | collection. - | This walks the named collection on each save of the source - | collection, so keep an eye on it for very large collections. + | "null" Records nothing. Every invalidation then falls back to clearing + | any cached URL the graph does not know about, which means the + | whole cache. Useful to rule the addon out while debugging. | */ - 'collection_entry_rules' => [ - 'reusable_blocks' => [ - ['block' => 'reusable_block', 'field' => 'entry'], - ], - ], + 'driver' => env('CACHE_INVALIDATION_DRIVER', 'sqlite'), /* |-------------------------------------------------------------------------- - | Collections that clear explicit URLs + | Sqlite driver path |-------------------------------------------------------------------------- | - | Format: 'collection_handle' => ['/overview', ...] + | Kept next to Statamic's own static cache bookkeeping so the graph and the + | cache share a directory, and a deploy that discards one discards both. | */ - 'collection_urls' => [ - // - ], + 'sqlite_path' => storage_path('statamic/cache-invalidation.sqlite'), /* |-------------------------------------------------------------------------- - | Taxonomies that target pagebuilder block types + | Database driver connection |-------------------------------------------------------------------------- | - | Format: 'taxonomy_handle' => ['block_type', ...] + | Null uses the application's default connection. | */ - 'taxonomy_target_blocks' => [ - // - ], + 'database_connection' => null, /* |-------------------------------------------------------------------------- - | Taxonomies that clear explicit URLs + | Debug |-------------------------------------------------------------------------- | - | Format: 'taxonomy_handle' => ['/overview', ...] + | Adds an X-Cache-Tags header to responses that are about to be cached, so + | you can read a page's recorded dependencies in devtools. Cached hits do + | not carry the header — the render that produced the cache entry does. | */ - 'taxonomy_urls' => [ - // - ], + 'debug' => env('CACHE_INVALIDATION_DEBUG', false), ]; diff --git a/database/migrations/2026_08_06_000000_create_static_cache_dependencies_table.php b/database/migrations/2026_08_06_000000_create_static_cache_dependencies_table.php new file mode 100644 index 0000000..16d86a2 --- /dev/null +++ b/database/migrations/2026_08_06_000000_create_static_cache_dependencies_table.php @@ -0,0 +1,35 @@ +char('url_hash', 40); + $table->text('url'); + $table->string('tag', 191); + + $table->primary(['url_hash', 'tag']); + $table->index('tag'); + }); + } + + public function down(): void + { + Schema::dropIfExists(DatabaseGraph::TABLE); + } +}; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e27d651 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + + + + diff --git a/src/Blade/CacheTagsDirective.php b/src/Blade/CacheTagsDirective.php new file mode 100644 index 0000000..a994bc2 --- /dev/null +++ b/src/Blade/CacheTagsDirective.php @@ -0,0 +1,24 @@ +add(' . $expression . '); ?>'; + } +} diff --git a/src/CacheTags.php b/src/CacheTags.php new file mode 100644 index 0000000..bac2fa6 --- /dev/null +++ b/src/CacheTags.php @@ -0,0 +1,85 @@ +recorder->add(...$tags); + } + + /** + * Clear every cached URL carrying any of the given tags. + * + * Returns the number of URLs cleared. + */ + public function invalidate(string ...$tags): int + { + $urls = $this->urlsFor(...$tags); + + if ($urls === []) { + return 0; + } + + config('statamic.static_caching.background_recache', false) + ? $this->cacher->refreshUrls($urls) + : $this->cacher->invalidateUrls($urls); + + return count($urls); + } + + /** + * The cached URLs carrying any of the given tags, without clearing them. + * + * @return list + */ + public function urlsFor(string ...$tags): array + { + $tags = array_values(array_filter($tags, static fn (string $tag): bool => $tag !== '')); + + if ($tags === []) { + return []; + } + + // Unlike a content save, this deliberately does not sweep up cached URLs + // that are absent from the graph. That safety net exists so routine + // editing can never leave a page stale, and the next content save applies + // it anyway; folding it in here would make an explicit, targeted call + // clear the entire cache right after a deploy. + return $this->graph->urlsFor([...$tags, Tag::OVERFLOW]); + } +} diff --git a/src/CachedUrls.php b/src/CachedUrls.php new file mode 100644 index 0000000..3de8c8c --- /dev/null +++ b/src/CachedUrls.php @@ -0,0 +1,50 @@ + + */ + public function all(): array + { + if (! $this->cacher instanceof AbstractCacher) { + return []; + } + + $domains = $this->cacher->getDomains(); + + if ($domains->isEmpty()) { + $domains = collect([$this->cacher->getBaseUrl()]); + } + + return $domains + ->flatMap(fn (string $domain): array => $this->cacher + ->getUrls($domain) + ->map(fn (string $url): string => $domain.$url) + ->values() + ->all()) + ->filter() + ->unique() + ->values() + ->all(); + } +} diff --git a/src/Cachers/RecordsDependencies.php b/src/Cachers/RecordsDependencies.php new file mode 100644 index 0000000..8733df7 --- /dev/null +++ b/src/Cachers/RecordsDependencies.php @@ -0,0 +1,50 @@ +getUrl($request); + + // Mirrors the parent's own early return. An excluded URL is never + // cached, so it must not gain a graph row either. + if ($this->isExcluded($url)) { + return; + } + + // Deliberately no reset() here. While handling an error the middleware + // caches the shared error URL and then the real URL within one request, + // and both rows need the full tag set. + try { + app(DependencyGraph::class)->record($url, app(DependencyRecorder::class)->tags()); + } catch (Throwable $e) { + // Recording is best effort: a visitor's page render must not fail + // because the graph could not be written. A missing row leaves the + // URL untracked, which the safety net clears on the next save, so + // the failure mode is over-invalidation rather than stale content. + Log::warning('Could not record cache dependencies for ['.$url.']: '.$e->getMessage()); + } + } +} diff --git a/src/Cachers/TrackingApplicationCacher.php b/src/Cachers/TrackingApplicationCacher.php new file mode 100644 index 0000000..eee8794 --- /dev/null +++ b/src/Cachers/TrackingApplicationCacher.php @@ -0,0 +1,15 @@ +argument('item'); + + [$label, $tags] = $this->resolve($item, $resolver); + + if ($tags === []) { + $this->components->error("Could not resolve [{$item}] to an entry, term, global set, form or tag."); + + return self::FAILURE; + } + + $urls = $graph->urlsFor([...$tags, Tag::OVERFLOW]); + $untracked = $graph->untracked($cached->all()); + + $this->line(''); + $this->components->twoColumnDetail('Item', $label); + $this->components->twoColumnDetail('Tags', implode(' ', $tags)); + $this->line(''); + + if ($urls === []) { + $this->components->warn('No cached page records a dependency on this.'); + } else { + $this->line(' Matched by the graph ('.count($urls).')'); + + foreach ($urls as $url) { + $this->line(' '.$url); + } + } + + $this->line(''); + + if ($untracked !== []) { + $this->components->warn(sprintf( + 'Plus %d untracked cached URL(s), which any save clears until they are re-rendered.', + count($untracked), + )); + $this->line(''); + } + + return self::SUCCESS; + } + + /** + * @return array{0: string, 1: list} + */ + private function resolve(string $item, TagResolver $resolver): array + { + if ($entry = Entry::find($item)) { + return ["entry: {$entry->collectionHandle()}/{$entry->slug()}", $resolver->forItem($entry)]; + } + + if ($term = Term::find($item)) { + return ["term: {$term->taxonomyHandle()}/{$term->slug()}", $resolver->forItem($term)]; + } + + if ($set = GlobalSet::find($item)) { + $variables = $set->in(Site::default()->handle()); + + return ["global: {$item}", $variables ? $resolver->forItem($variables) : [Tag::globalSet($item)]]; + } + + if ($form = Form::find($item)) { + return ["form: {$item}", $resolver->forItem($form)]; + } + + // Anything namespaced like a tag is taken at face value, so a dependency + // can be checked without owning an object to pass in. + if (str_contains($item, ':')) { + return ["tag: {$item}", [$item]]; + } + + return ['', []]; + } +} diff --git a/src/Console/ClearCommand.php b/src/Console/ClearCommand.php new file mode 100644 index 0000000..69377a5 --- /dev/null +++ b/src/Console/ClearCommand.php @@ -0,0 +1,48 @@ + $given */ + $given = (array) $this->argument('tags'); + + $urls = $tags->urlsFor(...$given); + + if ($urls === []) { + $this->components->info('No cached page carries '.implode(' or ', $given).'.'); + + return self::SUCCESS; + } + + foreach (array_slice($urls, 0, 20) as $url) { + $this->line(' '.$url); + } + + if (count($urls) > 20) { + $this->line(sprintf(' … and %d more', count($urls) - 20)); + } + + $cleared = $tags->invalidate(...$given); + + $this->line(''); + $this->components->info("Cleared {$cleared} cached URL(s)."); + + return self::SUCCESS; + } +} diff --git a/src/Console/DoctorCommand.php b/src/Console/DoctorCommand.php new file mode 100644 index 0000000..c39267d --- /dev/null +++ b/src/Console/DoctorCommand.php @@ -0,0 +1,140 @@ +line(''); + + if (! config('statamic.static_caching.strategy') || $cacher instanceof NullCacher) { + $this->components->info('Static caching is disabled. Nothing to invalidate.'); + + return self::SUCCESS; + } + + $this->check('Static caching strategy', (string) config('statamic.static_caching.strategy'), true); + + $driver = (string) (config('cache_invalidation.driver') ?: 'sqlite'); + $this->check('Graph driver', $driver, true); + + if ($graph instanceof NullGraph) { + $this->components->warn('The null driver records nothing, so every save clears the entire cache.'); + } + + try { + $graph->stats(); + $this->check('Graph reachable', 'yes', true); + } catch (Throwable $e) { + $this->check('Graph reachable', $e->getMessage(), false); + $failed = true; + } + + if ($driver === 'sqlite') { + $loaded = extension_loaded('pdo_sqlite'); + $this->check('pdo_sqlite', $loaded ? 'loaded' : 'missing', $loaded); + $failed = $failed || ! $loaded; + + $path = (string) (config('cache_invalidation.sqlite_path') + ?: storage_path('statamic/cache-invalidation.sqlite')); + $writable = is_writable(is_file($path) ? $path : dirname($path)); + $this->check('Sqlite path writable', $path, $writable); + $failed = $failed || ! $writable; + } + + if (! $failed) { + $cachedUrls = $cached->all(); + $untracked = count(array_diff($cachedUrls, $graph->urls())); + + $this->check( + 'Cached URLs tracked', + sprintf('%d of %d', count($cachedUrls) - $untracked, count($cachedUrls)), + true, + ); + + if ($untracked > 0) { + $this->components->warn( + $untracked.' cached URL(s) are untracked and will be cleared by any save until re-rendered. ' + .'Expected right after a deploy or a full flush.' + ); + } + } + + $this->check( + 'Invalidator', + class_basename((string) config('statamic.static_caching.invalidation.class')), + true, + ); + + $this->line(''); + + if ($obsolete = array_values(array_filter( + self::OBSOLETE_KEYS, + fn (string $key): bool => config()->has("cache_invalidation.{$key}"), + ))) { + $this->components->warn( + 'config/cache_invalidation.php still declares v1 rule keys, which are ignored. ' + . 'Safe to delete: ' . implode(', ', $obsolete) + ); + $this->line(''); + } + + if ($failed) { + $this->components->error('Cache invalidation cannot work on this environment.'); + + return self::FAILURE; + } + + $this->components->info('Cache invalidation is ready.'); + + return self::SUCCESS; + } + + private function check(string $label, string $value, bool $ok): void + { + $this->components->twoColumnDetail( + $label, + ($ok ? '' : '').$value.($ok ? '' : ''), + ); + } +} diff --git a/src/Console/StatsCommand.php b/src/Console/StatsCommand.php new file mode 100644 index 0000000..caf1ee8 --- /dev/null +++ b/src/Console/StatsCommand.php @@ -0,0 +1,70 @@ +stats(); + $cachedUrls = $cached->all(); + $trackedUrls = $graph->urls(); + + $untracked = array_values(array_diff($cachedUrls, $trackedUrls)); + $orphaned = array_values(array_diff($trackedUrls, $cachedUrls)); + + $this->line(''); + $this->components->twoColumnDetail('Driver', (string) (config('cache_invalidation.driver') ?: 'sqlite')); + $this->components->twoColumnDetail('Cached URLs', (string) count($cachedUrls)); + $this->components->twoColumnDetail('Tracked URLs', (string) $stats['urls']); + $this->components->twoColumnDetail('Distinct tags', (string) $stats['tags']); + $this->components->twoColumnDetail('Rows', (string) $stats['rows']); + $this->components->twoColumnDetail( + 'Tags per URL (avg)', + $stats['urls'] > 0 ? (string) round($stats['rows'] / $stats['urls'], 1) : '0', + ); + $this->line(''); + + if ($untracked !== []) { + $this->components->warn(sprintf( + '%d cached URL(s) have no recorded dependencies. Each is cleared by any save until re-rendered.', + count($untracked), + )); + + foreach (array_slice($untracked, 0, 10) as $url) { + $this->line(' '.$url); + } + + if (count($untracked) > 10) { + $this->line(sprintf(' … and %d more', count($untracked) - 10)); + } + + $this->line(''); + } + + if ($orphaned !== []) { + $this->components->info(sprintf( + '%d tracked URL(s) are no longer cached. Harmless; pruned as they are invalidated.', + count($orphaned), + )); + $this->line(''); + } + + if ($untracked === [] && $cachedUrls !== []) { + $this->components->info('Every cached URL has recorded dependencies.'); + $this->line(''); + } + + return self::SUCCESS; + } +} diff --git a/src/Console/WhyCommand.php b/src/Console/WhyCommand.php new file mode 100644 index 0000000..8c0b13e --- /dev/null +++ b/src/Console/WhyCommand.php @@ -0,0 +1,76 @@ +argument('url'); + + // Statamic keys the cache on the normalised request URL, which may or may + // not carry a trailing slash depending on the site. Try both rather than + // reporting "not found" for a URL that is plainly cached. + $variant = str_ends_with($url, '/') ? rtrim($url, '/') : $url.'/'; + + $tags = $graph->tagsFor($url); + + if ($tags === [] && $variant !== '') { + $tags = $graph->tagsFor($variant); + } + + $allCached = $cached->all(); + $isCached = in_array($url, $allCached, true) || in_array($variant, $allCached, true); + + $this->line(''); + $this->line(" URL {$url}"); + $this->line(' Cached '.($isCached ? 'yes' : 'no')); + $this->line(''); + + if ($tags === []) { + $this->components->warn( + $isCached + ? 'Cached but untracked. It will be cleared by the next save of anything, until it is rendered again.' + : 'Not in the graph. Render the page once so its dependencies are recorded.' + ); + + return self::SUCCESS; + } + + if (in_array(Tag::OVERFLOW, $tags, true)) { + $this->components->warn('This page exceeded the tag cap and is treated as depending on everything.'); + $this->line(''); + } + + $grouped = []; + + foreach ($tags as $tag) { + $grouped[str_contains($tag, ':') ? strtok($tag, ':') : 'other'][] = $tag; + } + + ksort($grouped); + + foreach ($grouped as $group => $groupTags) { + $this->line(" {$group} (".count($groupTags).')'); + + foreach ($groupTags as $tag) { + $this->line(' '.$tag); + } + + $this->line(''); + } + + return self::SUCCESS; + } +} diff --git a/src/ContentDependencyInvalidator.php b/src/ContentDependencyInvalidator.php deleted file mode 100644 index 584f3cf..0000000 --- a/src/ContentDependencyInvalidator.php +++ /dev/null @@ -1,306 +0,0 @@ -shouldFlushAll($item)) { - $this->cache->flush(); - - return; - } - - $urls = $this->urlsFor($item) - ->filter() - ->unique() - ->values(); - - $this->cacher->invalidateUrls($urls->all()); - - if ($item instanceof Entry && $this->entryAffectsPageIndex($item)) { - $this->pagebuilder->clearIndex(); - } - - parent::invalidate($item); - } - - private function entryAffectsPageIndex(Entry $entry): bool - { - $collection = $entry->collectionHandle(); - - if (in_array($collection, config('cache_invalidation.pagebuilder_collections', ['pages']), true)) { - return true; - } - - foreach (config('cache_invalidation.collection_entry_rules', []) as $ruleCollection => $rules) { - if ($ruleCollection !== $collection) { - continue; - } - - foreach ((array) $rules as $rule) { - if (($rule['block'] ?? null) === PagebuilderBlockType::ReusableBlock->value) { - return true; - } - } - } - - return false; - } - - private function shouldFlushAll(mixed $item): bool - { - if ($item instanceof Variables) { - return in_array( - $item->globalSet()->handle(), - config('cache_invalidation.globals_flush_all', []), - true, - ); - } - - if ($item instanceof Nav) { - return in_array( - $item->handle(), - config('cache_invalidation.navs_flush_all', []), - true, - ); - } - - if ($item instanceof NavTree) { - return in_array( - $item->structure()->handle(), - config('cache_invalidation.navs_flush_all', []), - true, - ); - } - - return false; - } - - protected function urlsFor(mixed $item): Collection - { - if ($item instanceof Variables) { - return $this->urlsForGlobal($item); - } - - if ($item instanceof Entry) { - return $this->urlsForEntry($item); - } - - if ($item instanceof LocalizedTerm) { - return $this->urlsForTaxonomyTerm($item); - } - - return collect(); - } - - protected function urlsForGlobal(Variables $variables): Collection - { - return $this->urlsWithBlockTargets('global_urls', 'global_target_blocks', $variables->globalSet()->handle()); - } - - protected function urlsForEntry(Entry $entry): Collection - { - $collection = $entry->collectionHandle(); - $rules = config('cache_invalidation.collection_entry_rules', []); - - // Always applied: a collection having block rules does not mean those - // rules cover every way its entries surface. Anything rendered by a - // collection's own template, rather than by a pagebuilder block, can - // only be expressed here. - $urls = $this->ownUrl($entry) - ->merge($this->configuredUrls('collection_urls', $collection)) - ->merge($this->customEntryUrls($entry)); - - if (! array_key_exists($collection, $rules)) { - return $urls; - } - - $rule = $rules[$collection]; - - if ($rule === 'all') { - return $this->allCachedUrls()->merge($urls); - } - - return $urls - ->merge($this->blockRuleUrls((array) $rule, $entry)) - ->merge($this->parentRuleUrls((array) $rule, $entry)) - ->merge($this->referencingEntryRuleUrls((array) $rule, $entry)); - } - - /** - * ['block' => 'x', 'field' => 'y'] — cached pages containing that block. - * - * @param list> $rules - */ - private function blockRuleUrls(array $rules, Entry $entry): Collection - { - $blockRules = array_values(array_filter( - $rules, - fn (array $rule): bool => isset($rule['block']), - )); - - if ($blockRules === []) { - return collect(); - } - - $entryId = $entry->id(); - - return $this->pagebuilder->urlsForBlocksMatching( - fn (array $block): bool => $this->blockMatchesAnyRule($block, $blockRules, $entryId), - ); - } - - /** - * ['parent' => true] — the saved entry's parent page, for a structured - * collection whose parent template renders its children. - * - * Opt-in per collection rather than automatic: in a collection mounted at - * the site root, a top-level entry's parent() is the root itself, so - * applying this everywhere would clear the home page on every save. - * - * @param list> $rules - */ - private function parentRuleUrls(array $rules, Entry $entry): Collection - { - $wantsParent = collect($rules)->contains( - fn (array $rule): bool => ($rule['parent'] ?? false) === true, - ); - - if (! $wantsParent) { - return collect(); - } - - $url = $entry->parent()?->absoluteUrl(); - - return $url ? collect([$url]) : collect(); - } - - /** - * ['collection' => 'x', 'field' => 'y'] — the URLs of entries in collection - * x whose field y references the saved entry. The inverse of a block rule: - * for relations rendered by a collection's own template rather than by a - * pagebuilder block, so the block index cannot reach them. - * - * @param list> $rules - */ - private function referencingEntryRuleUrls(array $rules, Entry $entry): Collection - { - $entryId = $entry->id(); - - return collect($rules) - ->filter(fn (array $rule): bool => isset($rule['collection'], $rule['field'])) - ->flatMap(fn (array $rule): array => EntryFacade::whereCollection($rule['collection']) - ->filter(fn (Entry $candidate): bool => $this->valueReferencesEntry( - $candidate->get($rule['field']), - $entryId, - )) - ->map(fn (Entry $candidate): ?string => $candidate->absoluteUrl()) - ->filter() - ->all()) - ->unique() - ->values(); - } - - protected function urlsForTaxonomyTerm(LocalizedTerm $term): Collection - { - return $this->urlsWithBlockTargets('taxonomy_urls', 'taxonomy_target_blocks', $term->taxonomyHandle()); - } - - private function urlsWithBlockTargets(string $urlsKey, string $targetsKey, string $handle): Collection - { - $urls = $this->configuredUrls($urlsKey, $handle); - $blockTargets = config("cache_invalidation.{$targetsKey}", []); - - if (! isset($blockTargets[$handle])) { - return $urls; - } - - $types = (array) $blockTargets[$handle]; - - return $urls->merge($this->pagebuilder->urlsForBlocksMatching( - fn (array $block): bool => in_array($block['type'] ?? null, $types, true), - )); - } - - private function ownUrl(Entry $entry): Collection - { - $url = $entry->absoluteUrl(); - - return $url ? collect([$url]) : collect(); - } - - /** - * Override in a site-specific subclass when a collection cannot be expressed - * with config-driven block and field-reference rules. - */ - protected function customEntryUrls(Entry $entry): Collection - { - return collect(); - } - - /** - * @param array $block - * @param list $rules - */ - private function blockMatchesAnyRule(array $block, array $rules, string $entryId): bool - { - foreach ($rules as $rule) { - $blockType = $rule['block']; - - if (($block['type'] ?? null) !== $blockType) { - continue; - } - - if (! isset($rule['field'])) { - return true; - } - - if ($this->valueReferencesEntry($block[$rule['field']] ?? null, $entryId)) { - return true; - } - } - - return false; - } - - private function valueReferencesEntry(mixed $value, string $entryId): bool - { - return in_array($entryId, $this->references->extract($value), true); - } - - private function configuredUrls(string $configKey, string $handle): Collection - { - return collect((array) config("cache_invalidation.{$configKey}.{$handle}", [])) - ->filter() - ->values(); - } - - private function allCachedUrls(): Collection - { - return $this->cacher->getUrls()->filter()->values(); - } -} diff --git a/src/EntryReferenceExtractor.php b/src/EntryReferenceExtractor.php deleted file mode 100644 index 59cce42..0000000 --- a/src/EntryReferenceExtractor.php +++ /dev/null @@ -1,76 +0,0 @@ - - */ - public function extract(mixed $value): array - { - return collect($this->extractRecursive($value)) - ->filter() - ->unique() - ->values() - ->all(); - } - - /** - * @return array - */ - private function extractRecursive(mixed $value): array - { - if (is_string($value)) { - return $this->extractFromString($value); - } - - if ($value instanceof Entry) { - return [$value->id()]; - } - - if ($value instanceof Collection) { - return $value - ->flatMap(fn (mixed $item): array => $this->extractRecursive($item)) - ->values() - ->all(); - } - - if (is_array($value)) { - return collect($value) - ->flatMap(fn (mixed $item): array => $this->extractRecursive($item)) - ->values() - ->all(); - } - - return []; - } - - /** - * A bare id covers the entries fieldtype. The link fieldtype stores - * "entry::" and Bard stores hrefs as "statamic://entry::", so a rule - * pointed at either of those fields would otherwise never match. - * - * @return array - */ - private function extractFromString(string $value): array - { - if (Str::isUuid($value)) { - return [$value]; - } - - if (! Str::contains($value, 'entry::')) { - return []; - } - - $id = Str::after($value, 'entry::'); - - return Str::isUuid($id) ? [$id] : []; - } -} diff --git a/src/Facades/CacheTags.php b/src/Facades/CacheTags.php new file mode 100644 index 0000000..e996cf8 --- /dev/null +++ b/src/Facades/CacheTags.php @@ -0,0 +1,23 @@ + urlsFor(string ...$tags) + * + * @see Manager + */ +final class CacheTags extends Facade +{ + protected static function getFacadeAccessor(): string + { + return Manager::class; + } +} diff --git a/src/FlushStaticCacheOnFormBlueprintSaved.php b/src/FlushStaticCacheOnFormBlueprintSaved.php deleted file mode 100644 index 79c1153..0000000 --- a/src/FlushStaticCacheOnFormBlueprintSaved.php +++ /dev/null @@ -1,27 +0,0 @@ -blueprint->namespace() !== 'forms') { - return; - } - - if (! config('cache_invalidation.forms_flush_all', true)) { - return; - } - - $this->cache->flush(); - } -} diff --git a/src/Graph/ClearGraphWhenCacheCleared.php b/src/Graph/ClearGraphWhenCacheCleared.php new file mode 100644 index 0000000..babe55e --- /dev/null +++ b/src/Graph/ClearGraphWhenCacheCleared.php @@ -0,0 +1,31 @@ +graph->flush(); + } +} diff --git a/src/Graph/DatabaseGraph.php b/src/Graph/DatabaseGraph.php new file mode 100644 index 0000000..0977924 --- /dev/null +++ b/src/Graph/DatabaseGraph.php @@ -0,0 +1,37 @@ +db->connection($this->connection); + } + + protected function table(): string + { + return self::TABLE; + } +} diff --git a/src/Graph/DependencyGraph.php b/src/Graph/DependencyGraph.php new file mode 100644 index 0000000..1488b69 --- /dev/null +++ b/src/Graph/DependencyGraph.php @@ -0,0 +1,63 @@ + tags graph recorded while pages render. + * + * URLs are absolute throughout, matching Cacher::getUrl(). Statamic stores its + * own URL list relative to the domain, so anything comparing the two has to + * re-prefix; see CachedUrls. + */ +interface DependencyGraph +{ + /** + * Replace the recorded tag set for a URL. An empty set removes the URL from + * the graph entirely, which leaves it untracked and therefore cleared by the + * next invalidation rather than assumed to depend on nothing. + * + * @param list $tags + */ + public function record(string $url, array $tags): void; + + /** + * @param list $tags + * @return list + */ + public function urlsFor(array $tags): array; + + /** + * @return list + */ + public function tagsFor(string $url): array; + + public function forget(string $url): void; + + /** + * Of the given URLs, those with no recorded dependencies. + * + * The safety net behind every invalidation: a URL that is cached but absent + * from the graph — cached before the addon was installed, or written while + * the graph was unreachable — has to be treated as depending on everything, + * or it would stay stale forever with no symptom. Bounded by the number of + * cached URLs rather than the size of the graph, because it runs on save. + * + * @param list $urls + * @return list + */ + public function untracked(array $urls): array; + + /** + * @return list + */ + public function urls(): array; + + public function flush(): void; + + /** + * @return array{urls: int, tags: int, rows: int} + */ + public function stats(): array; +} diff --git a/src/Graph/NullGraph.php b/src/Graph/NullGraph.php new file mode 100644 index 0000000..fc29ce3 --- /dev/null +++ b/src/Graph/NullGraph.php @@ -0,0 +1,48 @@ + 0, 'tags' => 0, 'rows' => 0]; + } +} diff --git a/src/Graph/SqlGraph.php b/src/Graph/SqlGraph.php new file mode 100644 index 0000000..309b1b4 --- /dev/null +++ b/src/Graph/SqlGraph.php @@ -0,0 +1,141 @@ +normalize($tags); + $hash = $this->hash($url); + + $this->connection()->transaction(function () use ($url, $hash, $tags): void { + $this->query()->where('url_hash', $hash)->delete(); + + foreach (array_chunk($tags, self::CHUNK) as $chunk) { + $this->query()->insertOrIgnore(array_map( + fn (string $tag): array => ['url_hash' => $hash, 'url' => $url, 'tag' => $tag], + $chunk, + )); + } + }); + } + + public function urlsFor(array $tags): array + { + if (($tags = $this->normalize($tags)) === []) { + return []; + } + + $urls = []; + + foreach (array_chunk($tags, self::CHUNK) as $chunk) { + foreach ($this->query()->whereIn('tag', $chunk)->distinct()->pluck('url') as $url) { + $urls[$url] = true; + } + } + + return array_keys($urls); + } + + public function tagsFor(string $url): array + { + return $this->query() + ->where('url_hash', $this->hash($url)) + ->orderBy('tag') + ->pluck('tag') + ->all(); + } + + public function forget(string $url): void + { + $this->query()->where('url_hash', $this->hash($url))->delete(); + } + + public function untracked(array $urls): array + { + if ($urls === []) { + return []; + } + + $tracked = []; + + foreach (array_chunk($urls, self::CHUNK) as $chunk) { + $hashes = array_map(fn (string $url): string => $this->hash($url), $chunk); + + foreach ($this->query()->whereIn('url_hash', $hashes)->distinct()->pluck('url_hash') as $hash) { + $tracked[$hash] = true; + } + } + + return array_values(array_filter( + $urls, + fn (string $url): bool => ! isset($tracked[$this->hash($url)]), + )); + } + + public function urls(): array + { + return $this->query()->distinct()->orderBy('url')->pluck('url')->all(); + } + + public function flush(): void + { + $this->query()->delete(); + } + + public function stats(): array + { + return [ + 'urls' => $this->query()->distinct()->count('url_hash'), + 'tags' => $this->query()->distinct()->count('tag'), + 'rows' => $this->query()->count(), + ]; + } + + protected function query(): Builder + { + return $this->connection()->table($this->table()); + } + + protected function hash(string $url): string + { + return sha1($url); + } + + /** + * @param list $tags + * @return list + */ + private function normalize(array $tags): array + { + return array_values(array_unique(array_filter( + $tags, + static fn (string $tag): bool => $tag !== '', + ))); + } +} diff --git a/src/Graph/SqliteGraph.php b/src/Graph/SqliteGraph.php new file mode 100644 index 0000000..2cb3e12 --- /dev/null +++ b/src/Graph/SqliteGraph.php @@ -0,0 +1,76 @@ +ensureSchema(); + + return $this->db->connection(self::CONNECTION); + } + + protected function table(): string + { + return 'dependencies'; + } + + /** + * Runs once per process. CREATE ... IF NOT EXISTS rather than a migration so + * that installing the addon requires no artisan step. + */ + private function ensureSchema(): void + { + if ($this->ready) { + return; + } + + if (! is_dir($directory = dirname($this->path))) { + mkdir($directory, 0755, true); + } + + // Laravel's sqlite connector resolves the path with realpath() and throws + // when the file is absent, so the file has to exist before we connect. + if (! is_file($this->path)) { + touch($this->path); + } + + $connection = $this->db->connection(self::CONNECTION); + + $connection->statement( + 'CREATE TABLE IF NOT EXISTS dependencies (' + .'url_hash TEXT NOT NULL, url TEXT NOT NULL, tag TEXT NOT NULL, ' + .'PRIMARY KEY (url_hash, tag)' + .') WITHOUT ROWID' + ); + + $connection->statement( + 'CREATE INDEX IF NOT EXISTS dependencies_tag_index ON dependencies (tag)' + ); + + $this->ready = true; + } +} diff --git a/src/HandleCollectionTreeSaved.php b/src/HandleCollectionTreeSaved.php deleted file mode 100644 index 40a1fcc..0000000 --- a/src/HandleCollectionTreeSaved.php +++ /dev/null @@ -1,34 +0,0 @@ -pagebuilder->clearIndex(); - - $handle = $event->tree->collection()->handle(); - - if (in_array($handle, config('cache_invalidation.collection_trees_flush_all', []), true)) { - $this->cache->flush(); - } - } -} diff --git a/src/Http/AddCacheTagsHeader.php b/src/Http/AddCacheTagsHeader.php new file mode 100644 index 0000000..c7b4647 --- /dev/null +++ b/src/Http/AddCacheTagsHeader.php @@ -0,0 +1,50 @@ +tags(); + $total = count($tags); + + if ($total > self::MAX_TAGS) { + $tags = array_slice($tags, 0, self::MAX_TAGS); + $tags[] = sprintf('… (%d total)', $total); + } + + $response->headers->set('X-Cache-Tags', implode(' ', $tags)); + + return $response; + } +} diff --git a/src/Invalidation/GraphInvalidator.php b/src/Invalidation/GraphInvalidator.php new file mode 100644 index 0000000..caa1357 --- /dev/null +++ b/src/Invalidation/GraphInvalidator.php @@ -0,0 +1,70 @@ +tags->forItem($item); + + $this->clear([ + ...$this->getItemUrls($item), + ...$tags === [] ? [] : $this->graph->urlsFor([...$tags, Tag::OVERFLOW]), + ...$this->graph->untracked($this->cached->all()), + ]); + } + + /** + * @param list $urls + */ + private function clear(array $urls): void + { + $urls = array_values(array_unique(array_filter($urls))); + + if ($urls === []) { + return; + } + + // DefaultInvalidator::refresh() flips this before delegating here. v1 + // ignored it and always hard-purged, which silently broke + // static_caching.background_recache. + $this->refreshing + ? $this->cacher->refreshUrls($urls) + : $this->cacher->invalidateUrls($urls); + } +} diff --git a/src/Invalidation/TagResolver.php b/src/Invalidation/TagResolver.php new file mode 100644 index 0000000..0c1d10a --- /dev/null +++ b/src/Invalidation/TagResolver.php @@ -0,0 +1,71 @@ + + */ + public function forItem(mixed $item): array + { + return match (true) { + $item instanceof Entry => array_values(array_filter([ + Tag::entry((string) $item->id()), + $item->collectionHandle() ? Tag::collection((string) $item->collectionHandle()) : null, + ])), + + $item instanceof LocalizedTerm, $item instanceof Term => array_values(array_filter([ + $item->taxonomyHandle() && $item->slug() + ? Tag::term((string) $item->taxonomyHandle(), (string) $item->slug()) + : null, + $item->taxonomyHandle() ? Tag::taxonomy((string) $item->taxonomyHandle()) : null, + ])), + + $item instanceof Variables => [Tag::globalSet((string) $item->globalSet()->handle())], + + $item instanceof Form => [Tag::form((string) $item->handle())], + + // A nav is tagged where it renders rather than clearing the whole + // cache, so a nav used on a handful of pages clears only those. A nav + // in the shared layout still reaches every page — that is now an + // emergent consequence of where it is used, not a hardcoded rule. + $item instanceof Nav => [Tag::nav((string) $item->handle())], + + $item instanceof NavTree => [Tag::nav((string) $item->structure()->handle())], + + $item instanceof Collection => [Tag::collection((string) $item->handle())], + + // A tree save moves or reorders entries, which changes any listing + // built from the collection. + $item instanceof CollectionTree => [Tag::collection((string) $item->collection()->handle())], + + // Assets are not tracked on the read side yet, so there is nothing to + // match. Statamic's own rule-based URLs still apply. + $item instanceof Asset => [], + + default => [], + }; + } +} diff --git a/src/PagebuilderBlockResolver.php b/src/PagebuilderBlockResolver.php deleted file mode 100644 index 65cc267..0000000 --- a/src/PagebuilderBlockResolver.php +++ /dev/null @@ -1,100 +0,0 @@ -> - */ - public function resolve(Entry $entry, bool $expandReusable = true): array - { - $blocks = $entry->get('pagebuilder'); - - if (! is_array($blocks)) { - return []; - } - - return $this->resolveBlocks($blocks, $expandReusable); - } - - /** - * @param array $blocks - * @param array $visitedReusableBlockIds - * @return array> - */ - private function resolveBlocks(array $blocks, bool $expandReusable, array $visitedReusableBlockIds = []): array - { - $resolved = []; - - foreach ($blocks as $block) { - if (! is_array($block)) { - continue; - } - - $resolved[] = $block; - - if (! $this->shouldExpandReusableBlock($block, $expandReusable)) { - continue; - } - - $resolved = [ - ...$resolved, - ...$this->resolveReusableBlocks($block, $expandReusable, $visitedReusableBlockIds), - ]; - } - - return $resolved; - } - - /** - * @param array $block - * @param array $visitedReusableBlockIds - * @return array> - */ - private function resolveReusableBlocks(array $block, bool $expandReusable, array $visitedReusableBlockIds): array - { - $resolved = []; - - foreach ($this->references->extract($block['entry'] ?? null) as $reusableBlockId) { - if (in_array($reusableBlockId, $visitedReusableBlockIds, true)) { - continue; - } - - $reusableBlock = EntryFacade::find($reusableBlockId); - - if (! $reusableBlock instanceof Entry) { - continue; - } - - $resolved = [ - ...$resolved, - ...$this->resolveBlocks( - $reusableBlock->get('pagebuilder') ?? [], - $expandReusable, - [...$visitedReusableBlockIds, $reusableBlockId], - ), - ]; - } - - return $resolved; - } - - /** - * @param array $block - */ - private function shouldExpandReusableBlock(array $block, bool $expandReusable): bool - { - return $expandReusable - && PagebuilderBlockType::ReusableBlock->matches($block); - } -} diff --git a/src/PagebuilderBlockType.php b/src/PagebuilderBlockType.php deleted file mode 100644 index 8aa82ee..0000000 --- a/src/PagebuilderBlockType.php +++ /dev/null @@ -1,18 +0,0 @@ - $block - */ - public function matches(array $block): bool - { - return ($block['type'] ?? null) === $this->value; - } -} diff --git a/src/PagebuilderDependencyScanner.php b/src/PagebuilderDependencyScanner.php deleted file mode 100644 index b9aa07a..0000000 --- a/src/PagebuilderDependencyScanner.php +++ /dev/null @@ -1,133 +0,0 @@ -getIndex()) - ->filter(fn (array $blocks): bool => collect($blocks)->contains($predicate)) - ->keys() - ->values(); - } - - public function clearIndex(): void - { - Cache::forget($this->indexKey()); - Cache::forget(self::INDEX_KEY); - } - - /** - * The index only keeps the block fields named in collection_entry_rules - * (see slimBlocks), so a cached index built under a different rule set is - * not just stale, it is missing keys the new rules match on. Fingerprinting - * the config into the key means such an index is never read: without this, - * adding a field-scoped rule silently matches nothing until something else - * happens to clear the index. - */ - private function indexKey(): string - { - $fingerprint = md5(serialize([ - config('cache_invalidation.collection_entry_rules', []), - config('cache_invalidation.pagebuilder_collections', ['pages']), - ])); - - return self::INDEX_KEY.'.'.$fingerprint; - } - - /** - * @return array>> - */ - private function getIndex(): array - { - return Cache::rememberForever($this->indexKey(), fn (): array => $this->buildIndex()); - } - - /** - * @return array>> - */ - private function buildIndex(): array - { - $collections = config('cache_invalidation.pagebuilder_collections', ['pages']); - $relevantFields = $this->indexRelevantFields(); - - return EntryFacade::whereInCollection($collections) - ->filter(fn (Entry $entry): bool => $this->isPublicPagebuilderEntry($entry)) - ->mapWithKeys(fn (Entry $entry): array => [ - $entry->absoluteUrl() => $this->slimBlocks($this->blocks->resolve($entry, true), $relevantFields), - ]) - ->all(); - } - - /** - * Returns the block field handles that must be kept in the index. - * - * Only 'block' rules match against indexed blocks; global and taxonomy - * predicates match on block type alone, and the 'field' of a 'collection' - * rule names a field on an entry rather than on a block. - * - * @return list - */ - private function indexRelevantFields(): array - { - return collect(config('cache_invalidation.collection_entry_rules', [])) - ->flatMap(fn (mixed $rules): array => is_array($rules) - ? collect($rules) - ->filter(fn (mixed $rule): bool => is_array($rule) && isset($rule['block'], $rule['field'])) - ->pluck('field') - ->all() - : [] - ) - ->unique() - ->values() - ->all(); - } - - /** - * Strips each block down to type + the fields needed for rule matching, - * discarding rich-text and other large field values that bloat the cache. - * - * @param list> $blocks - * @param list $relevantFields - * @return list> - */ - private function slimBlocks(array $blocks, array $relevantFields): array - { - return array_map(function (array $block) use ($relevantFields): array { - $slim = ['type' => $block['type'] ?? null]; - - foreach ($relevantFields as $field) { - if (array_key_exists($field, $block)) { - $slim[$field] = $block[$field]; - } - } - - return $slim; - }, $blocks); - } - - private function isPublicPagebuilderEntry(Entry $entry): bool - { - if (! $entry->published()) { - return false; - } - - return $entry->absoluteUrl() !== null - && is_array($entry->get('pagebuilder')); - } -} diff --git a/src/Recording/DependencyRecorder.php b/src/Recording/DependencyRecorder.php new file mode 100644 index 0000000..ac2b7c2 --- /dev/null +++ b/src/Recording/DependencyRecorder.php @@ -0,0 +1,168 @@ + */ + private array $tags = []; + + private bool $overflowed = false; + + private bool $suppressing = false; + + /** + * Ignore everything recorded while the callback runs. + * + * For Statamic's own resolution machinery, which queries content to work out + * *which* entry a request is for. Those reads are not rendered output, and + * treating them as such attributes dependencies to a page that it does not + * display — see TrackingEntryRepository. + */ + public function suppressed(callable $callback): mixed + { + $previous = $this->suppressing; + $this->suppressing = true; + + try { + return $callback(); + } finally { + $this->suppressing = $previous; + } + } + + public function add(string ...$tags): void + { + if ($this->suppressing || $this->overflowed) { + return; + } + + foreach ($tags as $tag) { + if ($tag === '' || isset($this->tags[$tag])) { + continue; + } + + if (count($this->tags) >= self::MAX_TAGS) { + $this->overflow(); + + return; + } + + $this->tags[$tag] = true; + } + } + + /** + * @param iterable $ids + */ + public function entries(iterable $ids): void + { + foreach ($ids as $id) { + if (is_string($id) && $id !== '') { + $this->add(Tag::entry($id)); + } + } + } + + /** + * @param iterable $handles + */ + public function collections(iterable $handles): void + { + foreach ($handles as $handle) { + if (is_string($handle) && $handle !== '') { + $this->add(Tag::collection($handle)); + } + } + } + + public function term(string $taxonomy, string $slug): void + { + $this->add(Tag::term($taxonomy, $slug)); + } + + /** + * @param iterable $handles + */ + public function taxonomies(iterable $handles): void + { + foreach ($handles as $handle) { + if (is_string($handle) && $handle !== '') { + $this->add(Tag::taxonomy($handle)); + } + } + } + + public function globalSet(string $handle): void + { + $this->add(Tag::globalSet($handle)); + } + + public function form(string $handle): void + { + $this->add(Tag::form($handle)); + } + + public function nav(string $handle): void + { + $this->add(Tag::nav($handle)); + } + + /** + * @return list + */ + public function tags(): array + { + return array_keys($this->tags); + } + + public function isEmpty(): bool + { + return $this->tags === []; + } + + public function overflowed(): bool + { + return $this->overflowed; + } + + public function reset(): void + { + $this->tags = []; + $this->overflowed = false; + $this->suppressing = false; + } + + /** + * Collapse to the overflow tag and stop collecting. Deliberately lossy in + * the safe direction: the page is now cleared by any content save, which + * beats a silently truncated tag set that would leave it stale. + */ + private function overflow(): void + { + $this->tags = [Tag::OVERFLOW => true]; + $this->overflowed = true; + } +} diff --git a/src/Recording/DetectsItemLookups.php b/src/Recording/DetectsItemLookups.php new file mode 100644 index 0000000..6d0c903 --- /dev/null +++ b/src/Recording/DetectsItemLookups.php @@ -0,0 +1,89 @@ +wheres)) { + return false; + } + + $bounded = false; + + foreach ($this->wheres as $where) { + if (($where['boolean'] ?? 'and') !== 'and') { + return false; + } + + if ($this->boundsResults($where)) { + $bounded = true; + } + } + + return $bounded; + } + + /** + * @param array $where + */ + private function boundsResults(array $where): bool + { + if (! in_array($where['column'] ?? null, self::IDENTIFYING_COLUMNS, true)) { + return false; + } + + if (! in_array($where['type'] ?? null, self::BOUNDING_TYPES, true)) { + return false; + } + + return ($where['type'] === 'In') || ($where['operator'] ?? '=') === '='; + } +} diff --git a/src/Recording/TrackingAugmentedVariables.php b/src/Recording/TrackingAugmentedVariables.php new file mode 100644 index 0000000..1b69247 --- /dev/null +++ b/src/Recording/TrackingAugmentedVariables.php @@ -0,0 +1,44 @@ +handle !== '') { + $this->recorder->globalSet($this->handle); + } + + return parent::get($handle); + } +} diff --git a/src/Recording/TrackingEntryQueryBuilder.php b/src/Recording/TrackingEntryQueryBuilder.php new file mode 100644 index 0000000..897f312 --- /dev/null +++ b/src/Recording/TrackingEntryQueryBuilder.php @@ -0,0 +1,55 @@ +isItemLookup()) { + // where('collection', ...) never lands in $wheres — EntryQueryBuilder + // intercepts it into $collections first — so isItemLookup() is not + // fooled by it. An unscoped query spans every collection. + $this->recorder->collections($this->collections ?: Collection::handles()); + } + + return parent::getFilteredKeys(); + } + + protected function getItems($keys) + { + $items = parent::getItems($keys); + + $this->recorder->entries($items->map->id()); + + return $items; + } +} diff --git a/src/Recording/TrackingEntryRepository.php b/src/Recording/TrackingEntryRepository.php new file mode 100644 index 0000000..d98b350 --- /dev/null +++ b/src/Recording/TrackingEntryRepository.php @@ -0,0 +1,44 @@ +recorder->suppressed(fn (): ?Entry => parent::findByUri($uri, $site)); + + if ($entry !== null) { + $this->recorder->entries([$entry->id()]); + } + + return $entry; + } +} diff --git a/src/Recording/TrackingFormRepository.php b/src/Recording/TrackingFormRepository.php new file mode 100644 index 0000000..5e3ee1e --- /dev/null +++ b/src/Recording/TrackingFormRepository.php @@ -0,0 +1,38 @@ +recorder->form($handle); + } + + return $form; + } +} diff --git a/src/Recording/TrackingNavTag.php b/src/Recording/TrackingNavTag.php new file mode 100644 index 0000000..b2fca94 --- /dev/null +++ b/src/Recording/TrackingNavTag.php @@ -0,0 +1,55 @@ +handle() : $handle; + + $value = $recorder->suppressed(fn () => parent::structure($handle)); + + // Suppression also drops the nav the repository would have recorded, so it + // is re-added here — this is the one tag that should survive. + if (is_string($resolved) && $resolved !== '') { + Str::startsWith($resolved, 'collection::') + // A collection structure is that collection's tree, so its list + // tag is the honest dependency. + ? $recorder->add(Tag::collection(Str::after($resolved, 'collection::'))) + : $recorder->nav($resolved); + } + + return $value; + } +} diff --git a/src/Recording/TrackingNavTreeRepository.php b/src/Recording/TrackingNavTreeRepository.php new file mode 100644 index 0000000..d79f1e4 --- /dev/null +++ b/src/Recording/TrackingNavTreeRepository.php @@ -0,0 +1,38 @@ +recorder->nav($handle); + } + + return $tree; + } +} diff --git a/src/Recording/TrackingNavigationRepository.php b/src/Recording/TrackingNavigationRepository.php new file mode 100644 index 0000000..bf72e1f --- /dev/null +++ b/src/Recording/TrackingNavigationRepository.php @@ -0,0 +1,52 @@ +recorder->nav((string) $nav->handle()); + } + + return $nav; + } + + public function all(): Collection + { + $navs = parent::all(); + + foreach ($navs as $nav) { + $this->recorder->nav((string) $nav->handle()); + } + + return $navs; + } +} diff --git a/src/Recording/TrackingTermQueryBuilder.php b/src/Recording/TrackingTermQueryBuilder.php new file mode 100644 index 0000000..446886b --- /dev/null +++ b/src/Recording/TrackingTermQueryBuilder.php @@ -0,0 +1,46 @@ +isItemLookup()) { + $this->recorder->taxonomies($this->taxonomies ?: Taxonomy::handles()); + } + + return parent::getFilteredKeys(); + } + + protected function getItems($keys) + { + $items = parent::getItems($keys); + + foreach ($items as $term) { + if (($taxonomy = $term->taxonomyHandle()) && ($slug = $term->slug())) { + $this->recorder->term((string) $taxonomy, (string) $slug); + } + } + + return $items; + } +} diff --git a/src/Recording/TrackingTermRepository.php b/src/Recording/TrackingTermRepository.php new file mode 100644 index 0000000..23c7e0a --- /dev/null +++ b/src/Recording/TrackingTermRepository.php @@ -0,0 +1,33 @@ +ensureAssociations(); + + return new TrackingTermQueryBuilder($this->store, $this->recorder); + } +} diff --git a/src/Recording/TrackingVariables.php b/src/Recording/TrackingVariables.php new file mode 100644 index 0000000..3bb97b5 --- /dev/null +++ b/src/Recording/TrackingVariables.php @@ -0,0 +1,22 @@ +handle()); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 9fc4371..2fe4d4e 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -4,20 +4,73 @@ namespace RoxDigital\CacheInvalidation; -use Statamic\Events\BlueprintSaved; -use Statamic\Events\CollectionTreeSaved; +use Illuminate\Database\DatabaseManager; +use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Event; +use RoxDigital\CacheInvalidation\Blade\CacheTagsDirective; +use RoxDigital\CacheInvalidation\Cachers\TrackingApplicationCacher; +use RoxDigital\CacheInvalidation\Cachers\TrackingFileCacher; +use RoxDigital\CacheInvalidation\Console\AffectedCommand; +use RoxDigital\CacheInvalidation\Console\ClearCommand; +use RoxDigital\CacheInvalidation\Console\DoctorCommand; +use RoxDigital\CacheInvalidation\Console\StatsCommand; +use RoxDigital\CacheInvalidation\Console\WhyCommand; +use RoxDigital\CacheInvalidation\Graph\ClearGraphWhenCacheCleared; +use RoxDigital\CacheInvalidation\Graph\DatabaseGraph; +use RoxDigital\CacheInvalidation\Graph\DependencyGraph; +use RoxDigital\CacheInvalidation\Graph\NullGraph; +use RoxDigital\CacheInvalidation\Graph\SqliteGraph; +use RoxDigital\CacheInvalidation\Http\AddCacheTagsHeader; +use RoxDigital\CacheInvalidation\Invalidation\GraphInvalidator; +use RoxDigital\CacheInvalidation\Recording\DependencyRecorder; +use RoxDigital\CacheInvalidation\Recording\TrackingEntryQueryBuilder; +use RoxDigital\CacheInvalidation\Recording\TrackingEntryRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingFormRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingNavigationRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingNavTag; +use RoxDigital\CacheInvalidation\Recording\TrackingNavTreeRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingTermRepository; +use RoxDigital\CacheInvalidation\Recording\TrackingVariables; +use Statamic\Contracts\Entries\EntryRepository as EntryRepositoryContract; +use Statamic\Contracts\Entries\QueryBuilder as EntryQueryBuilderContract; +use Statamic\Contracts\Forms\FormRepository as FormRepositoryContract; +use Statamic\Contracts\Globals\Variables as VariablesContract; +use Statamic\Contracts\Structures\NavigationRepository as NavigationRepositoryContract; +use Statamic\Contracts\Structures\NavTreeRepository as NavTreeRepositoryContract; +use Statamic\Contracts\Taxonomies\TermRepository as TermRepositoryContract; +use Statamic\Events\StaticCacheCleared; +use Statamic\Facades\StaticCache; use Statamic\Providers\AddonServiceProvider; +use Statamic\Stache\Query\EntryQueryBuilder; +use Statamic\Stache\Stache; +use Statamic\Stache\Stores\Store; +use Statamic\Statamic; +use Statamic\Tags\Nav as NavTag; +use Statamic\StaticCaching\Cachers\Writer; +use Statamic\StaticCaching\StaticCacheManager; class ServiceProvider extends AddonServiceProvider { protected $config = false; - protected $listen = [ - BlueprintSaved::class => [ - FlushStaticCacheOnFormBlueprintSaved::class, + protected $commands = [ + AffectedCommand::class, + ClearCommand::class, + DoctorCommand::class, + StatsCommand::class, + WhyCommand::class, + ]; + + protected $middlewareGroups = [ + 'statamic.web' => [ + AddCacheTagsHeader::class, ], - CollectionTreeSaved::class => [ - HandleCollectionTreeSaved::class, + ]; + + protected $listen = [ + StaticCacheCleared::class => [ + ClearGraphWhenCacheCleared::class, ], ]; @@ -25,20 +78,150 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/cache_invalidation.php', 'cache_invalidation'); - if ($this->app['config']->get('statamic.static_caching.invalidation.class') === null) { - $this->app['config']->set( - 'statamic.static_caching.invalidation.class', - ContentDependencyInvalidator::class, - ); + $this->fillMissingConfig(); + $this->registerSqliteConnection(); + $this->registerGraph(); + $this->registerRecorder(); + $this->registerTrackingCachers(); + $this->registerInvalidator(); + } + + public function bootAddon(): void + { + $this->publishes([ + __DIR__ . '/../config/cache_invalidation.php' => config_path('cache_invalidation.php'), + ], 'cache-invalidation-config'); + + // The default sqlite driver creates its own schema, so only the opt-in + // database driver has anything for `php artisan migrate` to find. + if ($this->graphDriver() === 'database') { + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } + + $this->registerReadRecorders(); + + Blade::directive('cachetags', CacheTagsDirective::compile(...)); + } + + /** + * Defaults cannot live only in the config file. + * + * `php artisan config:cache` makes mergeConfigFrom a no-op, so a site that + * followed the install instructions — which say there is nothing to publish — + * and then cached its config has no cache_invalidation namespace at all at + * runtime. Without this the sqlite path is empty, the graph cannot be opened, + * and the queued invalidation job throws: pages stay stale and nothing says so. + */ + private function fillMissingConfig(): void + { + $defaults = [ + 'driver' => 'sqlite', + 'sqlite_path' => storage_path('statamic/cache-invalidation.sqlite'), + 'database_connection' => null, + 'debug' => false, + ]; + + foreach ($defaults as $key => $default) { + if ($this->app['config']->get("cache_invalidation.{$key}") === null) { + $this->app['config']->set("cache_invalidation.{$key}", $default); + } + } + } + + /** + * A dedicated connection owned by the addon, so the graph works on a site + * with no DB_CONNECTION configured — which is the common Statamic case. + */ + private function registerSqliteConnection(): void + { + $this->app['config']->set('database.connections.' . SqliteGraph::CONNECTION, [ + 'driver' => 'sqlite', + 'database' => $this->sqlitePath(), + 'prefix' => '', + 'foreign_key_constraints' => false, + 'journal_mode' => 'wal', + 'busy_timeout' => 5000, + ]); + } + + private function registerGraph(): void + { + $this->app->singleton(DependencyGraph::class, fn ($app): DependencyGraph => match ($this->graphDriver()) { + 'database' => new DatabaseGraph( + $app->make(DatabaseManager::class), + $app['config']->get('cache_invalidation.database_connection'), + ), + 'null' => new NullGraph, + default => new SqliteGraph($app->make(DatabaseManager::class), $this->sqlitePath()), + }); + } + + private function registerRecorder(): void + { + $this->app->singleton(DependencyRecorder::class); + $this->app->singleton(CacheTags::class); + + // In PHP-FPM the singleton's lifetime is the request. A queue worker + // keeps the container alive across jobs, so the tag set has to be cleared + // between them or it would grow until it overflowed. (Octane needs the + // same treatment via its RequestReceived event.) + Event::listen(JobProcessing::class, function (): void { + $this->app->make(DependencyRecorder::class)->reset(); + }); + } + + /** + * Subclasses of the concrete cachers rather than a decorator on the Cacher + * binding: Statamic's cache middleware branches on `instanceof + * ApplicationCacher`, `FileCacher` and `NullCacher`, and a wrapper would + * silently change which responses are cached. + * + * Registered through afterResolving so the custom creators are in place + * before anything calls driver() on the manager. + */ + private function registerTrackingCachers(): void + { + $this->app->afterResolving(StaticCacheManager::class, function (StaticCacheManager $manager): void { + // Statamic's Manager hands custom creators the same fully merged + // config its own createXDriver() methods receive — exclusions, query + // string handling and locale included — so nothing has to be + // reconstructed here. + $manager->extend('application', fn ($app, array $config): TrackingApplicationCacher => new TrackingApplicationCacher( + StaticCache::cacheStore(), + $config, + )); + + $manager->extend('file', fn ($app, array $config): TrackingFileCacher => new TrackingFileCacher( + new Writer($config['permissions'] ?? []), + StaticCache::cacheStore(), + $config, + )); + }); + } + + /** + * Claims the invalidator unless the host app points at a class of its own. + * + * The "of its own" test matters on upgrade: sites pin this by name, and a v1 + * site pinning ContentDependencyInvalidator must follow the addon forward + * rather than fataling on a class that no longer exists. A genuinely foreign + * subclass is still respected. + */ + private function registerInvalidator(): void + { + $configured = $this->app['config']->get('statamic.static_caching.invalidation.class'); + + if ($configured === null || $this->isOwnInvalidator((string) $configured)) { + $this->app['config']->set('statamic.static_caching.invalidation.class', GraphInvalidator::class); } /* * Contextual bindings are keyed on the exact concrete, so binding only - * this class leaves a host app that points the config at a subclass with - * an unresolvable $rules parameter. Bind the configured class too. + * our own class leaves a host app that points the config at a subclass + * with an unresolvable $rules parameter. Bind the configured class too. */ $concretes = array_unique(array_filter([ - ContentDependencyInvalidator::class, + GraphInvalidator::class, $this->app['config']->get('statamic.static_caching.invalidation.class'), ])); @@ -46,14 +229,82 @@ public function register(): void $this->app ->when($concrete) ->needs('$rules') - ->giveConfig('statamic.static_caching.invalidation.rules'); + // The default matters: a host app whose static_caching config + // predates the invalidation.rules key, or sets it to null, would + // otherwise resolve the invalidator with null and fatal. + ->giveConfig('statamic.static_caching.invalidation.rules', []); } } - public function bootAddon(): void + /** + * Deliberately in boot rather than register: Statamic's Stache provider binds + * EntryQueryBuilder unconditionally in its own register(), so a binding made + * during register() would be clobbered if our provider happened to run first. + * Boot runs after every register(), and nothing resolves a query builder, + * global set or form before a request or command is handled. + */ + private function registerReadRecorders(): void { - $this->publishes([ - __DIR__ . '/../config/cache_invalidation.php' => config_path('cache_invalidation.php'), - ], 'cache-invalidation-config'); + $recorder = fn (): DependencyRecorder => $this->app->make(DependencyRecorder::class); + $entries = fn (): Store => $this->app->make(Stache::class)->store('entries'); + + $builder = fn (): TrackingEntryQueryBuilder => new TrackingEntryQueryBuilder($entries(), $recorder()); + + // EntryRepository::query() resolves the contract; the concrete is bound + // too, in case anything resolves it directly. + $this->app->bind(EntryQueryBuilderContract::class, $builder); + $this->app->bind(EntryQueryBuilder::class, $builder); + + // Separates URL resolution from rendered output; see the class docblock. + Statamic::repository(EntryRepositoryContract::class, TrackingEntryRepository::class); + + // TermRepository::query() constructs its builder directly instead of + // resolving it, so the repository itself has to be replaced. + Statamic::repository(TermRepositoryContract::class, TrackingTermRepository::class); + + Statamic::repository(NavigationRepositoryContract::class, TrackingNavigationRepository::class); + Statamic::repository(NavTreeRepositoryContract::class, TrackingNavTreeRepository::class); + + // Statamic resolves tag classes through the container, so this reaches the + // nav tag without touching the tag registry. + $this->app->bind(NavTag::class, TrackingNavTag::class); + + // The global variables store builds its items with app(Variables::class). + $this->app->bind(VariablesContract::class, TrackingVariables::class); + + Statamic::repository(FormRepositoryContract::class, TrackingFormRepository::class); + } + + /** + * Invalidator classes this addon has shipped, including removed ones. + * + * An explicit list rather than a namespace prefix: a prefix would also claim + * any other class that happens to live under this namespace, which is broader + * than the intent and would silently override something it should not. + */ + private function isOwnInvalidator(string $class): bool + { + return in_array($class, [ + GraphInvalidator::class, + __NAMESPACE__ . '\ContentDependencyInvalidator', // v1, removed in 2.0 + ], true); + } + + private function graphDriver(): string + { + return (string) ($this->app['config']->get('cache_invalidation.driver') ?: 'sqlite'); + } + + /** + * Resolved rather than read straight from config, so nothing depends on the + * config namespace existing at the moment the graph is built. + */ + private function sqlitePath(): string + { + $path = $this->app['config']->get('cache_invalidation.sqlite_path'); + + return is_string($path) && $path !== '' + ? $path + : storage_path('statamic/cache-invalidation.sqlite'); } } diff --git a/src/StaticCacheFlusher.php b/src/StaticCacheFlusher.php deleted file mode 100644 index 430501d..0000000 --- a/src/StaticCacheFlusher.php +++ /dev/null @@ -1,28 +0,0 @@ -pagebuilder->clearIndex(); - } -} diff --git a/src/Tag.php b/src/Tag.php new file mode 100644 index 0000000..6a5effe --- /dev/null +++ b/src/Tag.php @@ -0,0 +1,64 @@ +cacher = app(Cacher::class); + $this->domain = $this->cacher->getBaseUrl(); + } + + #[Test] + public function it_records_a_custom_tag_against_the_current_render(): void + { + CacheTags::add('api:reviews'); + + $this->assertSame(['api:reviews'], $this->recorder->tags()); + } + + #[Test] + public function the_blade_directive_records_through_the_same_api(): void + { + $compiled = Blade::compileString("@cachetags('api:reviews', 'api:ratings')"); + + eval('?>'.$compiled); + + $this->assertSame(['api:reviews', 'api:ratings'], $this->recorder->tags()); + } + + #[Test] + public function it_clears_cached_pages_carrying_a_custom_tag(): void + { + $this->cache('/reviews', ['api:reviews']); + $this->cache('/ratings', ['api:ratings']); + + $cleared = CacheTags::invalidate('api:reviews'); + + $this->assertSame(1, $cleared); + $this->assertCachedIs(['/ratings']); + } + + #[Test] + public function it_accepts_several_tags_at_once_and_counts_each_url_once(): void + { + $this->cache('/both', ['api:reviews', 'api:ratings']); + $this->cache('/neither', ['api:other']); + + $this->assertSame(1, CacheTags::invalidate('api:reviews', 'api:ratings')); + $this->assertCachedIs(['/neither']); + } + + #[Test] + public function it_also_clears_pages_that_depend_on_everything(): void + { + $this->cache('/overflowed', [Tag::OVERFLOW]); + $this->cache('/unrelated', ['api:other']); + + CacheTags::invalidate('api:reviews'); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function it_works_with_built_in_tags_too(): void + { + $this->cache('/overview', ['collection:articles']); + $this->cache('/detail', ['entry:abc']); + + CacheTags::invalidate('collection:articles'); + + $this->assertCachedIs(['/detail']); + } + + #[Test] + public function it_leaves_untracked_urls_alone(): void + { + // Deliberate divergence from a content save. The untracked sweep exists so + // routine editing can never leave a page stale; folding it in here would + // make an explicit, targeted call clear everything right after a deploy. + $this->cache('/reviews', ['api:reviews']); + $this->cacher->cacheUrl(md5('/untracked'), '/untracked', $this->domain); + + CacheTags::invalidate('api:reviews'); + + $this->assertCachedIs(['/untracked']); + } + + #[Test] + public function invalidating_an_unknown_tag_clears_nothing(): void + { + $this->cache('/reviews', ['api:reviews']); + + $this->assertSame(0, CacheTags::invalidate('api:nothing')); + $this->assertCachedIs(['/reviews']); + } + + #[Test] + public function invalidating_no_tags_clears_nothing(): void + { + // Guards against an empty argument list matching the overflow tag and + // taking the whole cache with it. + $this->cache('/overflowed', [Tag::OVERFLOW]); + + $this->assertSame(0, CacheTags::invalidate()); + $this->assertSame(0, CacheTags::invalidate('')); + $this->assertCachedIs(['/overflowed']); + } + + #[Test] + public function it_refreshes_rather_than_purges_when_background_recache_is_on(): void + { + config(['statamic.static_caching.background_recache' => true]); + + $cacher = \Mockery::mock(Cacher::class); + $cacher->shouldReceive('refreshUrls')->once(); + $cacher->shouldNotReceive('invalidateUrls'); + + $tags = new \RoxDigital\CacheInvalidation\CacheTags($this->recorder, $this->graph, $cacher); + $this->graph->record($this->domain.'/reviews', ['api:reviews']); + + $this->assertSame(1, $tags->invalidate('api:reviews')); + } + + #[Test] + public function urls_for_reports_without_clearing(): void + { + $this->cache('/reviews', ['api:reviews']); + + $this->assertSame([$this->domain.'/reviews'], CacheTags::urlsFor('api:reviews')); + $this->assertCachedIs(['/reviews']); + } + + #[Test] + public function the_clear_command_reports_what_it_cleared(): void + { + $this->cache('/reviews', ['api:reviews']); + + $this->artisan('cache-invalidation:clear', ['tags' => ['api:reviews']]) + ->expectsOutputToContain($this->domain.'/reviews') + ->expectsOutputToContain('Cleared 1 cached URL(s).') + ->assertSuccessful(); + + $this->assertCachedIs([]); + } + + #[Test] + public function the_clear_command_says_so_when_nothing_matches(): void + { + $this->artisan('cache-invalidation:clear', ['tags' => ['api:nothing']]) + ->expectsOutputToContain('No cached page carries api:nothing.') + ->assertSuccessful(); + } + + /** + * @param list $tags + */ + private function cache(string $path, array $tags): void + { + $this->cacher->cacheUrl(md5($path), $path, $this->domain); + $this->graph->record($this->domain.$path, $tags); + } + + /** + * @param list $expected + */ + private function assertCachedIs(array $expected): void + { + $this->assertEqualsCanonicalizing( + $expected, + $this->cacher->getUrls($this->domain)->values()->all(), + ); + } +} diff --git a/tests/CachedConfigTest.php b/tests/CachedConfigTest.php new file mode 100644 index 0000000..e3d5fdc --- /dev/null +++ b/tests/CachedConfigTest.php @@ -0,0 +1,56 @@ +set('cache_invalidation', []); + app()->forgetInstance(DependencyGraph::class); + + $graph = app(DependencyGraph::class); + + $this->assertInstanceOf(SqliteGraph::class, $graph); + + $graph->record('https://site.test/a', ['entry:1']); + + $this->assertSame(['entry:1'], $graph->tagsFor('https://site.test/a')); + } + + #[Test] + public function the_sqlite_connection_is_registered_with_a_usable_path(): void + { + config()->set('cache_invalidation', []); + + $path = config('database.connections.'.SqliteGraph::CONNECTION.'.database'); + + $this->assertIsString($path); + $this->assertNotSame('', $path); + } + + #[Test] + public function the_commands_still_run_with_no_config(): void + { + config()->set('cache_invalidation', []); + app()->forgetInstance(DependencyGraph::class); + + $this->artisan('cache-invalidation:stats')->assertSuccessful(); + $this->artisan('cache-invalidation:doctor')->assertSuccessful(); + } +} diff --git a/tests/Cachers/RecordsDependenciesTest.php b/tests/Cachers/RecordsDependenciesTest.php new file mode 100644 index 0000000..f432d55 --- /dev/null +++ b/tests/Cachers/RecordsDependenciesTest.php @@ -0,0 +1,134 @@ +assertInstanceOf(TrackingApplicationCacher::class, $cacher); + + // Statamic's cache middleware branches on these to decide which status + // codes are cacheable and whether exclusions apply. A decorator would + // fail them silently, which is why the concrete cachers are subclassed. + $this->assertInstanceOf(ApplicationCacher::class, $cacher); + $this->assertInstanceOf(AbstractCacher::class, $cacher); + } + + #[Test] + public function the_full_measure_cacher_is_replaced_and_still_passes_statamics_type_checks(): void + { + $cacher = $this->fullMeasureCacher(); + + $this->assertInstanceOf(TrackingFileCacher::class, $cacher); + $this->assertInstanceOf(FileCacher::class, $cacher); + $this->assertInstanceOf(AbstractCacher::class, $cacher); + } + + #[Test] + public function caching_a_page_records_the_recorded_tags_against_its_url(): void + { + $cacher = app(Cacher::class); + $this->recorder->add('entry:1', 'global:footer'); + + $request = Request::create('http://localhost/about'); + $cacher->cachePage($request, new Response('')); + + $this->assertEqualsCanonicalizing( + ['entry:1', 'global:footer'], + $this->graph->tagsFor($cacher->getUrl($request)), + ); + } + + #[Test] + public function full_measure_records_against_the_same_url_as_half_measure(): void + { + // Both strategies must behave identically; this is the assertion that + // stops one driver silently keying rows differently from the other. + $half = app(Cacher::class); + $full = $this->fullMeasureCacher(); + + $request = Request::create('http://localhost/about'); + + $this->assertSame($half->getUrl($request), $full->getUrl($request)); + + $this->recorder->add('entry:1'); + $full->cachePage($request, new Response('')); + + $this->assertSame(['entry:1'], $this->graph->tagsFor($full->getUrl($request))); + } + + #[Test] + public function an_excluded_url_is_not_recorded(): void + { + config(['statamic.static_caching.exclude.urls' => ['/private']]); + + $cacher = app(Cacher::class); + $this->recorder->add('entry:1'); + + $request = Request::create('http://localhost/private'); + $cacher->cachePage($request, new Response('')); + + // An excluded URL is never cached, so it must not gain a row either — + // otherwise the graph would claim coverage it does not have. + $this->assertSame([], $this->graph->tagsFor($cacher->getUrl($request))); + } + + #[Test] + public function a_page_that_recorded_nothing_is_left_untracked(): void + { + $cacher = app(Cacher::class); + + $request = Request::create('http://localhost/empty'); + $cacher->cachePage($request, new Response('')); + + $url = $cacher->getUrl($request); + + $this->assertSame([], $this->graph->tagsFor($url)); + $this->assertSame([$url], $this->graph->untracked([$url])); + } + + #[Test] + public function recording_failure_does_not_break_the_page_render(): void + { + // A visitor's page must not 500 because the graph could not be written. + // The URL is then untracked, and the safety net clears it on the next save. + config(['cache_invalidation.sqlite_path' => '/nonexistent-directory/x/y.sqlite']); + app()->forgetInstance(\RoxDigital\CacheInvalidation\Graph\DependencyGraph::class); + + $cacher = app(Cacher::class); + $this->recorder->add('entry:1'); + + $cacher->cachePage(Request::create('http://localhost/about'), new Response('')); + + $this->assertTrue(true, 'cachePage completed despite an unwritable graph'); + } + + private function fullMeasureCacher(): Cacher + { + config([ + 'statamic.static_caching.strategy' => 'full', + 'statamic.static_caching.strategies.full.path' => __DIR__ . '/../__fixtures__/dev-null/static', + ]); + + app()->forgetInstance(\Statamic\StaticCaching\StaticCacheManager::class); + + return app(Cacher::class); + } +} diff --git a/tests/Doubles/HostInvalidator.php b/tests/Doubles/HostInvalidator.php new file mode 100644 index 0000000..c8077de --- /dev/null +++ b/tests/Doubles/HostInvalidator.php @@ -0,0 +1,14 @@ + 'nonexistent']); + + $this->graph->record('https://site.test/a', ['entry:1']); + + $this->assertSame(['entry:1'], $this->graph->tagsFor('https://site.test/a')); + $this->assertInstanceOf(SqliteGraph::class, $this->graph); + } + + #[Test] + public function it_finds_urls_by_any_of_their_tags(): void + { + $this->graph->record('https://site.test/a', ['entry:1', 'global:footer']); + $this->graph->record('https://site.test/b', ['entry:2', 'global:footer']); + $this->graph->record('https://site.test/c', ['entry:3']); + + $this->assertSame(['https://site.test/a'], $this->graph->urlsFor(['entry:1'])); + + $this->assertEqualsCanonicalizing( + ['https://site.test/a', 'https://site.test/b'], + $this->graph->urlsFor(['global:footer']), + ); + + // A URL matching more than one of the given tags appears once, and an + // unknown tag contributes nothing. + $matched = $this->graph->urlsFor(['entry:1', 'global:footer', 'entry:missing']); + + $this->assertEqualsCanonicalizing(['https://site.test/a', 'https://site.test/b'], $matched); + $this->assertSame(1, count(array_keys($matched, 'https://site.test/a', true))); + } + + #[Test] + public function recording_replaces_a_urls_previous_tags_rather_than_adding_to_them(): void + { + $this->graph->record('https://site.test/a', ['entry:1', 'entry:2']); + $this->graph->record('https://site.test/a', ['entry:3']); + + $this->assertSame(['entry:3'], $this->graph->tagsFor('https://site.test/a')); + $this->assertSame([], $this->graph->urlsFor(['entry:1'])); + } + + #[Test] + public function recording_an_empty_tag_set_leaves_the_url_untracked(): void + { + $this->graph->record('https://site.test/a', ['entry:1']); + $this->graph->record('https://site.test/a', []); + + $this->assertSame([], $this->graph->tagsFor('https://site.test/a')); + + // Untracked rather than "depends on nothing" — the safety net must still + // reach it, or a page whose recording failed would stay stale forever. + $this->assertSame( + ['https://site.test/a'], + $this->graph->untracked(['https://site.test/a']), + ); + } + + #[Test] + public function untracked_returns_only_urls_absent_from_the_graph(): void + { + $this->graph->record('https://site.test/known', ['entry:1']); + + $this->assertSame( + ['https://site.test/unknown'], + $this->graph->untracked(['https://site.test/known', 'https://site.test/unknown']), + ); + + $this->assertSame([], $this->graph->untracked([])); + } + + #[Test] + public function it_distinguishes_urls_that_differ_only_in_their_query_string(): void + { + // Identity is a hash, so a truncating index would collapse these. + $long = 'https://site.test/'.str_repeat('a', 200); + + $this->graph->record($long.'?page=1', ['entry:1']); + $this->graph->record($long.'?page=2', ['entry:2']); + + $this->assertSame(['entry:1'], $this->graph->tagsFor($long.'?page=1')); + $this->assertSame(['entry:2'], $this->graph->tagsFor($long.'?page=2')); + } + + #[Test] + public function it_handles_more_tags_than_fit_in_one_insert(): void + { + $tags = array_map(fn (int $i): string => "entry:{$i}", range(1, 1200)); + + $this->graph->record('https://site.test/big', $tags); + + $this->assertCount(1200, $this->graph->tagsFor('https://site.test/big')); + $this->assertSame(['https://site.test/big'], $this->graph->urlsFor(['entry:1200'])); + } + + #[Test] + public function forget_and_flush_remove_rows(): void + { + $this->graph->record('https://site.test/a', ['entry:1']); + $this->graph->record('https://site.test/b', ['entry:2']); + + $this->graph->forget('https://site.test/a'); + $this->assertSame(['https://site.test/b'], $this->graph->urls()); + + $this->graph->flush(); + $this->assertSame([], $this->graph->urls()); + $this->assertSame(['urls' => 0, 'tags' => 0, 'rows' => 0], $this->graph->stats()); + } +} diff --git a/tests/Invalidation/InvalidatesByDependencyTest.php b/tests/Invalidation/InvalidatesByDependencyTest.php new file mode 100644 index 0000000..cddee20 --- /dev/null +++ b/tests/Invalidation/InvalidatesByDependencyTest.php @@ -0,0 +1,285 @@ +cacher = app(Cacher::class); + $this->domain = $this->cacher->getBaseUrl(); + + Collection::make('articles')->save(); + Collection::make('pages')->save(); + } + + #[Test] + public function saving_an_entry_clears_pages_that_rendered_it_and_leaves_others(): void + { + $entry = $this->article('one'); + + $this->cache('/uses-it', ["entry:{$entry->id()}"]); + $this->cache('/unrelated', ['entry:something-else']); + + $entry->data(['title' => 'Changed'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function saving_an_entry_clears_pages_that_listed_its_collection(): void + { + $entry = $this->article('one'); + + $this->cache('/overview', ['collection:articles']); + $this->cache('/unrelated', ['collection:pages']); + + $entry->data(['title' => 'Changed'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function creating_an_entry_clears_listings_that_could_never_have_known_its_id(): void + { + // The reason list tags exist. A brand new entry's id appears in no recorded + // tag set, so only the collection tag can reach the pages that list it. + $this->cache('/overview', ['collection:articles']); + $this->cache('/detail', ['entry:some-existing-id']); + + $this->article('brand-new'); + + $this->assertCachedIs(['/detail']); + } + + #[Test] + public function a_page_embedding_one_reusable_block_is_unaffected_by_another(): void + { + Collection::make('reusable_blocks')->save(); + + $a = tap(Entry::make()->collection('reusable_blocks')->slug('a')->data(['title' => 'A']))->save(); + $b = tap(Entry::make()->collection('reusable_blocks')->slug('b')->data(['title' => 'B']))->save(); + + $this->cache('/uses-a-1', ["entry:{$a->id()}"]); + $this->cache('/uses-a-2', ["entry:{$a->id()}"]); + $this->cache('/uses-b', ["entry:{$b->id()}"]); + + $a->data(['title' => 'A changed'])->save(); + + $this->assertCachedIs(['/uses-b']); + } + + #[Test] + public function a_transitive_dependency_clears_the_embedding_page(): void + { + // Page C embeds a reusable block that pulls in a global and another entry. + // All three land in C's tag set, so any of them clears it. + $this->makeGlobalSet('footer', ['phone' => '123']); + + $block = tap(Entry::make()->collection('pages')->slug('block')->data(['title' => 'Block']))->save(); + $inner = $this->article('inner'); + + $this->cache('/page-c', ["entry:{$block->id()}", 'global:footer', "entry:{$inner->id()}"]); + $this->cache('/page-d', ['entry:unrelated']); + + GlobalSet::find('footer')->in('default')->data(['phone' => '456'])->save(); + + $this->assertCachedIs(['/page-d']); + } + + #[Test] + public function saving_a_global_clears_only_pages_that_read_it(): void + { + $this->makeGlobalSet('footer', ['phone' => '123']); + $this->makeGlobalSet('seo', ['title' => 'Site']); + + $this->cache('/reads-footer', ['global:footer']); + $this->cache('/reads-seo', ['global:seo']); + + GlobalSet::find('footer')->in('default')->data(['phone' => '456'])->save(); + + $this->assertCachedIs(['/reads-seo']); + } + + #[Test] + public function saving_a_term_clears_pages_that_rendered_it_or_queried_its_taxonomy(): void + { + Taxonomy::make('topics')->save(); + $term = tap(Term::make('news')->taxonomy('topics')->data(['title' => 'News']))->save(); + + $this->cache('/shows-term', ['term:topics::news']); + $this->cache('/lists-taxonomy', ['taxonomy:topics']); + $this->cache('/unrelated', ['taxonomy:other']); + + $term->in('default')->data(['title' => 'Nieuws'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function saving_a_form_clears_only_pages_rendering_it(): void + { + Form::make('contact')->save(); + Form::make('newsletter')->save(); + + $this->cache('/contact', ['form:contact']); + $this->cache('/signup', ['form:newsletter']); + + Form::find('contact')->title('Contact us')->save(); + + $this->assertCachedIs(['/signup']); + } + + #[Test] + public function saving_a_navigation_clears_only_the_pages_that_render_it(): void + { + Nav::make('footer_nav')->title('Footer')->save(); + Nav::make('sidebar_nav')->title('Sidebar')->save(); + + $this->cache('/has-footer-nav', ['nav:footer_nav']); + $this->cache('/has-sidebar-nav', ['nav:sidebar_nav']); + + Nav::find('footer_nav')->title('Footer links')->save(); + + $this->assertCachedIs(['/has-sidebar-nav']); + } + + #[Test] + public function a_navigation_in_the_shared_layout_still_clears_everything(): void + { + // Not a special case any more: a nav rendered on every page is recorded on + // every page, so "clear all" falls out of where it is used rather than + // being hardcoded. + Nav::make('main_nav')->title('Main')->save(); + + $this->cache('/a', ['nav:main_nav', 'entry:1']); + $this->cache('/b', ['nav:main_nav', 'entry:2']); + + Nav::find('main_nav')->title('Main nav')->save(); + + $this->assertCachedIs([]); + } + + #[Test] + public function an_untracked_cached_url_is_cleared_by_any_save(): void + { + $entry = $this->article('one'); + + $this->cacheWithoutRecording('/untracked'); + $this->cache('/tracked', ['entry:unrelated']); + + $entry->data(['title' => 'Changed'])->save(); + + $this->assertCachedIs(['/tracked']); + } + + #[Test] + public function a_page_marked_as_depending_on_everything_is_always_cleared(): void + { + $entry = $this->article('one'); + + $this->cache('/overflowed', [Tag::OVERFLOW]); + $this->cache('/tracked', ['entry:unrelated']); + + $entry->data(['title' => 'Changed'])->save(); + + $this->assertCachedIs(['/tracked']); + } + + #[Test] + public function saving_an_entry_clears_its_own_url(): void + { + Collection::make('articles')->routes('/articles/{slug}')->save(); + $entry = $this->article('one'); + + $this->cache('/articles/one', []); + $this->graph->record($this->domain . '/articles/one', ['entry:something-else']); + $this->cache('/tracked', ['entry:unrelated']); + + $entry->data(['title' => 'Changed'])->save(); + + // Statamic resolves the entry's own URL and descendants; the graph only has + // to answer for pages that referenced it. + $this->assertCachedIs(['/tracked']); + } + + #[Test] + public function a_flush_clears_the_graph_as_well(): void + { + $this->cache('/a', ['entry:1']); + + \Statamic\Facades\StaticCache::flush(); + + $this->assertSame([], $this->graph->urls()); + } + + private function article(string $slug): \Statamic\Contracts\Entries\Entry + { + return tap(Entry::make()->collection('articles')->slug($slug)->data(['title' => ucfirst($slug)]))->save(); + } + + /** + * @param array $data + */ + private function makeGlobalSet(string $handle, array $data): void + { + $set = tap(GlobalSet::make($handle))->save(); + $set->makeLocalization('default')->data($data)->save(); + } + + /** + * @param list $tags + */ + private function cache(string $path, array $tags): void + { + $this->cacheWithoutRecording($path); + + if ($tags !== []) { + $this->graph->record($this->domain . $path, $tags); + } + } + + private function cacheWithoutRecording(string $path): void + { + $this->cacher->cacheUrl(md5($path), $path, $this->domain); + } + + /** + * @param list $expected + */ + private function assertCachedIs(array $expected): void + { + $this->assertEqualsCanonicalizing( + $expected, + $this->cacher->getUrls($this->domain)->values()->all(), + ); + } +} diff --git a/tests/Invalidation/InvalidatorWiringTest.php b/tests/Invalidation/InvalidatorWiringTest.php new file mode 100644 index 0000000..721b07f --- /dev/null +++ b/tests/Invalidation/InvalidatorWiringTest.php @@ -0,0 +1,179 @@ +set('statamic.static_caching.invalidation.class', static::$pin); + } + } + + #[Test] + public function it_claims_the_invalidator_when_the_config_leaves_it_unset(): void + { + $this->assertInstanceOf(GraphInvalidator::class, app(Invalidator::class)); + } + + #[Test] + public function it_upgrades_a_pin_at_one_of_its_own_removed_classes(): void + { + // Sites pin this by name. A 1.x site pinning ContentDependencyInvalidator + // would otherwise fatal on a class that no longer exists — or, before the + // class was removed, silently keep the old behaviour after an upgrade. + static::$pin = 'RoxDigital\CacheInvalidation\ContentDependencyInvalidator'; + $this->refreshApplication(); + + $this->assertSame( + GraphInvalidator::class, + config('statamic.static_caching.invalidation.class'), + ); + } + + #[Test] + public function it_respects_an_invalidator_belonging_to_the_host_app(): void + { + static::$pin = HostInvalidator::class; + $this->refreshApplication(); + + $this->assertSame(HostInvalidator::class, config('statamic.static_caching.invalidation.class')); + $this->assertInstanceOf(HostInvalidator::class, app(Invalidator::class)); + } + + #[Test] + public function it_resolves_the_invalidator_when_the_host_config_has_no_rules_key(): void + { + // Contextual $rules bindings resolve from config, which returns null when + // the key is missing. Without a default that fatals on a TypeError. + config(['statamic.static_caching.invalidation.rules' => null]); + app()->forgetInstance(Invalidator::class); + + $this->assertInstanceOf(GraphInvalidator::class, app(Invalidator::class)); + } + + #[Test] + public function it_refreshes_rather_than_purges_when_background_recache_is_on(): void + { + // DefaultInvalidator::refresh() flips a protected flag and delegates to + // invalidate(). v1 overrode invalidate() without checking it and always + // hard-purged, which silently broke background_recache. + config(['statamic.static_caching.background_recache' => true]); + + Collection::make('articles')->save(); + $entry = tap(Entry::make()->collection('articles')->slug('one')->data(['title' => 'One']))->save(); + + $cacher = Mockery::mock(Cacher::class); + $cacher->shouldReceive('refreshUrls')->once(); + $cacher->shouldNotReceive('invalidateUrls'); + + $invalidator = new GraphInvalidator( + $cacher, + [], + $this->graph, + app(TagResolver::class), + new CachedUrls($cacher), + ); + + $this->graph->record('https://site.test/a', ["entry:{$entry->id()}"]); + + $invalidator->refresh($entry); + } + + #[Test] + public function it_purges_when_background_recache_is_off(): void + { + config(['statamic.static_caching.background_recache' => false]); + + Collection::make('articles')->save(); + $entry = tap(Entry::make()->collection('articles')->slug('one')->data(['title' => 'One']))->save(); + + $cacher = Mockery::mock(Cacher::class); + $cacher->shouldReceive('invalidateUrls')->once(); + $cacher->shouldNotReceive('refreshUrls'); + + $invalidator = new GraphInvalidator( + $cacher, + [], + $this->graph, + app(TagResolver::class), + new CachedUrls($cacher), + ); + + $this->graph->record('https://site.test/a', ["entry:{$entry->id()}"]); + + $invalidator->refresh($entry); + } + + #[Test] + public function it_resolves_the_configured_graph_driver(): void + { + $this->assertInstanceOf(SqliteGraph::class, app(DependencyGraph::class)); + + config(['cache_invalidation.driver' => 'null']); + app()->forgetInstance(DependencyGraph::class); + $this->assertInstanceOf(NullGraph::class, app(DependencyGraph::class)); + + config(['cache_invalidation.driver' => 'database']); + app()->forgetInstance(DependencyGraph::class); + $this->assertInstanceOf(DatabaseGraph::class, app(DependencyGraph::class)); + } + + #[Test] + public function the_null_driver_treats_every_cached_url_as_untracked(): void + { + // Which is what makes it behave as "clear everything on every save". + $graph = new NullGraph; + + $this->assertSame(['a', 'b'], $graph->untracked(['a', 'b'])); + } + + #[Test] + public function the_recorder_is_reset_between_queued_jobs(): void + { + // A worker keeps the container alive across jobs, so without this the tag + // set would grow until it overflowed and every page would look like it + // depended on everything. + $this->recorder->add('entry:stale'); + + $job = Mockery::mock(\Illuminate\Contracts\Queue\Job::class); + $job->shouldReceive('payload')->andReturn([]); + + event(new JobProcessing('sync', $job)); + + $this->assertSame([], app(DependencyRecorder::class)->tags()); + } +} diff --git a/tests/Recording/DataAccessPathsTest.php b/tests/Recording/DataAccessPathsTest.php new file mode 100644 index 0000000..4ad58d2 --- /dev/null +++ b/tests/Recording/DataAccessPathsTest.php @@ -0,0 +1,126 @@ +save(); + $this->entryId = tap(Entry::make()->collection('articles')->slug('one') + ->data(['title' => 'One']))->save()->id(); + + Taxonomy::make('topics')->save(); + Term::make('news')->taxonomy('topics')->data(['title' => 'News'])->save(); + + Form::make('contact')->save(); + + $set = tap(GlobalSet::make('footer'))->save(); + $set->makeLocalization('default')->data(['phone' => '123'])->save(); + } + + #[Test] + public function a_plain_php_query_is_recorded(): void + { + // The @php block or view model case. + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->where('collection', 'articles')->get(), + ); + + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->entryId}", $tags); + } + + #[Test] + public function the_antlers_collection_tag_is_recorded(): void + { + $tags = $this->tagsRecordedDuring(fn () => Statamic::tag('collection:articles')->fetch()); + + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->entryId}", $tags); + } + + #[Test] + public function the_antlers_taxonomy_tag_is_recorded(): void + { + $tags = $this->tagsRecordedDuring(fn () => Statamic::tag('taxonomy:topics')->fetch()); + + $this->assertContains('taxonomy:topics', $tags); + $this->assertContains('term:topics::news', $tags); + } + + #[Test] + public function a_globals_read_through_the_cascade_is_recorded(): void + { + // How a Blade layout reaches a global: {{ $footer->phone }}. + $tags = $this->tagsRecordedDuring(function (): void { + GlobalSet::find('footer')->in('default')->phone; + }); + + $this->assertSame(['global:footer'], $tags); + } + + #[Test] + public function an_augmented_field_is_recorded(): void + { + // How a pagebuilder block reaches a relation: $block->entry. + $this->blueprint('collections.pages', 'page', [ + 'related' => ['type' => 'entries', 'collections' => ['articles']], + ]); + + Collection::make('pages')->save(); + $page = tap(Entry::make()->collection('pages')->slug('home') + ->data(['title' => 'Home', 'related' => [$this->entryId]]))->save(); + + $tags = $this->tagsRecordedDuring(function () use ($page): void { + // An entries field augments to a lazy query builder, so it has to be + // run — which is exactly what iterating it in a template does. + $page->augmentedValue('related')->value()->get()->first()?->id(); + }); + + $this->assertContains("entry:{$this->entryId}", $tags); + } + + #[Test] + public function the_form_tag_records_the_form(): void + { + $tags = $this->tagsRecordedDuring(fn () => Statamic::tag('form:create')->params(['in' => 'contact'])->fetch()); + + $this->assertContains('form:contact', $tags); + } + + #[Test] + public function reading_content_outside_a_repository_is_not_recorded(): void + { + // The boundary, stated as a test: recording follows the repositories, so + // anything that sidesteps them — an HTTP call, a file, a custom model — is + // invisible and needs CacheTags::add(). This is what @cachetags is for. + $tags = $this->tagsRecordedDuring(function (): void { + json_decode('{"reviews": 5}', true); + }); + + $this->assertSame([], $tags); + } +} diff --git a/tests/Recording/DependencyRecorderTest.php b/tests/Recording/DependencyRecorderTest.php new file mode 100644 index 0000000..7a845b6 --- /dev/null +++ b/tests/Recording/DependencyRecorderTest.php @@ -0,0 +1,123 @@ +recorder->add('entry:1', 'entry:1', '', 'entry:2'); + + $this->assertSame(['entry:1', 'entry:2'], $this->recorder->tags()); + } + + #[Test] + public function reads_accumulate_flatly_regardless_of_nesting(): void + { + // There is no notion of "which block am I inside": a reusable block's reads + // are the embedding page's reads, at any depth. This is what makes + // transitive dependencies work without expansion logic. + $this->recorder->entries(['a']); + $this->recorder->globalSet('footer'); + $this->recorder->entries(['b']); + $this->recorder->form('contact'); + + $this->assertSame( + ['entry:a', 'global:footer', 'entry:b', 'form:contact'], + $this->recorder->tags(), + ); + } + + #[Test] + public function suppressed_drops_everything_recorded_inside_it(): void + { + $this->recorder->add('entry:before'); + + $returned = $this->recorder->suppressed(function (): string { + $this->recorder->add('entry:inside'); + $this->recorder->collections(['pages']); + + return 'result'; + }); + + $this->recorder->add('entry:after'); + + $this->assertSame('result', $returned, 'the callback result is passed through'); + $this->assertSame(['entry:before', 'entry:after'], $this->recorder->tags()); + } + + #[Test] + public function suppression_is_lifted_even_when_the_callback_throws(): void + { + try { + $this->recorder->suppressed(fn () => throw new \RuntimeException('boom')); + } catch (\RuntimeException) { + // expected + } + + $this->recorder->add('entry:after'); + + $this->assertSame(['entry:after'], $this->recorder->tags()); + } + + #[Test] + public function suppression_nests(): void + { + $this->recorder->suppressed(function (): void { + $this->recorder->suppressed(fn () => $this->recorder->add('entry:inner')); + $this->recorder->add('entry:outer'); + }); + + $this->recorder->add('entry:after'); + + $this->assertSame(['entry:after'], $this->recorder->tags()); + } + + #[Test] + public function it_collapses_to_the_overflow_tag_past_the_cap(): void + { + $this->recorder->entries(array_map(fn (int $i): string => (string) $i, range(1, 2_500))); + + // Lossy in the safe direction: the page is now cleared by any content + // save, rather than silently keeping a truncated tag set that would leave + // it stale. + $this->assertSame([Tag::OVERFLOW], $this->recorder->tags()); + $this->assertTrue($this->recorder->overflowed()); + } + + #[Test] + public function an_overflowed_set_stays_overflowed(): void + { + $this->recorder->entries(array_map(fn (int $i): string => (string) $i, range(1, 2_500))); + $this->recorder->globalSet('footer'); + + $this->assertSame([Tag::OVERFLOW], $this->recorder->tags()); + } + + #[Test] + public function reset_clears_the_overflow_flag_too(): void + { + $this->recorder->entries(array_map(fn (int $i): string => (string) $i, range(1, 2_500))); + $this->recorder->reset(); + $this->recorder->entries(['a']); + + $this->assertSame(['entry:a'], $this->recorder->tags()); + $this->assertFalse($this->recorder->overflowed()); + } + + #[Test] + public function it_ignores_non_string_and_blank_identifiers(): void + { + $this->recorder->entries(['a', null, 42, '']); + $this->recorder->collections(['pages', null, '']); + + $this->assertSame(['entry:a', 'collection:pages'], $this->recorder->tags()); + } +} diff --git a/tests/Recording/GlobalsTermsAndFormsTest.php b/tests/Recording/GlobalsTermsAndFormsTest.php new file mode 100644 index 0000000..552b3ab --- /dev/null +++ b/tests/Recording/GlobalsTermsAndFormsTest.php @@ -0,0 +1,116 @@ +makeGlobalSet('footer', ['phone' => '123']); + + // Statamic hydrates every global set into every view whether a template + // touches it or not. Recording at hydration would mark every page as + // depending on every global, which is the behaviour this replaces. + $onFetch = $this->tagsRecordedDuring(fn () => GlobalSet::find('footer')->in('default')); + + $this->assertSame([], $onFetch, 'fetching a global set must not record it'); + + $onRead = $this->tagsRecordedDuring(function (): void { + $variables = GlobalSet::find('footer')->in('default'); + $variables->phone; + }); + + $this->assertSame(['global:footer'], $onRead); + } + + #[Test] + public function reading_one_global_does_not_record_another(): void + { + $this->makeGlobalSet('footer', ['phone' => '123']); + $this->makeGlobalSet('seo', ['title' => 'Site']); + + $tags = $this->tagsRecordedDuring(function (): void { + GlobalSet::find('footer')->in('default')->phone; + }); + + $this->assertSame(['global:footer'], $tags); + $this->assertNotContains('global:seo', $tags); + } + + #[Test] + public function a_taxonomy_query_records_the_taxonomy_and_each_term(): void + { + Taxonomy::make('topics')->save(); + Term::make('news')->taxonomy('topics')->data(['title' => 'News'])->save(); + Term::make('events')->taxonomy('topics')->data(['title' => 'Events'])->save(); + + $tags = $this->tagsRecordedDuring(fn () => Term::query()->where('taxonomy', 'topics')->get()); + + $this->assertContains('taxonomy:topics', $tags); + $this->assertContains('term:topics::news', $tags); + $this->assertContains('term:topics::events', $tags); + } + + #[Test] + public function finding_a_term_by_id_records_only_that_term(): void + { + Taxonomy::make('topics')->save(); + Term::make('news')->taxonomy('topics')->data(['title' => 'News'])->save(); + Term::make('events')->taxonomy('topics')->data(['title' => 'Events'])->save(); + + $tags = $this->tagsRecordedDuring(fn () => Term::find('topics::news')); + + $this->assertSame(['term:topics::news'], $tags); + $this->assertNotContains('taxonomy:topics', $tags); + } + + #[Test] + public function resolving_a_form_records_it(): void + { + Form::make('contact')->save(); + + $tags = $this->tagsRecordedDuring(fn () => Form::find('contact')); + + $this->assertSame(['form:contact'], $tags); + } + + #[Test] + public function listing_every_form_records_nothing(): void + { + Form::make('contact')->save(); + Form::make('newsletter')->save(); + + // FormRepository::all() resolves through self::find(), binding to the + // parent class. That is deliberate: all() is control panel territory and + // must not mark a page as depending on every form on the site. + $tags = $this->tagsRecordedDuring(fn () => Form::all()); + + $this->assertSame([], $tags); + } + + #[Test] + public function resolving_a_missing_form_records_nothing(): void + { + $this->assertSame([], $this->tagsRecordedDuring(fn () => Form::find('nope'))); + } + + /** + * @param array $data + */ + private function makeGlobalSet(string $handle, array $data): void + { + $set = tap(GlobalSet::make($handle))->save(); + + $set->makeLocalization('default')->data($data)->save(); + } +} diff --git a/tests/Recording/ItemAndListTagsTest.php b/tests/Recording/ItemAndListTagsTest.php new file mode 100644 index 0000000..a3deb5b --- /dev/null +++ b/tests/Recording/ItemAndListTagsTest.php @@ -0,0 +1,180 @@ +save(); + Collection::make('authors')->save(); + + $author = tap(Entry::make()->collection('authors')->slug('ada')->data(['title' => 'Ada']))->save(); + + $this->one = tap(Entry::make()->collection('articles')->slug('one') + ->data(['title' => 'One', 'author' => $author->id()]))->save()->id(); + + $this->two = tap(Entry::make()->collection('articles')->slug('two') + ->data(['title' => 'Two', 'author' => $author->id()]))->save()->id(); + } + + #[Test] + public function finding_an_entry_by_id_records_only_that_entry(): void + { + $tags = $this->tagsRecordedDuring(fn () => Entry::find($this->one)); + + $this->assertSame(["entry:{$this->one}"], $tags); + } + + #[Test] + public function an_augmented_entries_field_records_only_the_referenced_entry(): void + { + // The reusable-block case, and the one that regressed. Augmentation runs + // through OrderedQueryBuilder + StatusQueryBuilder, and the status filter + // back-fills the collection from the queried ids before adding a nested + // clause — which must not be mistaken for a list query. + $this->blueprint('collections.pages', 'page', [ + 'block' => ['type' => 'entries', 'max_items' => 1, 'collections' => ['articles']], + ]); + + Collection::make('pages')->save(); + + $page = tap(Entry::make()->collection('pages')->slug('home') + ->data(['title' => 'Home', 'block' => [$this->one]]))->save(); + + $tags = $this->tagsRecordedDuring(function () use ($page): void { + $resolved = $page->augmentedValue('block')->value(); + + // Force the lazy query builder to run, as rendering would. + $resolved instanceof \Statamic\Contracts\Entries\Entry ? $resolved->id() : collect($resolved)->first(); + }); + + $this->assertContains("entry:{$this->one}", $tags); + $this->assertNotContains('collection:articles', $tags, 'an id-pinned lookup must not record a list tag'); + } + + #[Test] + public function an_unfiltered_collection_query_records_the_list_tag_and_every_entry(): void + { + $tags = $this->tagsRecordedDuring(fn () => Entry::query()->where('collection', 'articles')->get()); + + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->one}", $tags); + $this->assertContains("entry:{$this->two}", $tags); + } + + #[Test] + public function a_limited_query_records_the_list_tag_and_only_the_entries_it_returned(): void + { + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->where('collection', 'articles')->orderBy('slug', 'asc')->limit(1)->get(), + ); + + // The list tag is what clears this page when a third article is created; + // its id could not possibly be in the recorded set. + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->one}", $tags); + $this->assertNotContains("entry:{$this->two}", $tags); + } + + #[Test] + public function a_query_filtered_on_a_normal_field_records_the_list_tag(): void + { + $author = Entry::query()->where('collection', 'authors')->first(); + + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->where('collection', 'articles')->where('author', $author->id())->get(), + ); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function count_records_the_list_tag(): void + { + // count() bypasses get() entirely in Stache\Query\Builder, so a recorder + // hooked on get() alone would silently miss it. + $tags = $this->tagsRecordedDuring(fn () => Entry::query()->where('collection', 'articles')->count()); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function pluck_records_the_list_tag(): void + { + $tags = $this->tagsRecordedDuring(fn () => Entry::query()->where('collection', 'articles')->pluck('title')); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function whereIn_on_ids_records_only_those_entries(): void + { + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->whereIn('id', [$this->one, $this->two])->get(), + ); + + $this->assertEqualsCanonicalizing(["entry:{$this->one}", "entry:{$this->two}"], $tags); + } + + #[Test] + public function excluding_ids_records_the_list_tag(): void + { + // whereNotIn excludes ids rather than bounding results to them, so an + // entry created later would appear and the page must react to it. + $tags = $this->tagsRecordedDuring( + fn () => Entry::query()->where('collection', 'articles')->whereNotIn('id', [$this->one])->get(), + ); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function an_or_clause_alongside_an_id_records_the_list_tag(): void + { + // An OR can admit rows from outside the id set, so the query is no longer + // bounded by it. + $tags = $this->tagsRecordedDuring( + fn () => Entry::query() + ->where('collection', 'articles') + ->where('id', $this->one) + ->orWhere('title', 'Two') + ->get(), + ); + + $this->assertContains('collection:articles', $tags); + } + + #[Test] + public function an_unscoped_query_records_every_collection(): void + { + $tags = $this->tagsRecordedDuring(fn () => Entry::query()->where('title', 'One')->get()); + + $this->assertContains('collection:articles', $tags); + $this->assertContains('collection:authors', $tags); + } +} diff --git a/tests/Recording/NavigationTest.php b/tests/Recording/NavigationTest.php new file mode 100644 index 0000000..3621789 --- /dev/null +++ b/tests/Recording/NavigationTest.php @@ -0,0 +1,95 @@ +title('Main')->save(); + Nav::make('footer_nav')->title('Footer')->save(); + } + + #[Test] + public function resolving_a_nav_by_handle_records_it(): void + { + // Statamic's nav tag reaches this through Tags\Structure::structure(). + $tags = $this->tagsRecordedDuring(fn () => Nav::findByHandle('main_nav')); + + $this->assertSame(['nav:main_nav'], $tags); + } + + #[Test] + public function find_records_it_too(): void + { + $this->assertSame(['nav:main_nav'], $this->tagsRecordedDuring(fn () => Nav::find('main_nav'))); + } + + #[Test] + public function resolving_one_nav_does_not_record_another(): void + { + $tags = $this->tagsRecordedDuring(fn () => Nav::findByHandle('footer_nav')); + + $this->assertSame(['nav:footer_nav'], $tags); + $this->assertNotContains('nav:main_nav', $tags); + } + + #[Test] + public function resolving_a_missing_nav_records_nothing(): void + { + $this->assertSame([], $this->tagsRecordedDuring(fn () => Nav::findByHandle('nope'))); + } + + #[Test] + public function listing_every_nav_records_all_of_them(): void + { + // Over-recording on purpose. A nav that goes unrecorded means a visitor + // keeps seeing a removed menu item, which is the one failure that shows; + // recording too many only clears more pages than strictly necessary. + $tags = $this->tagsRecordedDuring(fn () => Nav::all()); + + $this->assertContains('nav:main_nav', $tags); + $this->assertContains('nav:footer_nav', $tags); + } + + #[Test] + public function rendering_a_nav_records_the_nav_and_not_the_pages_it_links_to(): void + { + Collection::make('pages')->save(); + $linked = tap(Entry::make()->collection('pages')->slug('about')->data(['title' => 'About']))->save(); + + Nav::find('main_nav')->makeTree('default', [['entry' => $linked->id()]])->save(); + + $tags = $this->tagsRecordedDuring(fn () => Statamic::tag('nav:main_nav')->fetch()); + + // Statamic's TreeBuilder resolves every linked entry to build the menu. + // Recording those put an entry tag for each menu item on every page using + // the nav, so renaming any page in the menu cleared the whole site. + $this->assertContains('nav:main_nav', $tags); + $this->assertNotContains("entry:{$linked->id()}", $tags); + $this->assertNotContains('collection:pages', $tags); + } + + #[Test] + public function resolving_a_nav_tree_records_the_nav(): void + { + // A second, independent recording point, for a template that already holds + // a Nav object and never goes back through the repository. + $tags = $this->tagsRecordedDuring(function (): void { + Nav::findByHandle('main_nav')->in('default'); + }); + + $this->assertContains('nav:main_nav', $tags); + } +} diff --git a/tests/RendersAndRecordsTest.php b/tests/RendersAndRecordsTest.php new file mode 100644 index 0000000..250a17d --- /dev/null +++ b/tests/RendersAndRecordsTest.php @@ -0,0 +1,181 @@ +save(); + + $this->listed = tap(Entry::make()->collection('articles')->slug('listed') + ->data(['title' => 'Listed article']))->save()->id(); + + $set = tap(GlobalSet::make('footer'))->save(); + $set->makeLocalization('default')->data(['phone' => '0123'])->save(); + + // Structured, like a real pages collection. That matters: findByUri() + // consults the tree for a structured collection, and Tree::tree() runs + // validateTree(), which plucks the whole collection. + $pages = Collection::make('pages') + ->routes('/{slug}') + ->template('entry') + ->structureContents(['root' => false]); + $pages->save(); + + $about = Entry::make()->collection('pages')->slug('about')->data(['title' => 'About us']); + $about->save(); + + // In a structured collection the URI comes from the tree, not the route. + $pages->structure()->in('default')->tree([['entry' => $about->id()]])->save(); + } + + protected function resolveApplicationConfiguration($app): void + { + parent::resolveApplicationConfiguration($app); + + $app['config']->set('view.paths', [__DIR__ . '/__fixtures__/views']); + } + + #[Test] + public function rendering_a_page_records_everything_the_template_read(): void + { + $response = $this->get('/about'); + + $response->assertOk(); + $response->assertSee('About us'); + $response->assertSee('Listed article'); + $response->assertSee('0123'); + + $tags = $this->graph->tagsFor($this->urlFor('/about')); + + // The page's own entry, resolved by URI. + $about = Entry::query()->where('collection', 'pages')->first(); + $this->assertContains("entry:{$about->id()}", $tags); + + // The listing it rendered: both the collection and the entry it showed. + $this->assertContains('collection:articles', $tags); + $this->assertContains("entry:{$this->listed}", $tags); + + // The global whose value it printed. + $this->assertContains('global:footer', $tags); + } + + #[Test] + public function a_rendered_page_is_then_cleared_by_saving_what_it_read(): void + { + $this->get('/about')->assertOk(); + + $cacher = app(Cacher::class); + $this->assertNotEmpty($cacher->getUrls()->all(), 'the page should be in the static cache'); + + // A save of the listed article, going through Statamic's own event chain. + Entry::find($this->listed)->data(['title' => 'Renamed'])->save(); + + $this->assertSame([], $cacher->getUrls()->values()->all()); + } + + #[Test] + public function resolving_a_url_in_a_structured_collection_records_only_the_entry(): void + { + // findByUri() consults the collection tree, and Tree::tree() runs + // validateTree(), which plucks every entry in the collection. Recording + // that made every page depend on its whole collection, so saving any one + // page cleared every cached page on the site. + // + // Blink is flushed first because the tree is memoised per process: without + // this the tree built during setUp is reused and the pluck never runs, + // which is exactly why an earlier version of this test passed against the + // bug it was written for. + Blink::flush(); + + $tags = $this->tagsRecordedDuring(fn () => Entry::findByUri('/about')); + + $about = Entry::query()->where('collection', 'pages')->first(); + + $this->assertSame(["entry:{$about->id()}"], $tags); + } + + #[Test] + public function rendering_a_page_does_not_record_collections_it_never_touched(): void + { + // Statamic resolves every frontend request through findByUri(), which + // queries `uri` with no collection scope. Treating that as a set query made + // every rendered page depend on every collection on the site, quietly + // degrading the addon to "clear everything on any entry save". + Collection::make('unrelated')->save(); + Collection::make('another')->save(); + + $this->get('/about')->assertOk(); + + $tags = $this->graph->tagsFor($this->urlFor('/about')); + + $this->assertContains('collection:articles', $tags, 'the collection it listed'); + $this->assertNotContains('collection:unrelated', $tags); + $this->assertNotContains('collection:another', $tags); + + // The page's own collection is structured, so resolving its URL makes + // Statamic validate the tree by plucking every entry in it. That is + // bookkeeping, not rendered output: recording it made saving any single + // page clear every cached page on the site. + $this->assertNotContains('collection:pages', $tags); + } + + #[Test] + public function saving_unrelated_content_leaves_a_rendered_page_cached(): void + { + Collection::make('unrelated')->save(); + + $this->get('/about')->assertOk(); + + $cacher = app(Cacher::class); + $before = $cacher->getUrls()->values()->all(); + $this->assertNotEmpty($before); + + Entry::make()->collection('unrelated')->slug('thing')->data(['title' => 'Thing'])->save(); + + // Nothing on the page reads the unrelated collection, and the page is + // tracked, so the safety net does not apply either. + $this->assertSame($before, $cacher->getUrls()->values()->all()); + } + + #[Test] + public function the_debug_header_reports_the_recorded_tags(): void + { + config(['cache_invalidation.debug' => true]); + + $response = $this->get('/about'); + + $header = $response->headers->get('X-Cache-Tags'); + + $this->assertNotNull($header); + $this->assertStringContainsString('collection:articles', $header); + $this->assertStringContainsString('global:footer', $header); + } + + private function urlFor(string $path): string + { + return app(Cacher::class)->getBaseUrl() . $path; + } +} diff --git a/tests/RequirementsTest.php b/tests/RequirementsTest.php new file mode 100644 index 0000000..db20f2c --- /dev/null +++ b/tests/RequirementsTest.php @@ -0,0 +1,251 @@ +cacher = app(Cacher::class); + $this->domain = $this->cacher->getBaseUrl(); + } + + #[Test] + public function it_invalidates_through_statamics_own_invalidator_contract(): void + { + // So Statamic's Invalidate subscriber drives it — which is ShouldQueue, and + // therefore runs on a worker when one is configured and inline otherwise. + // Nothing here re-implements dispatching. + $invalidator = app(Invalidator::class); + + $this->assertInstanceOf(DefaultInvalidator::class, $invalidator); + $this->assertTrue(is_subclass_of(\Statamic\StaticCaching\Invalidate::class, \Illuminate\Contracts\Queue\ShouldQueue::class)); + } + + #[Test] + public function reordering_a_navigation_clears_the_pages_that_render_it(): void + { + Nav::make('main_nav')->title('Main')->save(); + $tree = Nav::find('main_nav')->makeTree('default'); + $tree->save(); + + $this->cache('/uses-nav', ['nav:main_nav']); + $this->cache('/no-nav', ['entry:x']); + + // A reorder dispatches NavTreeSaved rather than NavSaved. + $tree->tree([])->save(); + + $this->assertCachedIs(['/no-nav']); + } + + #[Test] + public function a_nav_tree_resolves_to_its_navs_tag(): void + { + // The two nav-tree events arrive differently: NavTreeSaved hands the + // invalidator the Nav (via $tree->structure()), NavTreeDeleted hands it the + // tree itself. The reorder test above therefore exercises the Nav branch, + // and this covers the tree branch. + Nav::make('main_nav')->title('Main')->save(); + + $tree = Nav::find('main_nav')->makeTree('default'); + + $this->assertSame(['nav:main_nav'], app(TagResolver::class)->forItem($tree)); + } + + #[Test] + public function a_form_submission_does_not_clear_anything(): void + { + Form::make('contact')->save(); + + $this->cache('/contact', ['form:contact']); + + $submission = Form::find('contact')->makeSubmission()->data(['name' => 'Bob']); + $submission->save(); + + $this->assertCachedIs(['/contact']); + } + + #[Test] + public function saving_a_form_clears_pages_that_render_it_from_inside_a_reusable_block(): void + { + Form::make('contact')->save(); + Collection::make('reusable_blocks')->save(); + + $this->blueprint('collections.reusable_blocks', 'reusable_block', [ + 'form' => ['type' => 'form', 'max_items' => 1], + ]); + + $block = tap(Entry::make()->collection('reusable_blocks')->slug('cta') + ->data(['title' => 'CTA', 'form' => 'contact']))->save(); + + // Rendering the embedding page renders the block inline, so the block's + // reads are the page's reads. Depth is irrelevant to the recorder. + $tags = $this->tagsRecordedDuring(function () use ($block): void { + $embedded = Entry::find($block->id()); + $form = $embedded->augmentedValue('form')->value(); + $form?->handle(); + }); + + $this->assertContains('form:contact', $tags); + $this->assertContains("entry:{$block->id()}", $tags); + } + + #[Test] + public function saving_a_term_clears_pages_that_rendered_it(): void + { + Taxonomy::make('topics')->save(); + $term = tap(Term::make('news')->taxonomy('topics')->data(['title' => 'News']))->save(); + + $this->cache('/shows-term', ['term:topics::news']); + $this->cache('/lists-topics', ['taxonomy:topics']); + $this->cache('/unrelated', ['entry:x']); + + $term->in('default')->data(['title' => 'Nieuws'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function an_entry_linked_from_bard_is_recorded(): void + { + Collection::make('articles')->save(); + Collection::make('pages')->save(); + + $linked = tap(Entry::make()->collection('articles')->slug('target')->data(['title' => 'Target']))->save(); + + $this->blueprint('collections.pages', 'page', [ + 'body' => ['type' => 'bard', 'save_html' => false], + ]); + + $page = tap(Entry::make()->collection('pages')->slug('home')->data([ + 'title' => 'Home', + 'body' => [[ + 'type' => 'paragraph', + 'content' => [[ + 'type' => 'text', + 'text' => 'Read this', + 'marks' => [['type' => 'link', 'attrs' => ['href' => 'statamic://entry::'.$linked->id()]]], + ]], + ]], + ]))->save(); + + // Bard rewrites statamic:// hrefs to real URLs when augmented, which means + // resolving the entry. v1 had to scan raw values for these by hand. + $tags = $this->tagsRecordedDuring(fn () => (string) $page->augmentedValue('body')); + + $this->assertContains("entry:{$linked->id()}", $tags); + } + + #[Test] + public function a_new_entry_clears_pages_that_list_its_collection(): void + { + Collection::make('articles')->save(); + + $this->cache('/latest-three', ['collection:articles']); + $this->cache('/unrelated', ['collection:pages']); + + // A brand new entry's id is in no recorded tag set, so only the list tag + // can reach the pages that show it. + Entry::make()->collection('articles')->slug('fresh')->data(['title' => 'Fresh'])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function changing_an_entrys_published_state_clears_pages_that_list_its_collection(): void + { + Collection::make('articles')->save(); + $entry = tap(Entry::make()->collection('articles')->slug('one')->data(['title' => 'One'])->published(true))->save(); + + $this->cache('/latest-three', ['collection:articles']); + $this->cache('/unrelated', ['collection:pages']); + + $entry->published(false)->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function reordering_a_collection_tree_clears_pages_that_list_it(): void + { + $collection = Collection::make('articles')->structureContents(['root' => false]); + $collection->save(); + + $a = tap(Entry::make()->collection('articles')->slug('a')->data(['title' => 'A']))->save(); + $b = tap(Entry::make()->collection('articles')->slug('b')->data(['title' => 'B']))->save(); + + $tree = $collection->structure()->in('default'); + $tree->tree([['entry' => $a->id()], ['entry' => $b->id()]])->save(); + + $this->cache('/latest-three', ['collection:articles']); + $this->cache('/unrelated', ['collection:pages']); + + // Order drives what "latest three" renders, and a reorder dispatches + // CollectionTreeSaved rather than any entry event. + $tree->tree([['entry' => $b->id()], ['entry' => $a->id()]])->save(); + + $this->assertCachedIs(['/unrelated']); + } + + #[Test] + public function saving_a_global_clears_only_pages_that_read_it(): void + { + $set = tap(GlobalSet::make('footer'))->save(); + $set->makeLocalization('default')->data(['phone' => '1'])->save(); + + $other = tap(GlobalSet::make('seo'))->save(); + $other->makeLocalization('default')->data(['title' => 'x'])->save(); + + $this->cache('/reads-footer', ['global:footer']); + $this->cache('/reads-seo', ['global:seo']); + + GlobalSet::find('footer')->in('default')->data(['phone' => '2'])->save(); + + $this->assertCachedIs(['/reads-seo']); + } + + /** + * @param list $tags + */ + private function cache(string $path, array $tags): void + { + $this->cacher->cacheUrl(md5($path), $path, $this->domain); + $this->graph->record($this->domain.$path, $tags); + } + + /** + * @param list $expected + */ + private function assertCachedIs(array $expected): void + { + $this->assertEqualsCanonicalizing( + $expected, + $this->cacher->getUrls($this->domain)->values()->all(), + ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..4de87c5 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,93 @@ +recorder = app(DependencyRecorder::class); + $this->graph = app(DependencyGraph::class); + + $this->graph->flush(); + $this->recorder->reset(); + } + + protected function tearDown(): void + { + File::delete($this->sqlitePath()); + + parent::tearDown(); + } + + protected function resolveApplicationConfiguration($app): void + { + parent::resolveApplicationConfiguration($app); + + // A real strategy has to be active or the null cacher is used and nothing + // is ever cached. Individual tests switch to 'full' where it matters. + $app['config']->set('statamic.static_caching.strategy', 'half'); + $app['config']->set('cache_invalidation.sqlite_path', $this->sqlitePath()); + } + + protected function sqlitePath(): string + { + return __DIR__ . '/__fixtures__/dev-null/cache-invalidation.sqlite'; + } + + /** + * The tags recorded while running the callback, in isolation. + * + * Every recording assertion goes through this rather than inspecting query + * internals: the point is what a render ends up depending on, not how the + * query was shaped. + * + * @return list + */ + protected function tagsRecordedDuring(callable $callback): array + { + $this->recorder->reset(); + + $callback(); + + return $this->recorder->tags(); + } + + /** + * Registers a blueprint so entries can carry the given fields. + * + * @param array> $fields + */ + protected function blueprint(string $namespace, string $handle, array $fields = []): void + { + Blueprint::make($handle) + ->setNamespace($namespace) + ->setContents([ + 'fields' => collect($fields) + ->map(fn (array $field, string $key): array => ['handle' => $key, 'field' => $field]) + ->values() + ->all(), + ]) + ->save(); + } +} diff --git a/tests/__fixtures__/dev-null/.gitkeep b/tests/__fixtures__/dev-null/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/__fixtures__/views/entry.blade.php b/tests/__fixtures__/views/entry.blade.php new file mode 100644 index 0000000..ac0dc7d --- /dev/null +++ b/tests/__fixtures__/views/entry.blade.php @@ -0,0 +1,7 @@ +

{{ $title }}

+ +@foreach (\Statamic\Facades\Entry::query()->where('collection', 'articles')->orderBy('slug', 'asc')->get() as $article) +
  • {{ $article->title }}
  • +@endforeach + +