diff --git a/CHANGELOG.md b/CHANGELOG.md index e461090d..7bb7ffbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- Deploy this version with a new `CacheVersionModule` version (an app that binds its own cache adapter rather than this package's `Storage*Module`s must clear its pools instead): entries stored by an earlier version carry no group tag (see Fixed), and a page recomposed from an earlier version's donut template can still lack it in its CDN `Surrogate-Key` header and its If-None-Match validator, so a bare purge would not reach those. +- Rebuild the injector when deploying this version. `QueryRepository`, `DonutRepository`, `ResourceStorage` and `CacheInterceptor` take new required constructor dependencies (`CacheDependency` an optional one), so neither the scripts compiled with 1.18.0 (BEAR.Package reuses them while its compile marker matches the app and context) nor an injector serialized with it (BEAR.Package 1.23 and earlier cache one) fits the new constructors. +- The automatic purge after a write (`RefreshSameCommand`/`DonutCommandInterceptor`) drops default-valued `onGet()` parameters from the URI it purges, so it reaches every variant even when the write request supplies one: a write to `app://self/articles` purges every page of the list, and with `onGet(int $id, string $lang = 'ja')` a write to `?id=1` purges the entries of every language for that id and the pages that embed them. +- `purge()` now hands the `PurgerInterface` two tags in one call, the URI's own tag and its group tag, separated by a space, as `invalidateTags()` with several tags already did. A custom purger has to split the string on spaces, as the bundled Fastly purger does; one that sends the whole string as a single key matches no response's key. +- To reach the variants that differ only in default-valued parameters or in queries `onGet()` does not declare, purging the bare or required-only URI now suffices in the server-side pools and, for `#[CacheableResponse]`/`#[DonutCache]` pages, at the CDN; a shared `Surrogate-Key` added to each such variant only for that purpose is no longer needed there. Variants that differ in a required parameter, and the CDN copies of `#[Cacheable]` resources, still need one. + +### Fixed +- A purge aimed at a GET's bare or required-only URI now also reaches its query variants (`?page=2`, or a query `onGet()` does not declare at all), which it used to leave stale (#255). Each cache entry is additionally tagged with a group tag - its URI with every default-valued `onGet()` parameter dropped - and `purge()` invalidates that tag too. This covers `#[Cacheable]` value entries and `#[CacheableResponse]`/`#[DonutCache]` pages, their If-None-Match validators and, for the donut pages, the CDN `Surrogate-Key` header, including after `ResourceDonut::refresh()`. +- A parent that embeds a query variant carries the child's group tag as well, in its CDN `Surrogate-Key` header too, so a bare purge of the child reaches the parent (#257): a `#[Cacheable]` parent or a data dependency of a donut page drops the parent's entry, and a hole recomposes the page. +- What a purge URI reaches depends on how it is spelled. One that spells out a default-valued parameter (`#[Purge(uri: 'app://self/articles?page=1')]`) is not coarsened and invalidates only that exact URI's tag; write it without the parameter to reach every variant. A required key keeps the spelling the request used (`?user_id=1`, which BEAR.Resource binds to `$userId`), so a purge URI aimed at a GET reaches its entries only when it spells each required key the way that GET did: `purge('?user_id=1')` reaches entries stored by `?user_id=1&page=2`, `purge('?userId=1')` does not. The automatic purge after a write names the key as the parameter does (`userId`), so it does not reach entries a GET stored with the snake_case key. +- A non-200 response from a `#[Cacheable]` GET still invalidates only its own URI's tag, so an error on the bare URI leaves the cached query variants in place. +- A recomposed donut page kept the tags only of its holes that carry a `Surrogate-Key` header, so a hole cached by its ETag alone, such as a plain `#[Cacheable]` child, could be purged once but not again. A recomposed page now keeps every hole's tags, as the first write does. + ## [1.18.0] - 2026-10-29 ### Changed diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f8dbb146..65ec4ad5 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -99,6 +99,15 @@ use BEAR\RepositoryModule\Annotation\Purge; public function onDelete(int $id): static { ... } ``` +### Purging query variants + +A purge also reaches the query variants of the URI it names that differ from it only in default-valued `onGet()` parameters or in queries `onGet()` does not declare. Each cache entry also carries a group tag: its URI with every default-valued `onGet()` parameter, and any query `onGet()` does not declare, dropped. `purge()`, `#[Purge]` and `#[Refresh]` invalidate the named URI's own tag and its group tag, so purging `app://self/articles` drops `?page=2`. A variant that differs in a required parameter is a separate group. A parent that embeds a variant carries the variant's group tag as well and is dropped with it. + +- A write method of a `#[Cacheable]` or `#[CacheableResponse]` resource, and one marked `#[RefreshCache]`, purges its own URI with default-valued parameters dropped, so it reaches every variant even when the write request supplies one. A write method of a `#[DonutCache]` resource purges its own URI only when it is marked `#[RefreshCache]`. +- A purge URI that spells out a default-valued parameter (`app://self/articles?page=1`) is not coarsened and reaches only that URI's entries. +- A required key keeps the spelling the request used. A purge reaches a GET's entries only when it spells each required key the same way: `?user_id=1` reaches `?user_id=1&page=2`, `?userId=1` does not. The automatic purge after a write uses the parameter name (`userId`). +- A non-200 GET response invalidates only its own URI's tag; cached variants stay. + ## Modules ### StorageRedisDsnModule diff --git a/src/BaseUriTag.php b/src/BaseUriTag.php new file mode 100644 index 00000000..325639c0 --- /dev/null +++ b/src/BaseUriTag.php @@ -0,0 +1,72 @@ +uriTag)($ro->uri); + } + + $query = $ro->uri->query; + $baseQuery = []; + foreach ((new ReflectionMethod($ro, 'onGet'))->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable()) { + continue; + } + + $key = isset($query[$parameter->name]) ? $parameter->name : self::snakeName($parameter->name); + if (! isset($query[$key])) { + continue; + } + + /** @psalm-suppress MixedAssignment */ + $baseQuery[$key] = $query[$key]; + } + + $baseUri = clone $ro->uri; + $baseUri->query = $baseQuery; + + return self::GROUP_TAG_PREFIX . ($this->uriTag)($baseUri); + } + + /** The conversion BEAR.Resource applies when it binds a query to a parameter */ + private static function snakeName(string $name): string + { + return ltrim(strtolower((string) preg_replace('/[A-Z]/', '_\0', $name)), '_'); + } +} diff --git a/src/BaseUriTagInterface.php b/src/BaseUriTagInterface.php new file mode 100644 index 00000000..0267bc0b --- /dev/null +++ b/src/BaseUriTagInterface.php @@ -0,0 +1,20 @@ +baseUriTag = $baseUriTag ?? new BaseUriTag($uriTag); } #[Override] public function depends(ResourceObject $from, ResourceObject $to): void { - $childTags = ($this->uriTag)($to->uri); + // The group tag too, so a purge of the child's bare URI reaches a parent that embeds one of its variants. + $childTags = sprintf('%s %s', ($this->uriTag)($to->uri), ($this->baseUriTag)($to)); if (isset($to->headers[Header::SURROGATE_KEY])) { $childTags .= sprintf(' %s', $to->headers[Header::SURROGATE_KEY]); } diff --git a/src/CacheInterceptor.php b/src/CacheInterceptor.php index 72610bcc..82305a03 100644 --- a/src/CacheInterceptor.php +++ b/src/CacheInterceptor.php @@ -9,6 +9,7 @@ use BEAR\QueryRepository\Log\Context\CacheHitContext; use BEAR\QueryRepository\Log\Context\CacheMissContext; use BEAR\QueryRepository\Log\Context\GetContext; +use BEAR\QueryRepository\Log\Context\PurgeContext; use BEAR\QueryRepository\Log\Context\PutSkippedContext; use BEAR\RepositoryModule\Annotation\CacheLog; use BEAR\Resource\ResourceObject; @@ -40,6 +41,8 @@ { public function __construct( private QueryRepositoryInterface $repository, + private ResourceStorageInterface $storage, + private UriTagInterface $uriTag, #[CacheLog] private SemanticLoggerInterface $logger = new NullSemanticLogger(), ) { @@ -91,7 +94,9 @@ public function invoke(MethodInvocation $invocation) // Record the actual non-200 code; without it the purge below reads // as if a 203 and a 404 were the same thing. $this->logger->event(new PutSkippedContext((string) $ro->uri, 'error-code', $ro->code)); - $this->repository->purge($ro->uri); + // Only this URI's own tag: purge() would also evict its query variants. + $this->logger->event(new PurgeContext((string) $ro->uri)); + $this->storage->invalidateTags([($this->uriTag)($ro->uri)]); return $ro; } diff --git a/src/DonutCommandInterceptor.php b/src/DonutCommandInterceptor.php index bc06f97a..bab8ad8a 100644 --- a/src/DonutCommandInterceptor.php +++ b/src/DonutCommandInterceptor.php @@ -16,6 +16,7 @@ use Override; use Ray\Aop\MethodInterceptor; use Ray\Aop\MethodInvocation; +use ReflectionMethod; use function assert; use function call_user_func_array; @@ -80,12 +81,33 @@ public function refreshDonutAndState(ResourceObject $ro): void { $getQuery = ($this->matchQuery)($ro); $delUri = clone $ro->uri; - $delUri->query = $getQuery; + // Default-valued parameters dropped, as BaseUriTag drops them; the refresh below still uses the exact $getQuery. + $delUri->query = $this->baseQuery($ro, $getQuery); $this->repository->purge($delUri); $this->refresh($getQuery, $ro); } + /** + * @param array $getQuery + * + * @return array + */ + private function baseQuery(ResourceObject $ro, array $getQuery): array + { + $baseQuery = []; + foreach ((new ReflectionMethod($ro, 'onGet'))->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable() || ! isset($getQuery[$parameter->name])) { + continue; + } + + /** @psalm-suppress MixedAssignment */ + $baseQuery[$parameter->name] = $getQuery[$parameter->name]; + } + + return $baseQuery; + } + /** @param array $getQuery */ private function refresh(array $getQuery, ResourceObject $ro): void { diff --git a/src/DonutRepository.php b/src/DonutRepository.php index 7964f68d..d06f084d 100644 --- a/src/DonutRepository.php +++ b/src/DonutRepository.php @@ -35,11 +35,7 @@ { private CacheDependencyInterface $cacheDependency; - /** - * Append new parameters only: direct (non-DI) callers pass them positionally. - * - * @SuppressWarnings("PHPMD.ExcessiveParameterList") - */ + /** @SuppressWarnings("PHPMD.ExcessiveParameterList") */ public function __construct( private QueryRepositoryInterface $queryRepository, private HeaderSetter $headerSetter, @@ -49,12 +45,13 @@ public function __construct( #[CacheLog] private SemanticLoggerInterface $logger, private DonutRendererInterface $renderer, + private BaseUriTagInterface $baseUriTag, private UriTagInterface $uriTag = new UriTag(), CacheDependencyInterface|null $cacheDependency = null, private DeclaredCacheControl $declaredCacheControl = new DeclaredCacheControl(), ) { // Same UriTag as templateKeys(), so a material's tag matches the format purge() emits. - $this->cacheDependency = $cacheDependency ?? new CacheDependency($this->uriTag, $this->logger); + $this->cacheDependency = $cacheDependency ?? new CacheDependency($this->uriTag, $this->logger, $this->baseUriTag); } #[Override] @@ -146,7 +143,7 @@ private function doPutStatic(ResourceObject $ro, int|null $ttl, int|null $sMaxAg { $this->classifyMaterials($ro); $this->logger->event(new PutDonutContext((string) $ro->uri, $ttl, $sMaxAge)); - $keys = new SurrogateKeys($ro->uri); + $keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro)); $keys->addTag($ro); $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); @@ -173,7 +170,7 @@ private function doPutDonut(ResourceObject $ro, int|null $donutTtl): void { $this->classifyMaterials($ro); $this->logger->event(new PutDonutContext((string) $ro->uri, $donutTtl, null)); - $keys = new SurrogateKeys($ro->uri); + $keys = new SurrogateKeys($ro->uri, ($this->baseUriTag)($ro)); $declaredKeys = $this->getHeaderKeys($ro); $templateKeys = $this->templateKeys($ro, $declaredKeys); $donut = ResourceDonut::create($ro, $this->renderer, $keys, $donutTtl, false)->withStorageState($donutTtl, $templateKeys); @@ -238,7 +235,7 @@ private function classifyMaterials(ResourceObject $ro): void */ private function templateKeys(ResourceObject $ro, array $declaredKeys): array { - return array_values(array_unique([($this->uriTag)($ro->uri), ...$declaredKeys])); + return array_values(array_unique([($this->uriTag)($ro->uri), ($this->baseUriTag)($ro), ...$declaredKeys])); } /** diff --git a/src/PurgerInterface.php b/src/PurgerInterface.php index c52a657a..2d18875d 100644 --- a/src/PurgerInterface.php +++ b/src/PurgerInterface.php @@ -6,5 +6,6 @@ interface PurgerInterface { + /** @param string $tag One or more tags separated by a space */ public function __invoke(string $tag): void; } diff --git a/src/QueryRepository.php b/src/QueryRepository.php index ffd4af23..de0bb9d6 100644 --- a/src/QueryRepository.php +++ b/src/QueryRepository.php @@ -29,10 +29,14 @@ use function sprintf; use function strtotime; use function time; +use function trim; /** @internal */ final readonly class QueryRepository implements QueryRepositoryInterface { + /** @see BaseUriTag::GROUP_TAG_PREFIX */ + private const GROUP_TAG_PREFIX = 'grp-'; + public function __construct( #[CacheLog] private SemanticLoggerInterface $logger, @@ -40,7 +44,9 @@ public function __construct( private ResourceStorageInterface $storage, private Expiry $expiry, private CacheDependencyInterface $cacheDependency, + private BaseUriTagInterface $baseUriTag, private DeclaredCacheControl $declaredCacheControl = new DeclaredCacheControl(), + private UriTagInterface $uriTag = new UriTag(), ) { } @@ -102,6 +108,9 @@ private function doPut(ResourceObject $ro): bool if (isset($ro->headers[Header::ETAG])) { $etag = $ro->headers[Header::ETAG]; $surrogateKeys = $ro->headers[Header::SURROGATE_KEY] ?? ''; + // saveEtag() takes a tag list of its own, not getTags()'s. + $surrogateKeys = trim($surrogateKeys . ' ' . ($this->baseUriTag)($ro)); + $this->storage->saveEtag($ro->uri, $etag, $surrogateKeys, $ttl); } @@ -194,6 +203,9 @@ public function get(AbstractUri $uri): ResourceState|null #[Override] public function purge(AbstractUri $uri) { + // The URI's own tag plus the group tag shared by its query variants (see BaseUriTag). + $tags = [($this->uriTag)($uri), self::GROUP_TAG_PREFIX . ($this->uriTag)($uri)]; + // A top-level purge is an application-initiated (manual) cache bust: wrap it in a // manual_purge scope so it stands out from automatic invalidation. A purge nested // inside a request GET or a write command stays an ordinary purge event there. @@ -201,7 +213,7 @@ public function purge(AbstractUri $uri) $openId = $this->logger->open(new ManualPurgeContext((string) $uri)); $purged = false; try { - return $purged = $this->storage->deleteEtag($uri); + return $purged = $this->storage->invalidateTags($tags); } finally { $this->logger->close(new ManualPurgeResultContext($purged), $openId); } @@ -209,7 +221,7 @@ public function purge(AbstractUri $uri) $this->logger->event(new PurgeContext((string) $uri)); - return $this->storage->deleteEtag($uri); + return $this->storage->invalidateTags($tags); } private function getHttpCacheAnnotation(ResourceObject $ro): HttpCache|null diff --git a/src/QueryRepositoryModule.php b/src/QueryRepositoryModule.php index 81c680a3..7db5658d 100644 --- a/src/QueryRepositoryModule.php +++ b/src/QueryRepositoryModule.php @@ -34,7 +34,7 @@ * SemanticLoggerInterface (null by default - install Dev/ProdQueryRepositoryLogModule to record) * MatchQueryInterface * UriTag - * MatchQueryInterface + * BaseUriTagInterface * DeclaredCacheControl * * The following module are installed. @@ -73,6 +73,7 @@ protected function configure(): void $this->bind(EtagSetterInterface::class)->to(EtagSetter::class); $this->bind(NamedParameterInterface::class)->to(NamedParameter::class); $this->bind(ResourceStorageInterface::class)->to(ResourceStorage::class)->in(Scope::SINGLETON); + $this->bind(BaseUriTagInterface::class)->to(BaseUriTag::class); $this->bind(MatchQueryInterface::class)->to(MatchQuery::class); $this->bind(RefreshAnnotatedCommand::class); $this->bind(RefreshSameCommand::class); diff --git a/src/RefreshSameCommand.php b/src/RefreshSameCommand.php index cacfaa61..e2f2a657 100644 --- a/src/RefreshSameCommand.php +++ b/src/RefreshSameCommand.php @@ -14,6 +14,7 @@ use Override; use Ray\Aop\MethodInvocation; use ReflectionException; +use ReflectionMethod; use function call_user_func_array; use function is_callable; @@ -57,7 +58,8 @@ public function command(MethodInvocation $invocation, ResourceObject $ro): void } $delUri = clone $ro->uri; - $delUri->query = $getQuery; + // Default-valued parameters dropped, as BaseUriTag drops them; the refresh below still uses the exact $getQuery. + $delUri->query = $this->baseQuery($ro, $getQuery); // delete data in repository $this->repository->purge($delUri); @@ -72,6 +74,26 @@ public function command(MethodInvocation $invocation, ResourceObject $ro): void } } + /** + * @param array $getQuery + * + * @return array + */ + private function baseQuery(ResourceObject $ro, array $getQuery): array + { + $baseQuery = []; + foreach ((new ReflectionMethod($ro, 'onGet'))->getParameters() as $parameter) { + if ($parameter->isDefaultValueAvailable() || ! isset($getQuery[$parameter->name])) { + continue; + } + + /** @psalm-suppress MixedAssignment */ + $baseQuery[$parameter->name] = $getQuery[$parameter->name]; + } + + return $baseQuery; + } + /** * @return array * diff --git a/src/ResourceDonut.php b/src/ResourceDonut.php index b30b0d80..a3ab7941 100644 --- a/src/ResourceDonut.php +++ b/src/ResourceDonut.php @@ -10,10 +10,11 @@ use BEAR\Resource\ResourceObject; use Throwable; -use function array_key_exists; +use function array_unique; use function assert; use function explode; use function hash; +use function implode; use function is_iterable; use function max; use function preg_replace_callback; @@ -91,9 +92,7 @@ public function refresh(ResourceInterface $resource, ResourceObject $ro): Resour throw CacheStoreFailure::from($e); } - if (array_key_exists(Header::SURROGATE_KEY, $ro->headers)) { - $etags->addTag($ro); - } + $etags->addTag($ro); return (string) $ro->view; }, $this->template); @@ -105,6 +104,13 @@ public function refresh(ResourceInterface $resource, ResourceObject $ro): Resour $ro->code = $this->code; } + // $this->headers lacks the group tag; add the stored tags. + $storedTags = $this->getStorageTags(); + if ($storedTags !== []) { + $existing = isset($ro->headers[Header::SURROGATE_KEY]) ? explode(' ', $ro->headers[Header::SURROGATE_KEY]) : []; + $ro->headers[Header::SURROGATE_KEY] = implode(' ', array_unique([...$existing, ...$storedTags])); + } + $etags->setSurrogateHeader($ro); return $ro; diff --git a/src/ResourceStorage.php b/src/ResourceStorage.php index 1709d0a9..f897330e 100644 --- a/src/ResourceStorage.php +++ b/src/ResourceStorage.php @@ -46,7 +46,8 @@ * saver: ResourceStorageSaver, * roProvider:ProviderInterface, * etagProvider: ProviderInterface, - * serverContext: ServerContextInterface + * serverContext: ServerContextInterface, + * baseUriTag: BaseUriTagInterface, * } * @internal */ @@ -91,6 +92,7 @@ public function __construct( ProviderInterface $roPoolProvider, #[Set(TagAwareAdapterInterface::class, EtagPool::class)] ProviderInterface $etagPoolProvider, + private BaseUriTagInterface $baseUriTag, private ResourceBodyEvaluator $evaluateBody = new ResourceBodyEvaluator(), ) { $this->initializePools($roPoolProvider, $etagPoolProvider); @@ -338,8 +340,9 @@ private function getTags(ResourceObject $ro, array|null $surrogateKeys = null): // as a tag produced one non-volatile tag Set per content version that, under a // volatile-* eviction policy, is never reclaimed - leaking memory without being read. $surrogateKeys ??= isset($ro->headers[Header::SURROGATE_KEY]) ? explode(' ', $ro->headers[Header::SURROGATE_KEY]) : []; + // Reachable by a purge aimed at the bare URI (see BaseUriTag). /** @var list $uniqueTags */ - $uniqueTags = array_values(array_unique([($this->uriTag)($ro->uri), ...$surrogateKeys])); + $uniqueTags = array_values(array_unique([($this->uriTag)($ro->uri), ($this->baseUriTag)($ro), ...$surrogateKeys])); return $uniqueTags; } @@ -423,6 +426,7 @@ public function __serialize(): array 'roProvider' => $this->roPoolProvider, 'etagProvider' => $this->etagPoolProvider, 'serverContext' => $this->serverContext, + 'baseUriTag' => $this->baseUriTag, ]; } @@ -441,6 +445,7 @@ public function __unserialize(array $data): void // Stateless, so rebuilt rather than carried: left out of the payload it stays // uninitialized, and the first save after a compiled-injector restore throws. $this->evaluateBody = new ResourceBodyEvaluator(); + $this->baseUriTag = $data['baseUriTag']; $this->initializePools($data['roProvider'], $data['etagProvider']); } } diff --git a/src/SurrogateKeys.php b/src/SurrogateKeys.php index f9ff9a42..fa37a28e 100644 --- a/src/SurrogateKeys.php +++ b/src/SurrogateKeys.php @@ -18,19 +18,22 @@ final class SurrogateKeys /** @var list */ private array $surrogateKeys; private readonly UriTagInterface $uriTag; + private readonly BaseUriTagInterface $baseUriTag; - public function __construct(AbstractUri $uri) + public function __construct(AbstractUri $uri, string|null $baseTag = null) { $this->uriTag = new UriTag(); - $this->surrogateKeys = [($this->uriTag)($uri)]; + $this->baseUriTag = new BaseUriTag($this->uriTag); + $this->surrogateKeys = $baseTag === null ? [($this->uriTag)($uri)] : [($this->uriTag)($uri), $baseTag]; } /** - * Add etag of embedded resource + * Add the tags of an embedded resource, including its group tag so that a purge of its bare URI reaches the page */ public function addTag(ResourceObject $ro): void { $this->surrogateKeys[] = ($this->uriTag)($ro->uri); + $this->surrogateKeys[] = ($this->baseUriTag)($ro); if (array_key_exists(Header::SURROGATE_KEY, $ro->headers)) { $this->surrogateKeys = array_merge($this->surrogateKeys, explode(' ', $ro->headers[Header::SURROGATE_KEY])); } diff --git a/tests/BaseUriTagTest.php b/tests/BaseUriTagTest.php new file mode 100644 index 00000000..39f79a96 --- /dev/null +++ b/tests/BaseUriTagTest.php @@ -0,0 +1,45 @@ +uri = new Uri('app://self/item?id=1&page=2'); + $uriTag = new UriTag(); + + set_error_handler(static function (int $errno, string $errstr): never { + throw new ErrorException($errstr, 0, $errno); + }); + try { + $groupTag = (new BaseUriTag($uriTag))($ro); + } finally { + restore_error_handler(); + } + + $this->assertSame('grp-' . $uriTag(new Uri('app://self/item?id=1')), $groupTag); + } +} diff --git a/tests/CacheDependencyTest.php b/tests/CacheDependencyTest.php index fed95eb9..ae2a046e 100644 --- a/tests/CacheDependencyTest.php +++ b/tests/CacheDependencyTest.php @@ -309,6 +309,6 @@ public function testDependsOnChildTagsHaveNoDuplicateWhenTheChildsOwnKeyEqualsIt $context = json_decode($edges[0], true); assert(is_array($context)); $childTags = $context['childTags']; - $this->assertSame([$uriTag($child->uri)], $childTags); + $this->assertSame([$uriTag($child->uri), (new BaseUriTag($uriTag))($child)], $childTags); } } diff --git a/tests/DonutCacheInterceptorTest.php b/tests/DonutCacheInterceptorTest.php index b9875ff4..63ee9d01 100644 --- a/tests/DonutCacheInterceptorTest.php +++ b/tests/DonutCacheInterceptorTest.php @@ -59,11 +59,12 @@ public function testInitialRequest(): string $this->assertSame('blog-posting:1comment01', $view); // save_donut records the tags the template entry is stored under: its own URI tag (what - // purge($uri) invalidates), plus any declared Surrogate-Key. + // purge($uri) invalidates), its group tag (what a bare-URI purge also reaches), plus any + // declared Surrogate-Key. $tree = $this->flushAndValidate($this->logger); $saveDonut = self::eventContextJsonOf($tree, 'save_donut'); $this->assertNotNull($saveDonut); - $this->assertStringContainsString('"tags":["_html_blog-posting-donut_"]', $saveDonut); + $this->assertStringContainsString('"tags":["_html_blog-posting-donut_","grp-_html_blog-posting-donut_"]', $saveDonut); return $blogPosting->headers[Header::SURROGATE_KEY]; } diff --git a/tests/DonutMaterialDependencyTest.php b/tests/DonutMaterialDependencyTest.php index 64f75939..602b5264 100644 --- a/tests/DonutMaterialDependencyTest.php +++ b/tests/DonutMaterialDependencyTest.php @@ -19,6 +19,7 @@ use function array_filter; use function array_values; use function dirname; +use function explode; use function str_contains; use function str_replace; @@ -292,33 +293,7 @@ public function testASecondFullMissOfTheSamePageRendersTheFreshHoleContent(): vo */ public function testAManuallyConstructedRepositoryUsesItsOwnUriTagForTheDefaultCacheDependency(): void { - $module = new FakeEtagPoolModule(ModuleFactory::getInstance('FakeVendor\HelloWorld')); - $module->override(new TwigModule([dirname(__DIR__) . '/tests/Fake/fake-app/var/templates'])); - $injector = new Injector($module, __DIR__ . '/tmp'); - - $resource = $injector->getInstance(ResourceInterface::class); - $repository = new DonutRepository( - $injector->getInstance(QueryRepositoryInterface::class), - $injector->getInstance(HeaderSetter::class), - $injector->getInstance(ResourceStorageInterface::class), - $resource, - $injector->getInstance(CdnCacheControlHeaderSetterInterface::class), - $injector->getInstance(SemanticLoggerInterface::class, CacheLog::class), - $injector->getInstance(DonutRendererInterface::class), - new class implements UriTagInterface { - #[Override] - public function __invoke(AbstractUri $uri): string - { - return 'customtag_' . str_replace(['/', '?', '&', ':'], '_', (string) $uri); - } - - #[Override] - public function fromAssoc(string $uriTemplate, array $assoc): string - { - return ''; - } - }, - ); + [$resource, $repository] = $this->manuallyConstructedRepository(); // uri() (not newRequest(), unavailable before bear/resource 1.2x) is the same GET // request #[Embed] itself builds; run once so its headers carry the material @@ -333,7 +308,86 @@ public function fromAssoc(string $uriTemplate, array $assoc): string $result = $repository->putDonut($parent, null); - $this->assertStringContainsString('customtag_page___self_html_comment', $result->headers[Header::SURROGATE_KEY] ?? '', "the default CacheDependency must tag the child with THIS repository's own UriTag, not a bare new UriTag()"); + $this->assertContains('customtag_page___self_html_comment', explode(' ', $result->headers[Header::SURROGATE_KEY] ?? ''), "the default CacheDependency must tag the child with THIS repository's own UriTag, not a bare new UriTag()"); + } + + /** The hole's group tag reaches the view, so a purge of the hole's bare URI recomposes the page. */ + public function testBarePurgeReachesAPageRenderingAQueryVariantAsAHole(): void + { + $page = (string) $this->resource->get('page://self/html/variant-hole-parent'); + $this->assertSame($page, (string) $this->resource->get('page://self/html/variant-hole-parent'), 'precondition: warm cache'); + + $this->queryRepository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertNotSame($page, (string) $this->resource->get('page://self/html/variant-hole-parent')); + } + + /** A recomposed page keeps its hole's tags, as the first write does, so the hole can be purged again. */ + public function testPurgeOfAHoleReachesThePageAgainAfterItWasRecomposed(): void + { + $first = (string) $this->resource->get('page://self/html/variant-hole-parent'); + $this->queryRepository->purge(new Uri('app://self/optional-param?id=1&page=2')); + $recomposed = (string) $this->resource->get('page://self/html/variant-hole-parent'); + $this->assertNotSame($first, $recomposed, 'precondition: the first purge recomposes the page'); + + $this->queryRepository->purge(new Uri('app://self/optional-param?id=1&page=2')); + + $this->assertNotSame($recomposed, (string) $this->resource->get('page://self/html/variant-hole-parent')); + } + + /** With a custom UriTag, a static donut page's CDN header carries the group tag in that format, the one purge() invalidates. */ + public function testAStaticDonutPageCarriesTheGroupTagInTheCustomUriTagFormat(): void + { + [, $repository] = $this->manuallyConstructedRepository(); + $page = new class extends ResourceObject { + public function onGet(int $page = 1): static + { + return $this; + } + }; + $page->uri = new Uri('page://self/dep/unit-paged?page=2'); + $page->body = []; + + $result = $repository->putStatic($page); + + $this->assertContains('grp-customtag_page___self_dep_unit-paged', explode(' ', $result->headers[Header::SURROGATE_KEY] ?? '')); + } + + /** @return array{ResourceInterface, DonutRepository} */ + private function manuallyConstructedRepository(): array + { + $module = new FakeEtagPoolModule(ModuleFactory::getInstance('FakeVendor\HelloWorld')); + $module->override(new TwigModule([dirname(__DIR__) . '/tests/Fake/fake-app/var/templates'])); + $injector = new Injector($module, __DIR__ . '/tmp'); + $resource = $injector->getInstance(ResourceInterface::class); + $uriTag = new class implements UriTagInterface { + #[Override] + public function __invoke(AbstractUri $uri): string + { + return 'customtag_' . str_replace(['/', '?', '&', ':'], '_', (string) $uri); + } + + #[Override] + public function fromAssoc(string $uriTemplate, array $assoc): string + { + return ''; + } + }; + + return [ + $resource, + new DonutRepository( + $injector->getInstance(QueryRepositoryInterface::class), + $injector->getInstance(HeaderSetter::class), + $injector->getInstance(ResourceStorageInterface::class), + $resource, + $injector->getInstance(CdnCacheControlHeaderSetterInterface::class), + $injector->getInstance(SemanticLoggerInterface::class, CacheLog::class), + $injector->getInstance(DonutRendererInterface::class), + new BaseUriTag($uriTag), + $uriTag, + ), + ]; } /** diff --git a/tests/Fake/fake-app/src/Resource/App/OptionalParam.php b/tests/Fake/fake-app/src/Resource/App/OptionalParam.php index b367a1df..a283b4ec 100644 --- a/tests/Fake/fake-app/src/Resource/App/OptionalParam.php +++ b/tests/Fake/fake-app/src/Resource/App/OptionalParam.php @@ -7,6 +7,7 @@ namespace FakeVendor\HelloWorld\Resource\App; use BEAR\RepositoryModule\Annotation\Cacheable; +use BEAR\RepositoryModule\Annotation\Purge; use BEAR\Resource\ResourceObject; #[Cacheable] @@ -24,6 +25,7 @@ public function onGet(int $id, int $page = 1, int $sort = 0) return $this; } + #[Purge(uri: 'app://self/optional-param?id={id}')] public function onPut(int $id) { return $this; diff --git a/tests/Fake/fake-app/src/Resource/App/PagedErrorOnBare.php b/tests/Fake/fake-app/src/Resource/App/PagedErrorOnBare.php new file mode 100644 index 00000000..2ce07e5b --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/App/PagedErrorOnBare.php @@ -0,0 +1,25 @@ +code = self::$fail && $page === 1 ? 503 : 200; + $this->body = ['page' => $page, 'time' => \microtime(true)]; + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/App/PagedValue.php b/tests/Fake/fake-app/src/Resource/App/PagedValue.php new file mode 100644 index 00000000..69bc3cc4 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/App/PagedValue.php @@ -0,0 +1,27 @@ +body = ['page' => $page, 'time' => \microtime(true)]; + + return $this; + } + + public function onPut() + { + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php b/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php new file mode 100644 index 00000000..fce5a45f --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/App/SnakeQueryParam.php @@ -0,0 +1,20 @@ +body = ['userId' => $userId, 'page' => $page, 'time' => \microtime(true)]; + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php b/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php new file mode 100644 index 00000000..ca4f2dd5 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Html/PagedStaticDonut.php @@ -0,0 +1,22 @@ +body = ['page' => $page, 'time' => \microtime(true)]; + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/Html/VariantDonutParent.php b/tests/Fake/fake-app/src/Resource/Page/Html/VariantDonutParent.php new file mode 100644 index 00000000..42e3acb5 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Html/VariantDonutParent.php @@ -0,0 +1,23 @@ +body['childTime'] = $this['child']['time']; + $this->body['time'] = \microtime(true); + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/Html/VariantHoleParent.php b/tests/Fake/fake-app/src/Resource/Page/Html/VariantHoleParent.php new file mode 100644 index 00000000..d8024581 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/Html/VariantHoleParent.php @@ -0,0 +1,22 @@ +body['time'] = \microtime(true); + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/PagedList.php b/tests/Fake/fake-app/src/Resource/Page/PagedList.php new file mode 100644 index 00000000..ea28296b --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/PagedList.php @@ -0,0 +1,27 @@ +body = ['page' => $page, 'time' => \microtime(true)]; + + return $this; + } + + public function onPost(string $title) + { + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php b/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php new file mode 100644 index 00000000..0ca62b8b --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/PagedResponseWithId.php @@ -0,0 +1,27 @@ +body = ['id' => $id, 'page' => $page, 'time' => \microtime(true)]; + + return $this; + } + + public function onPut(int $id) + { + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/PagedValueParent.php b/tests/Fake/fake-app/src/Resource/Page/PagedValueParent.php new file mode 100644 index 00000000..5536c8cc --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/PagedValueParent.php @@ -0,0 +1,22 @@ +body['time'] = \microtime(true); + + return $this; + } +} diff --git a/tests/Fake/fake-app/src/Resource/Page/VariantParent.php b/tests/Fake/fake-app/src/Resource/Page/VariantParent.php new file mode 100644 index 00000000..c9b77cb1 --- /dev/null +++ b/tests/Fake/fake-app/src/Resource/Page/VariantParent.php @@ -0,0 +1,22 @@ +body['time'] = \microtime(true); + + return $this; + } +} diff --git a/tests/Fake/fake-app/var/templates/App/OptionalParam.html.twig b/tests/Fake/fake-app/var/templates/App/OptionalParam.html.twig new file mode 100644 index 00000000..3160c808 --- /dev/null +++ b/tests/Fake/fake-app/var/templates/App/OptionalParam.html.twig @@ -0,0 +1 @@ +{{ id }}:{{ page }}:{{ time }} \ No newline at end of file diff --git a/tests/Fake/fake-app/var/templates/Page/Html/VariantHoleParent.html.twig b/tests/Fake/fake-app/var/templates/Page/Html/VariantHoleParent.html.twig new file mode 100644 index 00000000..fd1af381 --- /dev/null +++ b/tests/Fake/fake-app/var/templates/Page/Html/VariantHoleParent.html.twig @@ -0,0 +1 @@ +variant-hole-parent:{{ child }} \ No newline at end of file diff --git a/tests/QueryVariantPurgeTest.php b/tests/QueryVariantPurgeTest.php new file mode 100644 index 00000000..cc218ff5 --- /dev/null +++ b/tests/QueryVariantPurgeTest.php @@ -0,0 +1,359 @@ +resource = $injector->getInstance(ResourceInterface::class); + $this->repository = $injector->getInstance(QueryRepositoryInterface::class); + $this->storage = $injector->getInstance(ResourceStorageInterface::class); + } + + private function time(string $uri): float + { + $body = $this->resource->get($uri)->body; + assert(is_array($body)); + $time = $body['time']; + assert(is_float($time)); + + return $time; + } + + /** A query onGet() does not declare at all (not merely defaulted). */ + private function timeWithExtraQuery(): float + { + $request = $this->resource->get->uri('app://self/user')->withQuery(['id' => 1])->addQuery(['limit' => 5]); // @phpstan-ignore-line + assert($request instanceof AbstractRequest); + $ro = $request->eager->request(); + assert($ro instanceof ResourceObject); + $body = $ro->body; + assert(is_array($body)); + $time = $body['time']; + assert(is_float($time)); + + return $time; + } + + public function testPurgeAttributeReachesDefaultValuedQueryVariant(): void + { + $bare = $this->time('app://self/optional-param?id=1'); + $page2 = $this->time('app://self/optional-param?id=1&page=2'); + $this->assertSame($bare, $this->time('app://self/optional-param?id=1'), 'precondition: warm cache'); + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2'), 'precondition: warm cache'); + + $this->resource->put('app://self/optional-param', ['id' => 1]); + + $this->assertNotSame($bare, $this->time('app://self/optional-param?id=1')); + $this->assertNotSame($page2, $this->time('app://self/optional-param?id=1&page=2')); + } + + public function testDirectPurgeReachesDefaultValuedQueryVariant(): void + { + $bare = $this->time('app://self/optional-param?id=1'); + $page2 = $this->time('app://self/optional-param?id=1&page=2'); + $this->assertSame($bare, $this->time('app://self/optional-param?id=1'), 'precondition: warm cache'); + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2'), 'precondition: warm cache'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertNotSame($bare, $this->time('app://self/optional-param?id=1')); + $this->assertNotSame($page2, $this->time('app://self/optional-param?id=1&page=2')); + } + + /** Auto-purge also reaches a query that onGet() does not declare at all. */ + public function testAutoPurgeReachesQueryNotInOnGetSignature(): void + { + $plain = $this->time('app://self/user?id=1'); + $before = $this->timeWithExtraQuery(); + $this->assertSame($plain, $this->time('app://self/user?id=1'), 'precondition: warm cache'); + $this->assertSame($before, $this->timeWithExtraQuery(), 'precondition: warm cache'); + + $this->resource->delete('app://self/user', ['id' => 1]); + + $this->assertNotSame($plain, $this->time('app://self/user?id=1')); + $this->assertNotSame($before, $this->timeWithExtraQuery()); + } + + public function testDonutAutoPurgeReachesDefaultValuedQueryVariant(): void + { + $bare = (string) $this->resource->get('page://self/paged-list'); + $page2 = (string) $this->resource->get('page://self/paged-list?page=2'); + $this->assertSame($bare, (string) $this->resource->get('page://self/paged-list'), 'precondition: warm cache'); + $this->assertSame($page2, (string) $this->resource->get('page://self/paged-list?page=2'), 'precondition: warm cache'); + + $this->resource->post('page://self/paged-list', ['title' => 'new']); + + $this->assertNotSame($bare, (string) $this->resource->get('page://self/paged-list')); + $this->assertNotSame($page2, (string) $this->resource->get('page://self/paged-list?page=2')); + } + + /** PagedList::onPost has no #[Purge]/#[Refresh], so DonutCommandInterceptor's auto-purge is the only + * invalidation: a write to `?page=2` reaches every page variant. */ + public function testDonutAutoPurgeFromWriteUriCarryingDefaultedParamReachesOtherVariants(): void + { + $bare = (string) $this->resource->get('page://self/paged-list'); + $page1 = (string) $this->resource->get('page://self/paged-list?page=1'); + $this->assertSame($bare, (string) $this->resource->get('page://self/paged-list'), 'precondition: warm cache'); + $this->assertSame($page1, (string) $this->resource->get('page://self/paged-list?page=1'), 'precondition: warm cache'); + + $this->resource->post('page://self/paged-list?page=2', ['title' => 'new']); + + $this->assertNotSame($bare, (string) $this->resource->get('page://self/paged-list')); + $this->assertNotSame($page1, (string) $this->resource->get('page://self/paged-list?page=1')); + } + + /** Same for a plain #[Cacheable] value resource: PagedValue::onPut has no #[Purge]/#[Refresh], so + * RefreshSameCommand's auto-purge is the only invalidation. */ + public function testValueAutoPurgeFromWriteUriCarryingDefaultedParamReachesOtherVariants(): void + { + $bare = $this->time('app://self/paged-value'); + $page1 = $this->time('app://self/paged-value?page=1'); + $this->assertSame($bare, $this->time('app://self/paged-value'), 'precondition: warm cache'); + $this->assertSame($page1, $this->time('app://self/paged-value?page=1'), 'precondition: warm cache'); + + $this->resource->put('app://self/paged-value?page=2'); + + $this->assertNotSame($bare, $this->time('app://self/paged-value')); + $this->assertNotSame($page1, $this->time('app://self/paged-value?page=1')); + } + + /** The write's URI carries the required `id` and the defaulted `page`: auto-purge drops `page` and + * keeps `id`, so another id's cache survives. */ + public function testDonutAutoPurgeKeepsRequiredParamWhileDroppingDefaultedOne(): void + { + $id1Bare = (string) $this->resource->get('page://self/paged-response-with-id?id=1'); + $id1Page2 = (string) $this->resource->get('page://self/paged-response-with-id?id=1&page=2'); + $id2Page2 = (string) $this->resource->get('page://self/paged-response-with-id?id=2&page=2'); + $this->assertSame($id1Bare, (string) $this->resource->get('page://self/paged-response-with-id?id=1'), 'precondition: warm cache'); + $this->assertSame($id1Page2, (string) $this->resource->get('page://self/paged-response-with-id?id=1&page=2'), 'precondition: warm cache'); + + $this->resource->put('page://self/paged-response-with-id?id=1&page=2'); + + $this->assertNotSame($id1Bare, (string) $this->resource->get('page://self/paged-response-with-id?id=1')); + $this->assertNotSame($id1Page2, (string) $this->resource->get('page://self/paged-response-with-id?id=1&page=2')); + $this->assertSame($id2Page2, (string) $this->resource->get('page://self/paged-response-with-id?id=2&page=2'), 'a different id must not be purged'); + } + + /** Storing the bare URI on a cache miss (no write, no purge) must not evict an already-cached + * sibling variant. */ + public function testBareColdMissStoreDoesNotEvictSiblingVariant(): void + { + $page2 = $this->time('app://self/optional-param?id=1&page=2'); + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2'), 'precondition: warm cache'); + + $this->time('app://self/optional-param?id=1'); // first-ever fetch of the bare URI + + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2')); + } + + /** Donut flavour of the above. */ + public function testBareDonutColdMissStoreDoesNotEvictSiblingVariant(): void + { + $page2 = (string) $this->resource->get('page://self/paged-list?page=2'); + $this->assertSame($page2, (string) $this->resource->get('page://self/paged-list?page=2'), 'precondition: warm cache'); + + $this->resource->get('page://self/paged-list'); // first-ever fetch of the bare URI + + $this->assertSame($page2, (string) $this->resource->get('page://self/paged-list?page=2')); + } + + /** etagPool flavour: the bare URI's first fetch keeps the sibling's If-None-Match validator. */ + public function testBareColdMissStoreDoesNotEvictSiblingVariantEtag(): void + { + assert($this->storage instanceof ScopedValidatorInterface); + $uri = new Uri('app://self/optional-param?id=1&page=2'); + $etag = $this->resource->get('app://self/optional-param?id=1&page=2')->headers['ETag']; + + $this->resource->get('app://self/optional-param?id=1'); // first-ever fetch of the bare URI + + $this->assertTrue($this->storage->hasEtagFor($etag, $uri)); + } + + /** A bare purge also invalidates the variant's If-None-Match validator; the etagPool builds its tag + * list apart from getTags(). */ + public function testBarePurgeInvalidatesQueryVariantEtag(): void + { + assert($this->storage instanceof ScopedValidatorInterface); + $uri = new Uri('app://self/optional-param?id=1&page=2'); + $ro = $this->resource->get('app://self/optional-param?id=1&page=2'); + $etag = $ro->headers['ETag']; + $this->assertTrue($this->storage->hasEtagFor($etag, $uri), 'precondition: validator registered'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); + } + + /** After ResourceDonut::refresh() re-saves a page's value (its template survived), the ETag validator + * still carries the group tag. */ + public function testDonutRefreshKeepsTheGroupTagOnTheEtagValidator(): void + { + assert($this->storage instanceof ScopedValidatorInterface); + $uri = new Uri('page://self/html/paged-static-donut'); + $this->resource->get((string) $uri); // the interceptor's own putStatic() warms both pools + + // Drop just the value/etag entry, leaving the donut template behind, by reaching into + // ResourceStorage's internal pool directly - no public API removes only one of the two. + $roPool = (new ReflectionProperty($this->storage, 'roPool'))->getValue($this->storage); + assert($roPool instanceof TagAwareAdapterInterface); + $getUriKey = new ReflectionMethod($this->storage, 'getUriKey'); + $getUriKey->setAccessible(true); + $key = $getUriKey->invoke($this->storage, $uri, 'ro-'); + assert(is_string($key)); + $roPool->deleteItem($key); + $this->assertNull($this->repository->get($uri), 'precondition: value entry gone'); + $this->assertNotNull($this->storage->getDonut($uri), 'precondition: donut template still present'); + + $refreshed = $this->resource->get((string) $uri); + $etag = $refreshed->headers['ETag']; + $this->assertTrue($this->storage->hasEtagFor($etag, $uri), 'precondition: refreshed validator registered'); + + $this->storage->invalidateTags(['grp-_html_paged-static-donut_']); + + $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); + } + + /** On the first, never-refreshed save, doPutStatic()'s SurrogateKeys tags the etagPool entry with the page's group tag. */ + public function testDonutInitialSaveTagsTheEtagValidatorWithTheGroupTag(): void + { + assert($this->storage instanceof ScopedValidatorInterface); + $uri = new Uri('page://self/html/paged-static-donut'); + $ro = $this->resource->get((string) $uri); + $etag = $ro->headers['ETag']; + $this->assertTrue($this->storage->hasEtagFor($etag, $uri), 'precondition: validator registered'); + + $this->storage->invalidateTags(['grp-_html_paged-static-donut_']); + + $this->assertFalse($this->storage->hasEtagFor($etag, $uri)); + } + + /** doPutDonut() (dynamic #[DonutCache], template-only) builds the CDN Surrogate-Key header from its own + * SurrogateKeys, separate from templateKeys(), so it carries the group tag itself. */ + public function testDynamicDonutCarriesTheGroupTagInItsCdnHeader(): void + { + $ro = $this->resource->get('page://self/html/blog-posting-donut?id=5'); + + $this->assertContains('grp-_html_blog-posting-donut_', explode(' ', $ro->headers['Surrogate-Key'])); + } + + /** An error response invalidates its own URI's tag (e.g. a parent embedding it) but not the group tag. */ + public function testErrorOnBareUriKeepsQueryVariantsCached(): void + { + $page2 = $this->time('app://self/paged-error-on-bare?page=2'); + $this->assertSame($page2, $this->time('app://self/paged-error-on-bare?page=2'), 'precondition: warm cache'); + $roPool = (new ReflectionProperty($this->storage, 'roPool'))->getValue($this->storage); + assert($roPool instanceof TagAwareAdapterInterface); + $dependent = $roPool->getItem('dependent-on-bare'); + $dependent->set(true)->tag((new UriTag())(new Uri('app://self/paged-error-on-bare'))); + $this->assertTrue($roPool->save($dependent), 'precondition: dependent entry stored'); + + PagedErrorOnBare::$fail = true; + try { + $this->assertSame(503, $this->resource->get('app://self/paged-error-on-bare')->code); + } finally { + PagedErrorOnBare::$fail = false; + } + + $this->assertFalse($roPool->hasItem('dependent-on-bare')); + $this->assertSame($page2, $this->time('app://self/paged-error-on-bare?page=2')); + } + + /** A parent depends on its child's group, so a bare purge of the child reaches a parent that embeds a variant. */ + public function testBarePurgeReachesACacheableParentEmbeddingAQueryVariant(): void + { + $parent = $this->time('page://self/variant-parent'); + $this->assertSame($parent, $this->time('page://self/variant-parent'), 'precondition: warm cache'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertNotSame($parent, $this->time('page://self/variant-parent')); + } + + public function testBarePurgeReachesADonutPageReadingAQueryVariant(): void + { + $page = (string) $this->resource->get('page://self/html/variant-donut-parent'); + $this->assertSame($page, (string) $this->resource->get('page://self/html/variant-donut-parent'), 'precondition: warm cache'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1')); + + $this->assertNotSame($page, (string) $this->resource->get('page://self/html/variant-donut-parent')); + } + + public function testWriteToTheChildReachesAParentEmbeddingAnotherQueryVariant(): void + { + $parent = $this->time('page://self/paged-value-parent'); + $this->assertSame($parent, $this->time('page://self/paged-value-parent'), 'precondition: warm cache'); + + $this->resource->put('app://self/paged-value', ['page' => 1]); + + $this->assertNotSame($parent, $this->time('page://self/paged-value-parent')); + } + + /** A purge URI that spells a default value is not coarsened: it reaches that entry only. */ + public function testPurgeSpellingADefaultValueReachesThatVariantOnly(): void + { + $page1 = $this->time('app://self/optional-param?id=1&page=1'); + $page2 = $this->time('app://self/optional-param?id=1&page=2'); + + $this->repository->purge(new Uri('app://self/optional-param?id=1&page=1')); + + $this->assertNotSame($page1, $this->time('app://self/optional-param?id=1&page=1')); + $this->assertSame($page2, $this->time('app://self/optional-param?id=1&page=2')); + } + + /** BEAR.Resource binds `?user_id=` to `$userId`; the group keeps the required key as the request spelled it. */ + public function testBarePurgeWithSnakeCaseKeysReachesThatUsersVariantsOnly(): void + { + $user1 = $this->time('app://self/snake-query-param?user_id=1&page=2'); + $user2 = $this->time('app://self/snake-query-param?user_id=2&page=2'); + + $this->repository->purge(new Uri('app://self/snake-query-param?user_id=1')); + + $this->assertNotSame($user1, $this->time('app://self/snake-query-param?user_id=1&page=2')); + $this->assertSame($user2, $this->time('app://self/snake-query-param?user_id=2&page=2')); + } + + /** A camelCase key (`?userId=`) stays camelCase in the group. */ + public function testBarePurgeWithCamelCaseKeysReachesThatUsersVariantsOnly(): void + { + $user1 = $this->time('app://self/snake-query-param?userId=1&page=2'); + $user2 = $this->time('app://self/snake-query-param?userId=2&page=2'); + $this->assertSame($user1, $this->time('app://self/snake-query-param?userId=1&page=2'), 'precondition: warm cache'); + + $this->repository->purge(new Uri('app://self/snake-query-param?userId=1')); + + $this->assertNotSame($user1, $this->time('app://self/snake-query-param?userId=1&page=2')); + $this->assertSame($user2, $this->time('app://self/snake-query-param?userId=2&page=2')); + } +} diff --git a/tests/RefreshAnnotatedCommandTest.php b/tests/RefreshAnnotatedCommandTest.php index 16f69eb1..0a121430 100644 --- a/tests/RefreshAnnotatedCommandTest.php +++ b/tests/RefreshAnnotatedCommandTest.php @@ -17,6 +17,8 @@ use function array_filter; use function array_values; +use function explode; +use function in_array; class RefreshAnnotatedCommandTest extends TestCase { @@ -73,7 +75,7 @@ public function testRefreshToCacheableDestinationWritesExactlyOnce(): void $profileTagPurges = array_values(array_filter( $this->purger->tags, - static fn (string $tag): bool => $tag === '_user_profile_user_id=1', + static fn (string $tag): bool => in_array('_user_profile_user_id=1', explode(' ', $tag), true), )); $this->assertCount(2, $profileTagPurges, 'the explicit purge plus the GET\'s own pre-write cleanup'); diff --git a/tests/ResourceRepositoryTest.php b/tests/ResourceRepositoryTest.php index 35c44e9a..e076728d 100644 --- a/tests/ResourceRepositoryTest.php +++ b/tests/ResourceRepositoryTest.php @@ -48,9 +48,11 @@ public function get() new GlobalServerContext(), $tagAwareAdapterProvider, $tagAwareAdapterProvider, + new BaseUriTag(new UriTag()), ), new Expiry(0, 0, 0), new CacheDependency(new UriTag()), + new BaseUriTag(new UriTag()), ); $this->ro = new Index(); $this->ro->uri = new Uri('page://self/user'); @@ -130,9 +132,11 @@ public function get() new GlobalServerContext(), $tagAwareAdapterProvider, $tagAwareAdapterProvider, + new BaseUriTag(new UriTag()), ), new Expiry(0, 0, 0), new CacheDependency(new UriTag()), + new BaseUriTag(new UriTag()), ); $this->assertInstanceOf(Repository::class, $repository); } diff --git a/tests/ResourceStorageTest.php b/tests/ResourceStorageTest.php index 4b8f08e5..664f8b59 100644 --- a/tests/ResourceStorageTest.php +++ b/tests/ResourceStorageTest.php @@ -61,6 +61,7 @@ public function get() new GlobalServerContext(), $provider($roPool ?? $tagAwareAdapter), $provider($etagPool ?? $tagAwareAdapter), + new BaseUriTag(new UriTag()), ); } @@ -94,6 +95,7 @@ public function testARestoredStorageStillWrites(): void new GlobalServerContext(), new FakeArrayPoolProvider(), new FakeArrayPoolProvider(), + new BaseUriTag(new UriTag()), ); $storage = unserialize(serialize($serializable)); assert($storage instanceof ResourceStorage); @@ -246,6 +248,7 @@ public function get() new GlobalServerContext(), $poolProvider, $poolProvider, + new BaseUriTag(new UriTag()), ); $saved = $storage->saveValue($this->ro, 10); @@ -288,6 +291,7 @@ public function get() new GlobalServerContext(), $poolProvider, $poolProvider, + new BaseUriTag(new UriTag()), ); $donut = ResourceDonut::create($this->ro, new DonutRenderer(), new SurrogateKeys(new Uri('app://self/')), null, false); diff --git a/tests/SurrogateKeysTest.php b/tests/SurrogateKeysTest.php index 3ca95bda..f851929c 100644 --- a/tests/SurrogateKeysTest.php +++ b/tests/SurrogateKeysTest.php @@ -56,7 +56,7 @@ public function testSetSurrogateHeader(): void }; $ro->uri = $uri; $etags->setSurrogateHeader($ro); - $this->assertSame('_foo_ _foo1_ a b _foo2_', $ro->headers[Header::SURROGATE_KEY]); + $this->assertSame('_foo_ _foo1_ grp-_foo1_ a b _foo2_ grp-_foo2_', $ro->headers[Header::SURROGATE_KEY]); $ro = $this->resource->get('page://self/html/page-surrogate-key'); $this->assertStringContainsString('page-tag', $ro->headers[Header::SURROGATE_KEY]); @@ -71,7 +71,7 @@ public function testOnePurgeKey(): void $foo->uri = $uri; $etags->addTag($foo); $etags->setSurrogateHeader($foo); - $this->assertSame('_foo_', $foo->headers[Header::SURROGATE_KEY]); + $this->assertSame('_foo_ grp-_foo_', $foo->headers[Header::SURROGATE_KEY]); } public function testSetSurrogateHeaderDeduplicatesWithManualKeys(): void @@ -90,6 +90,6 @@ public function testSetSurrogateHeaderDeduplicatesWithManualKeys(): void }; $ro->uri = $uri; $etags->setSurrogateHeader($ro); - $this->assertSame('shared-tag _foo_ _foo1_', $ro->headers[Header::SURROGATE_KEY]); + $this->assertSame('shared-tag _foo_ _foo1_ grp-_foo1_', $ro->headers[Header::SURROGATE_KEY]); } }