diff --git a/.env.example b/.env.example index 9c34bdcb7..f574136eb 100644 --- a/.env.example +++ b/.env.example @@ -4,25 +4,12 @@ ENVIRONMENT=local # The base URI that's used for all generated URIs BASE_URI=http://localhost -# The CACHE key is used as a global override to turn all caches on or off -# Should be true in production, but null or false in local development -CACHE=null +# Setting to `false` force-disable internal caches. +INTERNAL_CACHES=true -# Enable or disable discovery cache +# Enable or disable discovery cache. Can be `true`, `partial` or `false`. DISCOVERY_CACHE=false -# Enable or disable config cache -CONFIG_CACHE=false - -# Enable or disable icon cache -ICON_CACHE=true - -# Enable or disable view cache -VIEW_CACHE=false - -# Enable or disable project cache (allround cache) -PROJECT_CACHE=false - # Overwrite default log paths (null = default) DEBUG_LOG_PATH=null -SERVER_LOG_PATH=null \ No newline at end of file +SERVER_LOG_PATH=null diff --git a/packages/cache/src/Cache.php b/packages/cache/src/Cache.php index abe845af4..8a82500dc 100644 --- a/packages/cache/src/Cache.php +++ b/packages/cache/src/Cache.php @@ -6,20 +6,88 @@ use Closure; use Psr\Cache\CacheItemInterface; +use Stringable; use Tempest\DateTime\DateTimeInterface; +use Tempest\DateTime\Duration; interface Cache { - public function put(string $key, mixed $value, ?DateTimeInterface $expiresAt = null): CacheItemInterface; + /** + * Whether the cache is enabled. + */ + public bool $enabled { + get; + set; + } - public function get(string $key): mixed; + /** + * Returns a lock for the specified key. The lock is not acquired until `acquire()` is called. + * + * @param Stringable|string $key The identifier of the lock. + * @param null|Duration|DateTimeInterface $expiration The expiration time for the lock. If not specified, the lock will not expire. + * @param null|Stringable|string $owner The owner of the lock, which will be used to identify the process releasing it. If not specified, a random string will be used. + */ + public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock; - /** @param Closure(): mixed $cache */ - public function resolve(string $key, Closure $cache, ?DateTimeInterface $expiresAt = null): mixed; + /** + * Sets the specified key to the specified value in the cache. Optionally, specify an expiration. + */ + public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface; - public function remove(string $key): void; + /** + * Sets the specified keys to the specified values in the cache. Optionally, specify an expiration. + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * @return array + */ + public function putMany(iterable $values, null|Duration|DateTimeInterface $expiration = null): array; - public function clear(): void; + /** + * Gets the value associated with the specified key from the cache. If the key does not exist, null is returned. + */ + public function get(Stringable|string $key): mixed; + + /** + * Gets the values associated with the specified keys from the cache. If a key does not exist, null is returned for that key. + * + * @template TKey of array-key + * @template TValue + * + * @param iterable $array + * @return array + */ + public function getMany(iterable $key): array; + + /** + * Determines whether the cache contains the specified key. + */ + public function has(Stringable|string $key): bool; + + /** + * Increments the value associated with the specified key by the specified amount. If the key does not exist, it is created with the specified amount. + */ + public function increment(Stringable|string $key, int $by = 1): int; - public function isEnabled(): bool; + /** + * Decrements the value associated with the specified key by the specified amount. If the key does not exist, it is created with the negative amount. + */ + public function decrement(Stringable|string $key, int $by = 1): int; + + /** + * If the specified key already exists in the cache, the value is returned and the `$callback` is not executed. Otherwise, the result of the callback is stored, then returned. + */ + public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed; + + /** + * Removes the specified key from the cache. + */ + public function remove(Stringable|string $key): void; + + /** + * Clears the entire cache. + */ + public function clear(): void; } diff --git a/packages/cache/src/CacheClearCommand.php b/packages/cache/src/CacheClearCommand.php deleted file mode 100644 index dca4abe38..000000000 --- a/packages/cache/src/CacheClearCommand.php +++ /dev/null @@ -1,52 +0,0 @@ -cacheConfig->caches; - - if ($all === false) { - $caches = $this->ask( - question: 'Which caches do you want to clear?', - options: $this->cacheConfig->caches, - multiple: true, - ); - } - - if (count($caches) === 0) { - $this->console->info('No cache selected.'); - - return; - } - - $this->console->header('Clearing caches'); - - foreach ($caches as $cacheClass) { - /** @var Cache $cache */ - $cache = $this->container->get($cacheClass); - $cache->clear(); - - $this->console->keyValue( - key: $cacheClass, - value: "CLEARED", - ); - } - } -} diff --git a/packages/cache/src/CacheConfig.php b/packages/cache/src/CacheConfig.php deleted file mode 100644 index 7a88dd696..000000000 --- a/packages/cache/src/CacheConfig.php +++ /dev/null @@ -1,98 +0,0 @@ -[] */ - public array $caches = []; - - public ?bool $enable; - - public bool $iconCache = false; - - public bool $projectCache = false; - - public bool $viewCache = false; - - public DiscoveryCacheStrategy $discoveryCache; - - public function __construct( - /** - * Path to the storage directory, relative to the internal storage. - */ - public string $directory = 'cache', - public ?CacheItemPoolInterface $projectCachePool = null, - - /** Used as a global override, should be true in production, null in local */ - ?bool $enable = null, - ) { - $this->enable = $this->enableCache($enable); - $this->iconCache = (bool) env('ICON_CACHE', true); - $this->projectCache = (bool) env('PROJECT_CACHE', false); - $this->viewCache = (bool) env('VIEW_CACHE', false); - $this->discoveryCache = $this->resolveDiscoveryCacheStrategy(); - $this->directory = internal_storage_path($directory); - } - - /** @param class-string<\Tempest\Cache\Cache> $className */ - public function addCache(string $className): void - { - $this->caches[] = $className; - } - - private function resolveDiscoveryCacheStrategy(): DiscoveryCacheStrategy - { - if ($this->isDiscoveryGenerateCommand()) { - return DiscoveryCacheStrategy::NONE; - } - - $cache = env('CACHE'); - - if ($cache !== null) { - $current = DiscoveryCacheStrategy::make($cache); - } else { - $current = DiscoveryCacheStrategy::make(env('DISCOVERY_CACHE')); - } - - if ($current === DiscoveryCacheStrategy::NONE) { - return $current; - } - - $original = DiscoveryCacheStrategy::make(@file_get_contents(DiscoveryCache::getCurrentDiscoverStrategyCachePath())); - - if ($current !== $original) { - return DiscoveryCacheStrategy::INVALID; - } - - return $current; - } - - private function enableCache(?bool $enable): ?bool - { - if ($this->isDiscoveryGenerateCommand()) { - return false; - } - - return $enable ?? env('CACHE'); - } - - private function isDiscoveryGenerateCommand(): bool - { - if (PHP_SAPI !== 'cli') { - return false; - } - - $command = $_SERVER['argv'][1] ?? null; - - return $command === 'dg' || $command === 'discovery:generate'; - } -} diff --git a/packages/cache/src/CacheDiscovery.php b/packages/cache/src/CacheDiscovery.php deleted file mode 100644 index 493f2362c..000000000 --- a/packages/cache/src/CacheDiscovery.php +++ /dev/null @@ -1,33 +0,0 @@ -implements(Cache::class)) { - $this->discoveryItems->add($location, $class->getName()); - } - } - - public function apply(): void - { - foreach ($this->discoveryItems as $className) { - $this->cacheConfig->addCache($className); - } - } -} diff --git a/packages/cache/src/CacheException.php b/packages/cache/src/CacheException.php new file mode 100644 index 000000000..56297fdd4 --- /dev/null +++ b/packages/cache/src/CacheException.php @@ -0,0 +1,7 @@ +getType()->matches(Cache::class); + } + #[Singleton] - public function initialize(Container $container): Cache|ProjectCache + public function initialize(ClassReflector $class, ?string $tag, Container $container): Cache + { + return new GenericCache( + cacheConfig: $container->get(CacheConfig::class, $tag), + enabled: $this->shouldCacheBeEnabled($tag), + ); + } + + private function shouldCacheBeEnabled(?string $tag): bool { - return new ProjectCache($container->get(CacheConfig::class)); + $globalCacheEnabled = (bool) env('CACHE_ENABLED', default: true); + + if (! $tag) { + return $globalCacheEnabled; + } + + $environmentVariableName = str($tag) + ->snake() + ->upper() + ->prepend('CACHE_') + ->append('_ENABLED') + ->toString(); + + return (bool) env($environmentVariableName, default: $globalCacheEnabled); } } diff --git a/packages/cache/src/CacheStatusCommand.php b/packages/cache/src/CacheStatusCommand.php deleted file mode 100644 index 2611a286a..000000000 --- a/packages/cache/src/CacheStatusCommand.php +++ /dev/null @@ -1,46 +0,0 @@ -console->header('Cache status'); - $this->console->keyValue('Total caches', (string) count($this->cacheConfig->caches)); - $this->console->keyValue('Global cache', match ($this->cacheConfig->enable) { - true => 'ENABLED', - false => 'FORCEFULLY DISABLED', - default => 'DISABLED', - }); - - foreach ($this->cacheConfig->caches as $cacheClass) { - /** @var Cache $cache */ - $cache = $this->container->get($cacheClass); - - $this->console->keyValue( - key: $cacheClass, - value: match ($cache->isEnabled()) { - true => 'ENABLED', - false => 'DISABLED', - }, - ); - } - } -} diff --git a/packages/cache/src/Commands/CacheClearCommand.php b/packages/cache/src/Commands/CacheClearCommand.php new file mode 100644 index 000000000..9c8e0881c --- /dev/null +++ b/packages/cache/src/Commands/CacheClearCommand.php @@ -0,0 +1,125 @@ +container instanceof GenericContainer)) { + $this->console->error('Clearing caches is only available when using the default container.'); + return; + } + + if ($internal) { + $this->clearInternalCaches($all); + } else { + $this->clearUserCaches($tag, $all); + } + } + + private function clearInternalCaches(bool $all = false): void + { + $caches = [ConfigCache::class, ViewCache::class, IconCache::class, DiscoveryCache::class]; + + if ($all === false && count($caches) > 1) { + $caches = $this->ask( + question: 'Which caches do you want to clear?', + options: $caches, + multiple: true, + ); + } + + if (count($caches) === 0) { + $this->console->info('No cache selected.'); + return; + } + + $this->console->header('Internal caches'); + + foreach ($caches as $cache) { + $cache = $this->container->get($cache); + $cache->clear(); + + $this->console->keyValue( + key: $cache::class, + value: "CLEARED", + ); + } + } + + private function clearUserCaches(?string $tag = null, bool $all = false): void + { + if ($tag && $all) { + $this->console->error('You cannot specify both a tag and clear all caches.'); + return; + } + + /** @var GenericContainer $container */ + $container = $this->container; + $cacheTags = arr($container->getSingletons(CacheConfig::class)) + ->map(fn ($_, string $key) => $key === CacheConfig::class ? self::DEFAULT_CACHE : Str\after_last($key, '#')) + ->filter(fn ($_, string $key) => in_array($tag, [null, self::DEFAULT_CACHE], strict: true) ? true : str($key)->afterLast('#')->equals($tag)) + ->values(); + + if ($all === false && count($cacheTags) > 1) { + $cacheTags = $this->ask( + question: 'Which caches do you want to clear?', + options: $cacheTags, + multiple: true, + ); + } + + if (count($cacheTags) === 0) { + $this->console->info('No cache selected.'); + return; + } + + $this->console->header('User caches'); + + foreach ($cacheTags as $tag) { + $cache = $this->container->get(Cache::class, $tag === self::DEFAULT_CACHE ? null : $tag); + $cache->clear(); + + $this->console->keyValue( + key: $tag, + value: "CLEARED", + ); + } + } +} diff --git a/packages/cache/src/Commands/CacheStatusCommand.php b/packages/cache/src/Commands/CacheStatusCommand.php new file mode 100644 index 000000000..3bda5ebb9 --- /dev/null +++ b/packages/cache/src/Commands/CacheStatusCommand.php @@ -0,0 +1,110 @@ +container instanceof GenericContainer)) { + $this->console->error('Clearing caches is only available when using the default container.'); + return; + } + + $caches = arr($this->container->getSingletons(CacheConfig::class)) + ->map(fn ($_, string $key) => $this->container->get(Cache::class, $key === CacheConfig::class ? null : Str\after_last($key, '#'))) + ->values(); + + if ($internal) { + $this->console->header('Internal caches'); + + foreach ([ConfigCache::class, ViewCache::class, IconCache::class] as $cacheName) { + /** @var Cache $cache */ + $cache = $this->container->get($cacheName); + + $this->console->keyValue( + key: $cacheName, + value: match ($cache->enabled) { + true => 'ENABLED', + false => 'DISABLED', + }, + ); + } + + $this->console->keyValue( + key: DiscoveryCache::class, + value: match ($this->discoveryCache->valid) { + false => 'INVALID', + true => match ($this->discoveryCache->enabled) { + true => 'ENABLED', + false => 'DISABLED', + }, + }, + ); + + if ($this->appConfig->environment->isProduction() && ! $this->discoveryCache->enabled) { + $this->console->writeln(); + $this->console->error('Discovery cache is disabled in production. This is not recommended.'); + } + } + + $this->console->header('User caches'); + + /** @var Cache $cache */ + foreach ($caches as $cache) { + $this->console->keyValue( + key: $this->getCacheName($cache), + value: match ($cache->enabled) { + true => 'ENABLED', + false => 'DISABLED', + }, + ); + } + } + + private function getCacheName(Cache $cache): string + { + if (! ($cache instanceof GenericCache)) { + return $cache::class; + } + + $tag = $cache->cacheConfig->tag; + + if ($tag instanceof UnitEnum) { + return $tag->name; + } + + return $tag ?? 'default'; + } +} diff --git a/packages/cache/src/Config/CacheConfig.php b/packages/cache/src/Config/CacheConfig.php new file mode 100644 index 000000000..646a195de --- /dev/null +++ b/packages/cache/src/Config/CacheConfig.php @@ -0,0 +1,14 @@ + + */ + private string $adapter, + + /* + * Identifies the {@see \Tempest\Cache\Cache} instance in the container, in case you need more than one configuration. + */ + public null|string|UnitEnum $tag = null, + ) {} + + public function createAdapter(): AdapterInterface + { + return get($this->adapter); + } +} diff --git a/packages/cache/src/Config/FilesystemCacheConfig.php b/packages/cache/src/Config/FilesystemCacheConfig.php new file mode 100644 index 000000000..d7b3fc2d5 --- /dev/null +++ b/packages/cache/src/Config/FilesystemCacheConfig.php @@ -0,0 +1,39 @@ +namespace ?? '', + directory: $this->directory ?? internal_storage_path('cache/project'), + ); + } +} diff --git a/packages/cache/src/Config/InMemoryCacheConfig.php b/packages/cache/src/Config/InMemoryCacheConfig.php new file mode 100644 index 000000000..d9429ac77 --- /dev/null +++ b/packages/cache/src/Config/InMemoryCacheConfig.php @@ -0,0 +1,29 @@ +toPsrClock(), + ); + } +} diff --git a/packages/cache/src/Config/PhpCacheConfig.php b/packages/cache/src/Config/PhpCacheConfig.php new file mode 100644 index 000000000..f5f1c68f0 --- /dev/null +++ b/packages/cache/src/Config/PhpCacheConfig.php @@ -0,0 +1,40 @@ +namespace ?? '', + directory: $this->directory ?? internal_storage_path('cache/project'), + ); + } +} diff --git a/packages/cache/src/Config/cache.config.php b/packages/cache/src/Config/cache.config.php deleted file mode 100644 index 97095d7c8..000000000 --- a/packages/cache/src/Config/cache.config.php +++ /dev/null @@ -1,7 +0,0 @@ -adapter ??= $this->cacheConfig->createAdapter(); + } + + public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock + { + if ($expiration instanceof Duration) { + $expiration = DateTime::now()->plus($expiration); + } + + return new GenericLock( + key: (string) $key, + owner: $owner ? ((string) $owner) : Random\secure_string(length: 10), + cache: $this, + expiration: $expiration, + ); + } + + public function has(Stringable|string $key): bool + { + if (! $this->enabled) { + return false; + } + + return $this->adapter->getItem((string) $key)->isHit(); + } + + public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface + { + $item = $this->adapter + ->getItem((string) $key) + ->set($value); + + if ($expiration instanceof Duration) { + $expiration = DateTime::now()->plus($expiration); + } + + if ($expiration !== null) { + $item = $item->expiresAt($expiration->toNativeDateTime()); + } + + if ($this->enabled) { + $this->adapter->save($item); + } + + return $item; + } + + public function putMany(iterable $values, null|Duration|DateTimeInterface $expiration = null): array + { + $items = []; + + foreach ($values as $key => $value) { + $items[(string) $key] = $this->put($key, $value, $expiration); + } + return $items; + } + + public function increment(Stringable|string $key, int $by = 1): int + { + if (! $this->enabled) { + return 0; + } + + $item = $this->adapter->getItem((string) $key); + + if (! $item->isHit()) { + $item->set($by); + } elseif (! is_numeric($item->get())) { + throw new NotNumberException((string) $key); + } else { + $item->set(((int) $item->get()) + $by); + } + + $this->adapter->save($item); + + return (int) $item->get(); + } + + public function decrement(Stringable|string $key, int $by = 1): int + { + if (! $this->enabled) { + return 0; + } + + $item = $this->adapter->getItem((string) $key); + + if (! $item->isHit()) { + $item->set(-$by); + } elseif (! is_numeric($item->get())) { + throw new NotNumberException((string) $key); + } else { + $item->set(((int) $item->get()) - $by); + } + + $this->adapter->save($item); + + return (int) $item->get(); + } + + public function get(Stringable|string $key): mixed + { + if (! $this->enabled) { + return null; + } + + return $this->adapter->getItem((string) $key)->get(); + } + + public function getMany(iterable $key): array + { + return Arr\map_with_keys( + array: $key, + map: fn (string|Stringable $key) => yield (string) $key => $this->adapter->getItem((string) $key)->get(), + ); + } + + public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed + { + if (! $this->enabled) { + return $callback(); + } + + $item = $this->adapter->getItem((string) $key); + + if (! $item->isHit()) { + $item = $this->put((string) $key, $callback(), $expiration); + } + + return $item->get(); + } + + public function remove(Stringable|string $key): void + { + if (! $this->enabled) { + return; + } + + $this->adapter->deleteItem((string) $key); + } + + public function clear(): void + { + if (! $this->adapter->clear()) { + throw new CouldNotClearCache(); + } + } +} diff --git a/packages/cache/src/GenericLock.php b/packages/cache/src/GenericLock.php new file mode 100644 index 000000000..64ce87e5c --- /dev/null +++ b/packages/cache/src/GenericLock.php @@ -0,0 +1,82 @@ +cache->has($this->key); + } + + return $this->cache->get($this->key) === ((string) $by); + } + + public function acquire(): bool + { + if ($this->locked()) { + return false; + } + + $this->cache->put( + key: $this->key, + value: $this->owner, + expiration: $this->expiration, + ); + + return true; + } + + public function execute(Closure $callback, null|DateTimeInterface|Duration $wait = null): mixed + { + $wait ??= Datetime::now(); + $waitUntil = ($wait instanceof Duration) + ? DateTime::now()->plus($wait) + : $wait; + + while (! $this->acquire()) { + if ($waitUntil->beforeOrAtTheSameTime(DateTime::now())) { + throw new LockAcquisitionTimedOutException($this->key); + } + + usleep(250); // TODO: sleep from clock? + } + + try { + return $callback(); + } finally { + $this->release(); + } + } + + public function release(bool $force = false): bool + { + if (! $this->locked()) { + return false; + } + + $lock = $this->cache->get($this->key); + + if ($lock !== $this->owner && ! $force) { + return false; + } + + $this->cache->remove($this->key); + + return true; + } +} diff --git a/packages/cache/src/IconCache.php b/packages/cache/src/IconCache.php deleted file mode 100644 index 2c905a585..000000000 --- a/packages/cache/src/IconCache.php +++ /dev/null @@ -1,33 +0,0 @@ -pool = new FilesystemAdapter( - directory: $this->cacheConfig->directory . '/icons', - ); - } - - protected function getCachePool(): CacheItemPoolInterface - { - return $this->pool; - } - - public function isEnabled(): bool - { - return $this->cacheConfig->enable ?? $this->cacheConfig->iconCache; - } -} diff --git a/packages/cache/src/InternalCacheInsightsProvider.php b/packages/cache/src/InternalCacheInsightsProvider.php new file mode 100644 index 000000000..746742555 --- /dev/null +++ b/packages/cache/src/InternalCacheInsightsProvider.php @@ -0,0 +1,47 @@ + match ($this->discoveryCache->valid) { + false => new Insight('Invalid', Insight::ERROR), + true => match ($this->discoveryCache->enabled) { + true => new Insight('Enabled', Insight::ERROR), + false => new Insight('Disabled', Insight::WARNING), + }, + }, + 'Configuration' => $this->getInsight($this->configCache->enabled), + 'View' => $this->getInsight($this->viewCache->enabled), + 'Icon' => $this->getInsight($this->iconCache->enabled), + ]; + } + + private function getInsight(bool $enabled): Insight + { + if ($enabled) { + return new Insight('ENABLED', Insight::SUCCESS); + } + + return new Insight('DISABLED', Insight::WARNING); + } +} diff --git a/packages/cache/src/IsCache.php b/packages/cache/src/IsCache.php deleted file mode 100644 index 3248fc42a..000000000 --- a/packages/cache/src/IsCache.php +++ /dev/null @@ -1,73 +0,0 @@ -getCachePool() - ->getItem($key) - ->set($value); - - if ($expiresAt !== null) { - $item = $item->expiresAt($expiresAt->toNativeDateTime()); - } - - if ($this->isEnabled()) { - $this->getCachePool()->save($item); - } - - return $item; - } - - public function get(string $key): mixed - { - if (! $this->isEnabled()) { - return null; - } - - return $this->getCachePool()->getItem($key)->get(); - } - - /** @param Closure(): mixed $cache */ - public function resolve(string $key, Closure $cache, ?DateTimeInterface $expiresAt = null): mixed - { - if (! $this->isEnabled()) { - return $cache(); - } - - $item = $this->getCachePool()->getItem($key); - - if (! $item->isHit()) { - $item = $this->put($key, $cache(), $expiresAt); - } - - return $item->get(); - } - - public function remove(string $key): void - { - if (! $this->isEnabled()) { - return; - } - - $this->getCachePool()->deleteItem($key); - } - - public function clear(): void - { - if (! $this->getCachePool()->clear()) { - throw new CouldNotClearCache(); - } - } -} diff --git a/packages/cache/src/Lock.php b/packages/cache/src/Lock.php new file mode 100644 index 000000000..0c1e684b3 --- /dev/null +++ b/packages/cache/src/Lock.php @@ -0,0 +1,59 @@ +pool = $this->cacheConfig->projectCachePool ?? new FilesystemAdapter( - directory: $this->cacheConfig->directory . '/project', - ); - } - - protected function getCachePool(): CacheItemPoolInterface - { - return $this->pool; - } - - public function isEnabled(): bool - { - return $this->cacheConfig->enable ?? $this->cacheConfig->projectCache; - } -} diff --git a/packages/cache/src/Testing/CacheTester.php b/packages/cache/src/Testing/CacheTester.php new file mode 100644 index 000000000..5dfe73e20 --- /dev/null +++ b/packages/cache/src/Testing/CacheTester.php @@ -0,0 +1,52 @@ + to_kebab_case($tag), + $tag instanceof UnitEnum => to_kebab_case($tag->name), + default => 'default', + }, + clock: $this->container->get(Clock::class)->toPsrClock(), + ); + + $this->container->singleton(Cache::class, $cache, $tag); + + return $cache; + } + + /** + * Prevents cache from being used without a fake. + */ + public function preventUsageWithoutFake(): void + { + if (! ($this->container instanceof GenericContainer)) { + throw new \RuntimeException('Container is not a GenericContainer, unable to prevent usage without fake.'); + } + + $this->container->unregister(Cache::class, tagged: true); + $this->container->removeInitializer(CacheInitializer::class); + $this->container->addInitializer(RestrictedCacheInitializer::class); + } +} diff --git a/packages/cache/src/Testing/RestrictedCache.php b/packages/cache/src/Testing/RestrictedCache.php new file mode 100644 index 000000000..b8714cebd --- /dev/null +++ b/packages/cache/src/Testing/RestrictedCache.php @@ -0,0 +1,75 @@ +tag); + } + + public function has(Stringable|string $key): bool + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function putMany(iterable $values, null|Duration|DateTimeInterface $expiration = null): array + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function increment(Stringable|string $key, int $by = 1): int + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function decrement(Stringable|string $key, int $by = 1): int + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function get(Stringable|string $key): mixed + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function getMany(iterable $key): array + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function remove(Stringable|string $key): void + { + throw new ForbiddenCacheUsageException($this->tag); + } + + public function clear(): void + { + throw new ForbiddenCacheUsageException($this->tag); + } +} diff --git a/packages/cache/src/Testing/RestrictedCacheInitializer.php b/packages/cache/src/Testing/RestrictedCacheInitializer.php new file mode 100644 index 000000000..c5a7573d8 --- /dev/null +++ b/packages/cache/src/Testing/RestrictedCacheInitializer.php @@ -0,0 +1,25 @@ +getType()->matches(Cache::class); + } + + #[Singleton] + public function initialize(ClassReflector $class, ?string $tag, Container $container): Cache + { + return new RestrictedCache($tag); + } +} diff --git a/packages/cache/src/Testing/TestingCache.php b/packages/cache/src/Testing/TestingCache.php new file mode 100644 index 000000000..12769190e --- /dev/null +++ b/packages/cache/src/Testing/TestingCache.php @@ -0,0 +1,209 @@ + $this->cache->enabled; + set => $this->cache->enabled = $value; + } + + private Cache $cache; + private ArrayAdapter $adapter; + + public function __construct( + public string $tag, + ClockInterface $clock, + ) { + $this->adapter = new ArrayAdapter(clock: $clock); + $this->cache = new GenericCache( + cacheConfig: new CustomCacheConfig(adapter: ArrayAdapter::class, tag: $tag), + adapter: $this->adapter, + ); + } + + public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): TestingLock + { + return new TestingLock(new GenericLock( + key: (string) $key, + owner: $owner ? ((string) $owner) : Random\secure_string(length: 10), + cache: $this->cache, + expiration: $expiration, + )); + } + + public function has(Stringable|string $key): bool + { + return $this->cache->has($key); + } + + public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface + { + return $this->cache->put($key, $value, $expiration); + } + + public function putMany(iterable $values, null|Duration|DateTimeInterface $expiration = null): array + { + return $this->cache->putMany($values, $expiration); + } + + public function increment(Stringable|string $key, int $by = 1): int + { + return $this->cache->increment($key, $by); + } + + public function decrement(Stringable|string $key, int $by = 1): int + { + return $this->cache->decrement($key, $by); + } + + public function get(Stringable|string $key): mixed + { + return $this->cache->get($key); + } + + public function getMany(iterable $key): array + { + return $this->cache->getMany($key); + } + + public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null): mixed + { + return $this->cache->resolve($key, $callback, $expiration); + } + + public function remove(Stringable|string $key): void + { + $this->cache->remove($key); + } + + public function clear(): void + { + $this->cache->clear(); + } + + /** + * Asserts that the given `$key` is cached and matches the expected `$value`. + */ + public function assertKeyHasValue(Stringable|string $key, mixed $value): self + { + $this->assertCached($key); + + Assert::assertSame( + expected: $value, + actual: $this->get($key), + message: "Cache key [{$key}] does not match the expected value.", + ); + + return $this; + } + + /** + * Asserts that the given `$key` is cached and does not match the given `$value`. + */ + public function assertKeyDoesNotHaveValue(Stringable|string $key, mixed $value): self + { + $this->assertCached($key); + + Assert::assertNotSame( + expected: $value, + actual: $this->get($key), + message: "Cache key [{$key}] matches the given value.", + ); + + return $this; + } + + /** + * Asserts that the given `$key` is cached. + * + * @param Closure(mixed $value): mixed $callback Optional callback to assert the value of the cached item. + */ + public function assertCached(string $key, ?Closure $callback = null): self + { + Assert::assertTrue( + condition: $this->get($key) !== null, + message: "Cache key [{$key}] was not cached.", + ); + + if ($callback && false === $callback($this->get($key))) { + Assert::fail("Cache key [{$key}] failed the assertion."); + } + + return $this; + } + + /** + * Asserts that the given `$key` is not cached. + */ + public function assertNotCached(string $key): self + { + Assert::assertFalse( + condition: $this->get($key) !== null, + message: "Cache key [{$key}] was cached.", + ); + + return $this; + } + + /** + * Asserts that the cache is empty. + */ + public function assertEmpty(): self + { + Assert::assertTrue( + condition: $this->adapter->getValues() === [], + message: 'Cache is not empty.', + ); + + return $this; + } + + /** + * Asserts that the cache is not empty. + */ + public function assertNotEmpty(): self + { + Assert::assertTrue( + condition: $this->adapter->getValues() !== [], + message: 'Cache is empty.', + ); + + return $this; + } + + /** + * Asserts that the specified lock is being held. + */ + public function assertLocked(string|Stringable $key, null|Stringable|string $by = null, null|DateTimeInterface|Duration $until = null): self + { + $this->lock($key)->assertLocked($by, $until); + + return $this; + } + + /** + * Asserts that the specified lock is not being held. + */ + public function assertNotLocked(string|Stringable $key, null|Stringable|string $by = null): self + { + $this->lock($key)->assertNotLocked($by); + + return $this; + } +} diff --git a/packages/cache/src/Testing/TestingLock.php b/packages/cache/src/Testing/TestingLock.php new file mode 100644 index 000000000..9dcaf26ce --- /dev/null +++ b/packages/cache/src/Testing/TestingLock.php @@ -0,0 +1,92 @@ + $this->lock->key; + } + + public ?DateTimeInterface $expiration { + get => $this->lock->expiration; + } + + public string $owner { + get => $this->lock->owner; + } + + public function __construct( + private readonly GenericLock $lock, + ) {} + + public function acquire(): bool + { + return $this->lock->acquire(); + } + + public function locked(null|Stringable|string $by = null): bool + { + return $this->lock->locked($by); + } + + public function execute(Closure $callback, null|DateTimeInterface|Duration $wait = null): mixed + { + return $this->lock->execute($callback, $wait); + } + + public function release(bool $force = false): bool + { + return $this->lock->release($force); + } + + /** + * Asserts that the specified lock is being held. + */ + public function assertLocked(null|Stringable|string $by = null, null|DateTimeInterface|Duration $until = null): self + { + Assert::assertTrue( + condition: $this->locked($by), + message: $by + ? "Lock `{$this->key}` is not being held by `{$by}`." + : "Lock `{$this->key}` is not being held.", + ); + + if ($until) { + if ($until instanceof Duration) { + $until = DateTime::now()->plus($until); + } + + Assert::assertTrue( + condition: $this->expiration->afterOrAtTheSameTime($until), + message: "Expected lock `{$this->key}` to expire at or after `{$until}`, but it expires at `{$this->expiration}`.", + ); + } + + return $this; + } + + /** + * Asserts that the specified lock is not being held. + */ + public function assertNotLocked(null|Stringable|string $by = null): self + { + Assert::assertFalse( + condition: $this->locked($by), + message: $by + ? "Lock `{$this->key}` is being held by `{$by}`." + : "Lock `{$this->key}` is being held.", + ); + + return $this; + } +} diff --git a/packages/cache/src/UserCacheInsightsProvider.php b/packages/cache/src/UserCacheInsightsProvider.php new file mode 100644 index 000000000..8ee3e037b --- /dev/null +++ b/packages/cache/src/UserCacheInsightsProvider.php @@ -0,0 +1,71 @@ +container instanceof GenericContainer)) { + return []; + } + + return arr($this->container->getSingletons(CacheConfig::class)) + ->map(fn ($_, string $key) => $this->container->get(Cache::class, $key === CacheConfig::class ? null : Str\after_last($key, '#'))) + ->mapWithKeys(fn (Cache $cache) => yield $this->getCacheName($cache) => $this->getInsight($cache)) + ->toArray(); + } + + /** @var Insight[] */ + private function getInsight(Cache $cache): array + { + $type = ($cache instanceof GenericCache) + ? match (get_class($cache->cacheConfig)) { + FilesystemCacheConfig::class => new Insight('Filesystem'), + PhpCacheConfig::class => new Insight('PHP'), + InMemoryCacheConfig::class => new Insight('In-memory'), + default => null, + } + : null; + + if ($cache->enabled) { + return [$type, new Insight('ENABLED', Insight::SUCCESS)]; + } + + return [$type, new Insight('DISABLED', Insight::WARNING)]; + } + + private function getCacheName(Cache $cache): string + { + if (! ($cache instanceof GenericCache)) { + return $cache::class; + } + + $tag = $cache->cacheConfig->tag; + + if ($tag instanceof UnitEnum) { + return $tag->name; + } + + return $tag ?? 'default'; + } +} diff --git a/packages/cache/src/cache.config.php b/packages/cache/src/cache.config.php new file mode 100644 index 000000000..b0616892f --- /dev/null +++ b/packages/cache/src/cache.config.php @@ -0,0 +1,9 @@ +console->writeRaw(Json\encode($json)); } - private function formatInsight(Stringable|Insight|string $value): string + private function formatInsight(Stringable|Insight|array|string $value): string { - if ($value instanceof Insight) { - return $value->formattedValue; - } - - return (string) $value; + return arr($value) + ->filter() + ->map(function (Stringable|Insight|string $value) { + if ($value instanceof Insight) { + return $value->formattedValue; + } + + return (string) $value; + }) + ->implode(', ') + ->toString(); } - private function rawInsight(Stringable|Insight|string $value): string + private function rawInsight(Stringable|Insight|array|string $value): array { - if ($value instanceof Insight) { - return $value->value; - } - - return Str\strip_tags((string) $value); + return arr($value) + ->filter() + ->map(function (Stringable|Insight|string $value) { + if ($value instanceof Insight) { + return $value->value; + } + + return Str\strip_tags((string) $value); + }) + ->toArray(); } } diff --git a/packages/console/src/Commands/MakeConfigCommand.php b/packages/console/src/Commands/MakeConfigCommand.php index ff6792da5..198d83d79 100644 --- a/packages/console/src/Commands/MakeConfigCommand.php +++ b/packages/console/src/Commands/MakeConfigCommand.php @@ -58,7 +58,6 @@ private function getStubFileFromConfigType(ConfigType $configType): StubFile return match ($configType) { ConfigType::CONSOLE => StubFile::from($stubPath . '/console.config.stub.php'), - ConfigType::CACHE => StubFile::from($stubPath . '/cache.config.stub.php'), ConfigType::LOG => StubFile::from($stubPath . '/log.config.stub.php'), ConfigType::COMMAND_BUS => StubFile::from($stubPath . '/command-bus.config.stub.php'), ConfigType::EVENT_BUS => StubFile::from($stubPath . '/event-bus.config.stub.php'), diff --git a/packages/console/src/Enums/ConfigType.php b/packages/console/src/Enums/ConfigType.php index 42ffc0720..26f483a5e 100644 --- a/packages/console/src/Enums/ConfigType.php +++ b/packages/console/src/Enums/ConfigType.php @@ -16,6 +16,5 @@ enum ConfigType: string case EVENT_BUS = 'event-bus'; case COMMAND_BUS = 'command-bus'; case LOG = 'log'; - case CACHE = 'cache'; case CONSOLE = 'console'; } diff --git a/packages/console/src/Middleware/CautionMiddleware.php b/packages/console/src/Middleware/CautionMiddleware.php index bd868c25e..f702223c7 100644 --- a/packages/console/src/Middleware/CautionMiddleware.php +++ b/packages/console/src/Middleware/CautionMiddleware.php @@ -25,7 +25,7 @@ public function __invoke(Invocation $invocation, ConsoleMiddlewareCallable $next $environment = $this->appConfig->environment; if ($environment->isProduction() || $environment->isStaging()) { - $continue = $this->console->confirm('Caution! Do you wish to continue?'); + $continue = $this->console->confirm('This command might be destructive. Do you wish to continue?'); if (! $continue) { return ExitCode::CANCELLED; diff --git a/packages/console/src/Middleware/OverviewMiddleware.php b/packages/console/src/Middleware/OverviewMiddleware.php index 1d1908d04..0611b7800 100644 --- a/packages/console/src/Middleware/OverviewMiddleware.php +++ b/packages/console/src/Middleware/OverviewMiddleware.php @@ -45,7 +45,7 @@ private function renderOverview(bool $showHidden = false): void subheader: 'This is an overview of available commands.' . PHP_EOL . 'Type --help to get more help about a specific command.', ); - if ($this->discoveryCache->isEnabled()) { + if ($this->discoveryCache->enabled) { $this->console->writeln(); $this->console->error('Caution: discovery cache is enabled'); } @@ -93,7 +93,7 @@ private function renderOverview(bool $showHidden = false): void $this->console ->unless( - condition: $this->discoveryCache->isValid(), + condition: $this->discoveryCache->valid, callback: fn (Console $console) => $console->writeln()->error('Discovery cache invalid. Run discovery:generate to enable discovery caching.'), ); } diff --git a/packages/console/src/Stubs/cache.config.stub.php b/packages/console/src/Stubs/cache.config.stub.php deleted file mode 100644 index 97095d7c8..000000000 --- a/packages/console/src/Stubs/cache.config.stub.php +++ /dev/null @@ -1,7 +0,0 @@ -definitions->getArrayCopy(); } - public function getSingletons(): array + /** + * Returns all registered singletons. If `$interface` is specified, returns only singletons for that interface. + */ + public function getSingletons(?string $interface = null): array { - return $this->singletons->getArrayCopy(); + $singletons = $this->singletons->getArrayCopy(); + + if (is_null($interface)) { + return $singletons; + } + + return array_filter( + array: $singletons, + callback: static fn (mixed $_, string $key) => str_starts_with($key, "{$interface}#") || $key === $interface, + mode: \ARRAY_FILTER_USE_BOTH, + ); } public function getInitializers(): array diff --git a/packages/core/composer.json b/packages/core/composer.json index ec8c1877b..f387555f6 100644 --- a/packages/core/composer.json +++ b/packages/core/composer.json @@ -10,6 +10,7 @@ "tempest/reflection": "dev-main", "tempest/support": "dev-main", "vlucas/phpdotenv": "^5.6", + "symfony/cache": "^7.2", "filp/whoops": "^2.15" }, "autoload": { diff --git a/packages/core/src/Commands/DiscoveryGenerateCommand.php b/packages/core/src/Commands/DiscoveryGenerateCommand.php index ef5e372aa..4081d09da 100644 --- a/packages/core/src/Commands/DiscoveryGenerateCommand.php +++ b/packages/core/src/Commands/DiscoveryGenerateCommand.php @@ -5,12 +5,13 @@ namespace Tempest\Core\Commands; use Closure; -use Tempest\Cache\DiscoveryCacheStrategy; use Tempest\Console\ConsoleCommand; use Tempest\Console\HasConsole; use Tempest\Container\Container; use Tempest\Container\GenericContainer; +use Tempest\Core\AppConfig; use Tempest\Core\DiscoveryCache; +use Tempest\Core\DiscoveryCacheStrategy; use Tempest\Core\DiscoveryConfig; use Tempest\Core\FrameworkKernel; use Tempest\Core\Kernel; @@ -25,12 +26,13 @@ public function __construct( private Kernel $kernel, private DiscoveryCache $discoveryCache, + private AppConfig $appConfig, ) {} #[ConsoleCommand(name: 'discovery:generate', description: 'Compile and cache all discovery according to the configured discovery caching strategy')] public function __invoke(): void { - $strategy = $this->resolveDiscoveryCacheStrategy(); + $strategy = DiscoveryCacheStrategy::make(env('DISCOVERY_CACHE', default: $this->appConfig->environment->isProduction())); if ($strategy === DiscoveryCacheStrategy::NONE) { $this->info('Discovery cache disabled, nothing to generate.'); @@ -78,17 +80,6 @@ public function generateDiscoveryCache(DiscoveryCacheStrategy $strategy, Closure } } - private function resolveDiscoveryCacheStrategy(): DiscoveryCacheStrategy - { - $cache = env('CACHE'); - - if ($cache !== null) { - return DiscoveryCacheStrategy::make($cache); - } - - return DiscoveryCacheStrategy::make(env('DISCOVERY_CACHE')); - } - public function resolveKernel(): Kernel { $container = new GenericContainer(); diff --git a/packages/core/src/Commands/DiscoveryStatusCommand.php b/packages/core/src/Commands/DiscoveryStatusCommand.php index 0131a9ee2..e5814e84c 100644 --- a/packages/core/src/Commands/DiscoveryStatusCommand.php +++ b/packages/core/src/Commands/DiscoveryStatusCommand.php @@ -4,11 +4,11 @@ namespace Tempest\Core\Commands; -use Tempest\Cache\DiscoveryCacheStrategy; use Tempest\Console\Console; use Tempest\Console\ConsoleArgument; use Tempest\Console\ConsoleCommand; use Tempest\Core\DiscoveryCache; +use Tempest\Core\DiscoveryCacheStrategy; use Tempest\Core\Kernel; use function Tempest\root_path; @@ -32,17 +32,17 @@ public function __invoke( $this->console->header('Discovery status'); $this->console->keyValue('Registered locations', (string) count($this->kernel->discoveryLocations)); $this->console->keyValue('Loaded discovery classes', (string) count($this->kernel->discoveryClasses)); - $this->console->keyValue('Cache', match ($this->discoveryCache->isEnabled()) { + $this->console->keyValue('Cache', match ($this->discoveryCache->enabled) { true => 'ENABLED', false => 'DISABLED', }); - $this->console->keyValue('Cache strategy', match ($this->discoveryCache->getStrategy()) { + $this->console->keyValue('Cache strategy', match ($this->discoveryCache->strategy) { DiscoveryCacheStrategy::FULL => 'FULL', DiscoveryCacheStrategy::PARTIAL => 'PARTIAL', DiscoveryCacheStrategy::NONE => 'NO CACHING', DiscoveryCacheStrategy::INVALID => 'INVALID', }); - $this->console->keyValue('Cache validity', match ($this->discoveryCache->isValid()) { + $this->console->keyValue('Cache validity', match ($this->discoveryCache->valid) { true => 'OK', false => 'CORRUPTED', }); diff --git a/packages/core/src/ConfigCache.php b/packages/core/src/ConfigCache.php index 103313efa..5a156b5ce 100644 --- a/packages/core/src/ConfigCache.php +++ b/packages/core/src/ConfigCache.php @@ -1,37 +1,66 @@ pool ??= new FilesystemAdapter( + directory: internal_storage_path('cache/config'), + ); + } - public function __construct() + public function clear(): void { - $this->pool = new FilesystemAdapter( - directory: internal_storage_path('config'), - ); + $this->pool->clear(); } - protected function getCachePool(): CacheItemPoolInterface + public function put(string $key, mixed $value, null|Duration|DateTimeInterface $expiresAt = null): CacheItemInterface { - return $this->pool; + $item = $this->pool + ->getItem($key) + ->set($value); + + if ($expiresAt instanceof Duration) { + $expiresAt = DateTime::now()->plus($expiresAt); + } + + if ($expiresAt !== null) { + $item = $item->expiresAt($expiresAt->toNativeDateTime()); + } + + if ($this->enabled) { + $this->pool->save($item); + } + + return $item; } - public function isEnabled(): bool + public function resolve(string $key, Closure $callback, null|Duration|DateTimeInterface $expiresAt = null): mixed { - return (bool) (env('CACHE') ?? env('CONFIG_CACHE', false)); + if (! $this->enabled) { + return $callback(); + } + + $item = $this->pool->getItem($key); + + if (! $item->isHit()) { + $item = $this->put($key, $callback(), $expiresAt); + } + + return $item->get(); } } diff --git a/packages/core/src/ConfigCacheInitializer.php b/packages/core/src/ConfigCacheInitializer.php new file mode 100644 index 000000000..351df4cdf --- /dev/null +++ b/packages/core/src/ConfigCacheInitializer.php @@ -0,0 +1,31 @@ +shouldCacheBeEnabled( + $container->get(AppConfig::class)->environment->isProduction(), + ), + ); + } + + private function shouldCacheBeEnabled(bool $isProduction): bool + { + if (env('INTERNAL_CACHES') === false) { + return false; + } + + return (bool) env('CONFIG_CACHE', default: $isProduction); + } +} diff --git a/packages/core/src/DiscoveryCache.php b/packages/core/src/DiscoveryCache.php index 62e6c9fa9..e336acaa6 100644 --- a/packages/core/src/DiscoveryCache.php +++ b/packages/core/src/DiscoveryCache.php @@ -5,39 +5,42 @@ namespace Tempest\Core; use Psr\Cache\CacheItemPoolInterface; +use RuntimeException; use Symfony\Component\Cache\Adapter\PhpFilesAdapter; -use Tempest\Cache\Cache; -use Tempest\Cache\CacheConfig; -use Tempest\Cache\DiscoveryCacheStrategy; -use Tempest\Cache\IsCache; use Tempest\Discovery\Discovery; use Tempest\Discovery\DiscoveryItems; use Throwable; use function Tempest\internal_storage_path; -final class DiscoveryCache implements Cache +final class DiscoveryCache { - use IsCache { - clear as parentClear; + public bool $enabled { + get => $this->valid && $this->strategy->isEnabled(); } - private CacheItemPoolInterface $pool; + public bool $valid { + get => $this->strategy->isValid(); + } public function __construct( - private readonly CacheConfig $cacheConfig, - ?CacheItemPoolInterface $pool = null, + private(set) DiscoveryCacheStrategy $strategy, + private ?CacheItemPoolInterface $pool = null, ) { $this->pool = $pool ?? new PhpFilesAdapter( - directory: $this->cacheConfig->directory . '/discovery', + directory: internal_storage_path('cache/discovery'), ); } public function restore(string $className): ?DiscoveryItems { - $key = str_replace('\\', '_', $className); + if (! $this->enabled) { + return null; + } - return $this->get($key); + return $this->pool + ->getItem(str_replace('\\', '_', $className)) + ->get(); } public function store(Discovery $discovery, DiscoveryItems $discoveryItems): void @@ -51,41 +54,15 @@ public function store(Discovery $discovery, DiscoveryItems $discoveryItems): voi $this->pool->save($item); } - protected function getCachePool(): CacheItemPoolInterface - { - return $this->pool; - } - - public function isEnabled(): bool - { - if (! $this->isValid()) { - return false; - } - - if ($this->cacheConfig->enable) { - return true; - } - - return $this->cacheConfig->discoveryCache->isEnabled(); - } - - public function isValid(): bool - { - return $this->cacheConfig->discoveryCache->isValid(); - } - public function clear(): void { - $this->parentClear(); + if (! $this->pool->clear()) { + throw new RuntimeException('Could not clear discovery cache.'); + } $this->storeStrategy(DiscoveryCacheStrategy::INVALID); } - public function getStrategy(): DiscoveryCacheStrategy - { - return $this->cacheConfig->discoveryCache; - } - public function storeStrategy(DiscoveryCacheStrategy $strategy): void { $dir = dirname(self::getCurrentDiscoverStrategyCachePath()); diff --git a/packages/core/src/DiscoveryCacheInitializer.php b/packages/core/src/DiscoveryCacheInitializer.php new file mode 100644 index 000000000..858da1199 --- /dev/null +++ b/packages/core/src/DiscoveryCacheInitializer.php @@ -0,0 +1,54 @@ +resolveDiscoveryCacheStrategy( + $container->get(AppConfig::class)->environment->isProduction(), + ), + ); + } + + private function resolveDiscoveryCacheStrategy(bool $isProduction): DiscoveryCacheStrategy + { + if ($this->isDiscoveryGenerateCommand()) { + return DiscoveryCacheStrategy::NONE; + } + + $current = DiscoveryCacheStrategy::make(env('DISCOVERY_CACHE', default: $isProduction)); + + if ($current === DiscoveryCacheStrategy::NONE) { + return $current; + } + + $original = DiscoveryCacheStrategy::make(@file_get_contents(DiscoveryCache::getCurrentDiscoverStrategyCachePath())); + + if ($current !== $original) { + return DiscoveryCacheStrategy::INVALID; + } + + return $current; + } + + private function isDiscoveryGenerateCommand(): bool + { + if (PHP_SAPI !== 'cli') { + return false; + } + + $command = $_SERVER['argv'][1] ?? null; + + return $command === 'dg' || $command === 'discovery:generate'; + } +} diff --git a/packages/cache/src/DiscoveryCacheStrategy.php b/packages/core/src/DiscoveryCacheStrategy.php similarity index 96% rename from packages/cache/src/DiscoveryCacheStrategy.php rename to packages/core/src/DiscoveryCacheStrategy.php index c44f6cd46..a0fe06853 100644 --- a/packages/cache/src/DiscoveryCacheStrategy.php +++ b/packages/core/src/DiscoveryCacheStrategy.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tempest\Cache; +namespace Tempest\Core; enum DiscoveryCacheStrategy: string { diff --git a/packages/core/src/FrameworkKernel.php b/packages/core/src/FrameworkKernel.php index cd16387c8..413532d40 100644 --- a/packages/core/src/FrameworkKernel.php +++ b/packages/core/src/FrameworkKernel.php @@ -151,6 +151,7 @@ public function loadDiscoveryLocations(): self public function loadDiscovery(): self { + $this->container->addInitializer(DiscoveryCacheInitializer::class); $this->container->invoke(LoadDiscoveryClasses::class); return $this; diff --git a/packages/core/src/Kernel/LoadDiscoveryClasses.php b/packages/core/src/Kernel/LoadDiscoveryClasses.php index 4af9a1172..e56b99ffe 100644 --- a/packages/core/src/Kernel/LoadDiscoveryClasses.php +++ b/packages/core/src/Kernel/LoadDiscoveryClasses.php @@ -8,9 +8,9 @@ use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; -use Tempest\Cache\DiscoveryCacheStrategy; use Tempest\Container\Container; use Tempest\Core\DiscoveryCache; +use Tempest\Core\DiscoveryCacheStrategy; use Tempest\Core\DiscoveryConfig; use Tempest\Core\DiscoveryDiscovery; use Tempest\Core\Kernel; @@ -69,7 +69,7 @@ private function resolveDiscovery(string $discoveryClass): Discovery /** @var Discovery $discovery */ $discovery = $this->container->get($discoveryClass); - if ($this->discoveryCache->isEnabled()) { + if ($this->discoveryCache->enabled) { $discovery->setItems( $this->discoveryCache->restore($discoveryClass) ?? new DiscoveryItems(), ); @@ -87,7 +87,7 @@ private function buildDiscovery(string $discoveryClass): Discovery { $discovery = $this->resolveDiscovery($discoveryClass); - if ($this->discoveryCache->getStrategy() === DiscoveryCacheStrategy::FULL && $discovery->getItems()->isLoaded()) { + if ($this->discoveryCache->strategy === DiscoveryCacheStrategy::FULL && $discovery->getItems()->isLoaded()) { return $discovery; } @@ -199,11 +199,11 @@ private function shouldSkipDiscoveryForClass(Discovery $discovery, ClassReflecto */ private function shouldSkipLocation(DiscoveryLocation $location): bool { - if (! $this->discoveryCache->isEnabled()) { + if (! $this->discoveryCache->enabled) { return false; } - return match ($this->discoveryCache->getStrategy()) { + return match ($this->discoveryCache->strategy) { // If discovery cache is disabled, no locations should be skipped, all should always be discovered DiscoveryCacheStrategy::NONE, DiscoveryCacheStrategy::INVALID => false, // If discover cache is enabled, all locations cache should be skipped diff --git a/packages/core/src/functions.php b/packages/core/src/functions.php index a695cc7bd..6c7d3c7b8 100644 --- a/packages/core/src/functions.php +++ b/packages/core/src/functions.php @@ -4,17 +4,11 @@ namespace Tempest { use Closure; - use ReflectionException; - use RuntimeException; use Stringable; - use Tempest\Container\Exceptions\CannotInstantiateDependencyException; - use Tempest\Container\Exceptions\CannotResolveTaggedDependency; - use Tempest\Container\Exceptions\CircularDependencyException; use Tempest\Core\Composer; use Tempest\Core\DeferredTasks; use Tempest\Core\Kernel; use Tempest\Support\Namespace\PathCouldNotBeMappedToNamespaceException; - use Tempest\Support\Regex\InvalidPatternException; use function Tempest\Support\Namespace\to_psr4_namespace; use function Tempest\Support\Path\to_absolute_path; @@ -40,7 +34,7 @@ function src_path(Stringable|string ...$parts): string */ function internal_storage_path(Stringable|string ...$parts): string { - return root_path(get(Kernel::class)->internalStorage, ...$parts); + return to_absolute_path(get(Kernel::class)->internalStorage, ...$parts); } /** diff --git a/packages/view/composer.json b/packages/view/composer.json index c3c17ad16..4a72896be 100644 --- a/packages/view/composer.json +++ b/packages/view/composer.json @@ -8,8 +8,8 @@ "tempest/core": "dev-main", "tempest/container": "dev-main", "tempest/validation": "dev-main", - "tempest/cache": "dev-main", - "tempest/clock": "dev-main" + "tempest/clock": "dev-main", + "symfony/cache": "^7.2" }, "autoload": { "files": [ diff --git a/packages/view/src/Components/Icon.php b/packages/view/src/Components/Icon.php index 613b9c209..92734a828 100644 --- a/packages/view/src/Components/Icon.php +++ b/packages/view/src/Components/Icon.php @@ -5,13 +5,13 @@ namespace Tempest\View\Components; use Exception; -use Tempest\Cache\IconCache; use Tempest\Clock\Clock; use Tempest\Core\AppConfig; use Tempest\Http\Status; use Tempest\HttpClient\HttpClient; use Tempest\Support\Html\HtmlString; use Tempest\Support\Str\ImmutableString; +use Tempest\View\IconCache; use Tempest\View\IconConfig; final readonly class Icon @@ -84,7 +84,7 @@ private function svg(string $name): ?string return $this->iconCache->resolve( key: "iconify-{$prefix}-{$name}", - cache: fn () => $this->download($prefix, $name), + callback: fn () => $this->download($prefix, $name), expiresAt: $this->iconConfig->cacheDuration ? $this->clock->now()->plusSeconds($this->iconConfig->cacheDuration) : null, diff --git a/packages/view/src/IconCache.php b/packages/view/src/IconCache.php new file mode 100644 index 000000000..2daec6198 --- /dev/null +++ b/packages/view/src/IconCache.php @@ -0,0 +1,75 @@ +pool ??= new FilesystemAdapter( + directory: internal_storage_path('cache/icons'), + ); + } + + public function get(string $key): mixed + { + if (! $this->enabled) { + return null; + } + + return $this->pool->getItem($key)->get(); + } + + public function clear(): void + { + $this->pool->clear(); + } + + public function put(string $key, mixed $value, null|Duration|DateTimeInterface $expiresAt = null): CacheItemInterface + { + $item = $this->pool + ->getItem($key) + ->set($value); + + if ($expiresAt instanceof Duration) { + $expiresAt = DateTime::now()->plus($expiresAt); + } + + if ($expiresAt !== null) { + $item = $item->expiresAt($expiresAt->toNativeDateTime()); + } + + if ($this->enabled) { + $this->pool->save($item); + } + + return $item; + } + + public function resolve(string $key, Closure $callback, null|Duration|DateTimeInterface $expiresAt = null): mixed + { + if (! $this->enabled) { + return $callback(); + } + + $item = $this->pool->getItem($key); + + if (! $item->isHit()) { + $item = $this->put($key, $callback(), $expiresAt); + } + + return $item->get(); + } +} diff --git a/packages/view/src/IconCacheInitializer.php b/packages/view/src/IconCacheInitializer.php new file mode 100644 index 000000000..300585b23 --- /dev/null +++ b/packages/view/src/IconCacheInitializer.php @@ -0,0 +1,32 @@ +shouldCacheBeEnabled( + $container->get(AppConfig::class)->environment->isProduction(), + ), + ); + } + + private function shouldCacheBeEnabled(bool $isProduction): bool + { + if (env('INTERNAL_CACHES') === false) { + return false; + } + + return (bool) env('ICON_CACHE', default: $isProduction); + } +} diff --git a/packages/view/src/ViewCache.php b/packages/view/src/ViewCache.php index bf730f764..9eace68af 100644 --- a/packages/view/src/ViewCache.php +++ b/packages/view/src/ViewCache.php @@ -5,50 +5,38 @@ namespace Tempest\View; use Closure; -use Psr\Cache\CacheItemPoolInterface; -use Tempest\Cache\Cache; -use Tempest\Cache\CacheConfig; -use Tempest\Cache\IsCache; +use function Tempest\internal_storage_path; use function Tempest\Support\path; -final class ViewCache implements Cache +final class ViewCache { - use IsCache; - - private readonly ViewCachePool $cachePool; - public function __construct( - private readonly CacheConfig $cacheConfig, - ?ViewCachePool $pool = null, + public bool $enabled = false, + private ?ViewCachePool $pool = null, ) { - $this->cachePool = $pool ?? new ViewCachePool( - directory: $this->cacheConfig->directory . '/views', + $this->pool ??= new ViewCachePool( + directory: internal_storage_path('cache/views'), ); } + public function clear(): void + { + $this->pool->clear(); + } + public function getCachedViewPath(string $path, Closure $compiledView): string { $cacheKey = (string) crc32($path); - $cacheItem = $this->cachePool->getItem($cacheKey); + $cacheItem = $this->pool->getItem($cacheKey); - if ($this->isEnabled() === false || $cacheItem->isHit() === false) { + if ($this->enabled === false || $cacheItem->isHit() === false) { $cacheItem->set($compiledView()); - $this->cachePool->save($cacheItem); + $this->pool->save($cacheItem); } - return path($this->cachePool->directory, $cacheItem->getKey() . '.php')->toString(); - } - - protected function getCachePool(): CacheItemPoolInterface - { - return $this->cachePool; - } - - public function isEnabled(): bool - { - return $this->cacheConfig->enable ?? $this->cacheConfig->viewCache; + return path($this->pool->directory, $cacheItem->getKey() . '.php')->toString(); } } diff --git a/packages/view/src/ViewCacheInitializer.php b/packages/view/src/ViewCacheInitializer.php new file mode 100644 index 000000000..e111d408d --- /dev/null +++ b/packages/view/src/ViewCacheInitializer.php @@ -0,0 +1,34 @@ +shouldCacheBeEnabled( + $container->get(AppConfig::class)->environment->isProduction(), + ), + ); + + return $viewCache; + } + + private function shouldCacheBeEnabled(bool $isProduction): bool + { + if (env('INTERNAL_CACHES') === false) { + return false; + } + + return (bool) env('VIEW_CACHE', default: $isProduction); + } +} diff --git a/packages/view/src/ViewCachePool.php b/packages/view/src/ViewCachePool.php index 9cea337f0..658850bba 100644 --- a/packages/view/src/ViewCachePool.php +++ b/packages/view/src/ViewCachePool.php @@ -20,7 +20,7 @@ public function __construct( public string $directory, ) {} - public function getItem(string $key): CacheItemInterface + public function getItem(string $key): CacheItem { $createCacheItem = Closure::bind( closure: static function ($key, $value, $isHit) { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ae98afb83..9c6b4da34 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -31,7 +31,7 @@ - + diff --git a/src/Tempest/Framework/Testing/IntegrationTest.php b/src/Tempest/Framework/Testing/IntegrationTest.php index a35e314d4..bf6147924 100644 --- a/src/Tempest/Framework/Testing/IntegrationTest.php +++ b/src/Tempest/Framework/Testing/IntegrationTest.php @@ -5,6 +5,7 @@ namespace Tempest\Framework\Testing; use PHPUnit\Framework\TestCase; +use Tempest\Cache\Testing\CacheTester; use Tempest\Clock\Clock; use Tempest\Clock\MockClock; use Tempest\Console\Testing\ConsoleTester; @@ -50,6 +51,8 @@ abstract class IntegrationTest extends TestCase protected StorageTester $storage; + protected CacheTester $cache; + protected function setUp(): void { parent::setUp(); @@ -70,6 +73,7 @@ protected function setUp(): void $this->installer = $this->container->get(InstallerTester::class); $this->eventBus = $this->container->get(EventBusTester::class); $this->storage = $this->container->get(StorageTester::class); + $this->cache = $this->container->get(CacheTester::class); $this->vite = $this->container->get(ViteTester::class); $this->vite->preventTagResolution(); diff --git a/tests/Integration/Cache/CacheClearCommandTest.php b/tests/Integration/Cache/CacheClearCommandTest.php index 104b8bf41..24c61817e 100644 --- a/tests/Integration/Cache/CacheClearCommandTest.php +++ b/tests/Integration/Cache/CacheClearCommandTest.php @@ -4,7 +4,10 @@ namespace Tests\Tempest\Integration\Cache; -use Tempest\Cache\ProjectCache; +use Tempest\Cache\Commands\CacheClearCommand; +use Tempest\Cache\Config\InMemoryCacheConfig; +use Tempest\Core\DiscoveryCache; +use Tempest\View\IconCache; use Tempest\View\ViewCache; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -13,13 +16,80 @@ */ final class CacheClearCommandTest extends FrameworkIntegrationTestCase { - public function test_cache_clear_single(): void + protected function setUp(): void + { + parent::setUp(); + $this->cache->fake(); + } + + public function test_cache_clear_default(): void + { + $this->console + ->call(CacheClearCommand::class) + ->assertSeeCount('CLEARED', expectedCount: 1); + } + + public function test_cache_clear_default_named(): void + { + $this->console + ->call(CacheClearCommand::class, ['tag' => 'default']) + ->assertSeeCount('CLEARED', expectedCount: 1); + } + + public function test_cache_clear_named(): void + { + $this->container->config(new InMemoryCacheConfig(tag: 'my-cache')); + + $this->console + ->call(CacheClearCommand::class, ['tag' => 'my-cache']) + ->assertSee('my-cache') + ->assertSeeCount('CLEARED', expectedCount: 1); + } + + public function test_cache_clear_default_all(): void { $this->console - ->call('cache:clear') - ->assertSee(ProjectCache::class) + ->call(CacheClearCommand::class, ['all' => true]) + ->assertSeeCount('CLEARED', expectedCount: 1); + } + + public function test_cache_clear_all(): void + { + $this->container->config(new InMemoryCacheConfig(tag: 'my-cache')); + + $this->console + ->call(CacheClearCommand::class, ['all' => true]) + ->assertSee('default') + ->assertSee('my-cache') + ->assertSeeCount('CLEARED', expectedCount: 2); + } + + public function test_cache_clear_filter(): void + { + $this->container->config(new InMemoryCacheConfig(tag: 'my-cache')); + + $this->console + ->call(CacheClearCommand::class) ->submit('0') ->submit('yes') - ->assertSeeCount('CLEARED', 1); + ->assertSee('default') + ->assertNotSee('my-cache') + ->assertSeeCount('CLEARED', expectedCount: 1); + + $this->console + ->call(CacheClearCommand::class) + ->submit('1') + ->submit('yes') + ->assertNotSee('default') + ->assertSee('my-cache') + ->assertSeeCount('CLEARED', expectedCount: 1); + + $this->console + ->call(CacheClearCommand::class) + ->submit('0,1') + ->submit('yes') + ->assertSee('default') + ->assertSee('my-cache') + ->assertSeeCount('CLEARED', expectedCount: 2); } } diff --git a/tests/Integration/Cache/CacheStatusCommandTest.php b/tests/Integration/Cache/CacheStatusCommandTest.php new file mode 100644 index 000000000..4d8009c20 --- /dev/null +++ b/tests/Integration/Cache/CacheStatusCommandTest.php @@ -0,0 +1,56 @@ +cache->fake(); + } + + public function test_cache_status(): void + { + $this->console + ->call(CacheStatusCommand::class, ['internal' => false]) + ->assertSeeCount('ENABLED', expectedCount: 1); + } + + public function test_cache_status_when_disabled(): void + { + $cache = $this->container->get(Cache::class); + $cache->enabled = false; + + $this->console + ->call(CacheStatusCommand::class, ['internal' => false]) + ->assertSeeCount('DISABLED', expectedCount: 1); + } + + public function test_cache_status_with_multiple_caches(): void + { + $this->container->config(new InMemoryCacheConfig(tag: 'test-cache')); + + $this->console + ->call(CacheStatusCommand::class, ['internal' => false]) + ->assertSee('test-cache') + ->assertSeeCount('ENABLED', expectedCount: 2); + } + + public function test_with_internal_caches(): void + { + $this->console + ->call(CacheStatusCommand::class, ['internal' => true]) + ->assertSee(ViewCache::class) + ->assertSee(IconCache::class) + ->assertSee(DiscoveryCache::class); + } +} diff --git a/tests/Integration/Cache/CacheTest.php b/tests/Integration/Cache/CacheTest.php index ef92fe3bd..8be6afa63 100644 --- a/tests/Integration/Cache/CacheTest.php +++ b/tests/Integration/Cache/CacheTest.php @@ -5,12 +5,14 @@ namespace Tests\Tempest\Integration\Cache; use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Tempest\Cache\CacheConfig; -use Tempest\Cache\ProjectCache; -use Tempest\Clock\MockClock; +use Tempest\Cache\Config\InMemoryCacheConfig; +use Tempest\Cache\GenericCache; +use Tempest\Cache\NotNumberException; use Tempest\DateTime\Duration; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; +use function Tempest\Support\str; + /** * @internal */ @@ -18,10 +20,12 @@ final class CacheTest extends FrameworkIntegrationTestCase { public function test_put(): void { - $clock = new MockClock(); - $pool = new ArrayAdapter(clock: $clock->toPsrClock()); - $cache = new ProjectCache(new CacheConfig(projectCachePool: $pool, enable: true)); $interval = Duration::days(1); + $clock = $this->clock(); + $cache = new GenericCache( + cacheConfig: new InMemoryCacheConfig(), + adapter: $pool = new ArrayAdapter(clock: $clock->toPsrClock()), + ); $cache->put('a', 'a', $clock->now()->plus($interval)); $cache->put('b', 'b'); @@ -40,17 +44,116 @@ public function test_put(): void $this->assertTrue($item->isHit()); } + public function test_put_many(): void + { + $cache = new GenericCache( + cacheConfig: new InMemoryCacheConfig(), + adapter: $pool = new ArrayAdapter(), + ); + + $cache->putMany(['foo1' => 'bar1', 'foo2' => 'bar2']); + + $item = $pool->getItem('foo1'); + $this->assertTrue($item->isHit()); + $this->assertSame('bar1', $item->get()); + $this->assertSame('bar1', $cache->get('foo1')); + + $item = $pool->getItem('foo2'); + $this->assertTrue($item->isHit()); + $this->assertSame('bar2', $item->get()); + $this->assertSame('bar2', $cache->get('foo2')); + } + + public function test_increment(): void + { + $cache = new GenericCache(new InMemoryCacheConfig()); + + $cache->increment('a', by: 1); + $this->assertSame(1, $cache->get('a')); + + $cache->increment('a', by: 1); + $this->assertSame(2, $cache->get('a')); + + $cache->increment('a', by: 5); + $this->assertSame(7, $cache->get('a')); + + $cache->increment('a', by: -1); + $this->assertSame(6, $cache->get('a')); + + $cache->increment('b', by: 1); + $this->assertSame(1, $cache->get('b')); + } + + public function test_increment_non_int_key(): void + { + $this->expectException(NotNumberException::class); + + $cache = new GenericCache(new InMemoryCacheConfig()); + + $cache->put('a', 'value'); + $cache->increment('a', by: 1); + } + + public function test_increment_non_int_numeric_key(): void + { + $cache = new GenericCache(new InMemoryCacheConfig()); + + $cache->put('a', '1'); + + $cache->increment('a', by: 1); + $this->assertSame(2, $cache->get('a')); + } + + public function test_decrement_non_int_key(): void + { + $this->expectException(NotNumberException::class); + + $cache = new GenericCache(new InMemoryCacheConfig()); + + $cache->put('a', 'value'); + $cache->decrement('a', by: 1); + } + + public function test_decrement_non_int_numeric_key(): void + { + $cache = new GenericCache(new InMemoryCacheConfig()); + + $cache->put('a', '1'); + + $cache->decrement('a', by: 1); + $this->assertSame(0, $cache->get('a')); + } + + public function test_decrement(): void + { + $cache = new GenericCache(new InMemoryCacheConfig()); + + $cache->decrement('a', by: 1); + $this->assertSame(-1, $cache->get('a')); + + $cache->decrement('a', by: 1); + $this->assertSame(-2, $cache->get('a')); + + $cache->decrement('a', by: 5); + $this->assertSame(-7, $cache->get('a')); + + $cache->decrement('a', by: -1); + $this->assertSame(-6, $cache->get('a')); + } + public function test_get(): void { - $clock = new MockClock(); - $pool = new ArrayAdapter(clock: $clock->toPsrClock()); - $cache = new ProjectCache(new CacheConfig(projectCachePool: $pool, enable: true)); $interval = Duration::days(1); + $clock = $this->clock(); + $cache = new GenericCache( + cacheConfig: new InMemoryCacheConfig(), + adapter: new ArrayAdapter(clock: $clock->toPsrClock()), + ); $cache->put('a', 'a', $clock->now()->plus($interval)); $cache->put('b', 'b'); - $this->assertSame('a', $cache->get('a')); + $this->assertSame('a', $cache->get(str('a'))); $this->assertSame('b', $cache->get('b')); $clock->plus($interval); @@ -59,16 +162,32 @@ public function test_get(): void $this->assertSame('b', $cache->get('b')); } + public function test_get_many(): void + { + $cache = new GenericCache(new InMemoryCacheConfig()); + + $cache->put('foo1', 'bar1'); + $cache->put('foo2', 'bar2'); + + $values = $cache->getMany(['foo1', 'foo2']); + + $this->assertSame('bar1', $values['foo1']); + $this->assertSame('bar2', $values['foo2']); + + $values = $cache->getMany(['foo2', 'foo3']); + + $this->assertSame('bar2', $values['foo2']); + $this->assertSame(null, $values['foo3']); + } + public function test_resolve(): void { - $clock = new MockClock(); - $pool = new ArrayAdapter(clock: $clock->toPsrClock()); - $config = new CacheConfig( - projectCachePool: $pool, - enable: true, - ); - $cache = new ProjectCache($config); $interval = Duration::days(1); + $clock = $this->clock(); + $cache = new GenericCache( + cacheConfig: new InMemoryCacheConfig(), + adapter: new ArrayAdapter(clock: $clock->toPsrClock()), + ); $a = $cache->resolve('a', fn () => 'a', $clock->now()->plus($interval)); $this->assertSame('a', $a); @@ -87,11 +206,9 @@ public function test_resolve(): void public function test_remove(): void { - $pool = new ArrayAdapter(); - $cache = new ProjectCache(new CacheConfig(projectCachePool: $pool, enable: true)); + $cache = new GenericCache(new InMemoryCacheConfig()); $cache->put('a', 'a'); - $cache->remove('a'); $this->assertNull($cache->get('a')); @@ -99,8 +216,7 @@ public function test_remove(): void public function test_clear(): void { - $pool = new ArrayAdapter(); - $cache = new ProjectCache(cacheConfig: new CacheConfig(projectCachePool: $pool)); + $cache = new GenericCache(new InMemoryCacheConfig()); $cache->put('a', 'a'); $cache->put('b', 'b'); diff --git a/tests/Integration/Cache/CacheTesterTest.php b/tests/Integration/Cache/CacheTesterTest.php new file mode 100644 index 000000000..c59a52748 --- /dev/null +++ b/tests/Integration/Cache/CacheTesterTest.php @@ -0,0 +1,153 @@ +cache->fake(); + $actual = $this->container->get(Cache::class); + + $this->assertInstanceOf(TestingCache::class, $faked); + $this->assertInstanceOf(TestingCache::class, $actual); + $this->assertSame($faked, $actual); + } + + public function test_multiple_fake_cache_are_registered_in_container(): void + { + $faked1 = $this->cache->fake('cache1'); + $faked2 = $this->cache->fake('cache2'); + + $actual1 = $this->container->get(Cache::class, 'cache1'); + $actual2 = $this->container->get(Cache::class, 'cache2'); + + $this->assertInstanceOf(TestingCache::class, $faked1); + $this->assertInstanceOf(TestingCache::class, $actual1); + $this->assertSame($faked1, $actual1); + + $this->assertInstanceOf(TestingCache::class, $faked2); + $this->assertInstanceOf(TestingCache::class, $actual2); + $this->assertSame($faked2, $actual2); + + $this->assertNotSame($actual1, $actual2); + } + + public function test_basic_assertions(): void + { + $cache = $this->cache->fake(); + + $cache->assertEmpty(); + + $cache->put('key', 'value'); + + $cache->assertCached('key'); + $cache->assertCached('key', function (string $value): void { + $this->assertSame('value', $value); + }); + + $cache->assertNotCached('foo'); + $cache->assertNotEmpty(); + + $cache->assertKeyHasValue('key', 'value'); + $cache->assertKeyDoesNotHaveValue('key', 'not-the-right-value'); + } + + public function test_prevent_usage_without_fake(): void + { + $this->expectException(ForbiddenCacheUsageException::class); + + $this->cache->preventUsageWithoutFake(); + + $cache = $this->container->get(Cache::class); + $cache->put('key', 'value'); + } + + public function test_prevent_usage_without_fake_with_tagged_cache(): void + { + $this->expectException(ForbiddenCacheUsageException::class); + + $this->container->config(new InMemoryCacheConfig(tag: 'tagged')); + $this->cache->preventUsageWithoutFake(); + + $cache = $this->container->get(Cache::class, 'tagged'); + $cache->put('key', 'value'); + } + + public function test_prevent_usage_without_fake_with_fake(): void + { + $this->cache->preventUsageWithoutFake(); + + $cache = $this->cache->fake(); + $cache->put('key', 'value'); + $cache->assertCached('key'); + } + + public function test_prevent_usage_without_fake_with_fake_tagged_cache(): void + { + $this->container->config(new InMemoryCacheConfig(tag: 'tagged')); + $this->cache->preventUsageWithoutFake(); + + $cache = $this->cache->fake('tagged'); + $cache->put('key', 'value'); + $cache->assertCached('key'); + } + + public function test_ttl(): void + { + $clock = $this->clock(); + $cache = $this->cache->fake(); + + $cache->put('key', 'value', expiration: Duration::minutes(10)); + $cache->assertCached('key'); + + $clock->plus(Duration::minutes(10)->withSeconds(1)); + $cache->assertNotCached('key'); + } + + public function test_lock_assertions(): void + { + $cache = $this->cache->fake(); + $lock = $cache->lock('processing'); + + $lock->assertNotLocked(); + + $this->assertTrue($lock->acquire()); + + $lock->assertLocked(); + $lock->assertLocked(by: $lock->owner); + + $lock->assertNotLocked(by: 'other-owner'); + + $cache->assertLocked($lock->key); + } + + public function test_assert_not_locked_while_locked(): void + { + $cache = $this->cache->fake(); + $lock = $cache->lock('processing'); + + $this->assertTrue($lock->acquire()); + + $this->expectException(ExpectationFailedException::class); + $lock->assertNotLocked(); + } + + public function test_lock_assertion_with_different_owner(): void + { + $testingCache = $this->cache->fake(); + + $actualCache = $this->container->get(Cache::class); + $actualCache->lock('processing')->acquire(); + + $testingCache->assertLocked('processing'); + } +} diff --git a/tests/Integration/Cache/LockTest.php b/tests/Integration/Cache/LockTest.php new file mode 100644 index 000000000..0ef82a7a6 --- /dev/null +++ b/tests/Integration/Cache/LockTest.php @@ -0,0 +1,125 @@ +lock('processing'); + + $this->assertTrue($lock->acquire()); + $this->assertTrue($lock->release()); + } + + public function test_same_lock_can_be_acquired_by_same_owner(): void + { + $cache = new GenericCache(new InMemoryCacheConfig()); + + $lock1 = $cache->lock('processing', owner: 'owner1'); + $lock2 = $cache->lock('processing', owner: 'owner1'); + + $this->assertTrue($lock1->acquire()); + $this->assertSame('owner1', $lock1->owner); + $this->assertSame('owner1', $lock2->owner); + $this->assertSame('processing', $lock1->key); + $this->assertSame('processing', $lock2->key); + + $this->assertFalse($lock2->acquire()); // can't re-acquire + $this->assertTrue($lock2->release()); // but can release + } + + public function test_same_lock_cannot_be_acquired_by_different_owners(): void + { + $cache = new GenericCache(new InMemoryCacheConfig()); + + $lock1 = $cache->lock('processing', owner: 'owner1'); + $lock2 = $cache->lock('processing', owner: 'owner2'); + + $this->assertSame('owner1', $lock1->owner); + $this->assertSame('owner2', $lock2->owner); + $this->assertSame('processing', $lock1->key); + $this->assertSame('processing', $lock2->key); + + $this->assertTrue($lock1->acquire()); + $this->assertFalse($lock2->acquire()); + + $this->assertTrue($lock1->release()); + $this->assertTrue($lock2->acquire()); + } + + public function test_lock_with_ttl(): void + { + $clock = $this->clock(); + $cache = new GenericCache( + cacheConfig: new InMemoryCacheConfig(), + adapter: new ArrayAdapter(clock: $clock->toPsrClock()), + ); + + $lock = $cache->lock('processing', expiration: Duration::hours(1)); + + $this->assertTrue($lock->acquire()); + $this->assertTrue($lock->expiration->equals($clock->now()->plusHours(1))); + + // Still locked after 30 min + $clock->plus(Duration::minutes(30)); + $this->assertFalse($lock->acquire()); + + // No longer locked after another 30 min (total 1h) + $clock->plus(Duration::minutes(30)); + $this->assertTrue($lock->acquire()); + $this->assertFalse($lock->release()); + } + + public function test_lock_execution_without_timeout(): void + { + $cache = new GenericCache(new InMemoryCacheConfig()); + + $lock = $cache->lock('processing'); + + $this->assertTrue($lock->execute(fn () => true)); // @phpstan-ignore method.alreadyNarrowedType + $this->assertFalse($lock->release()); + } + + public function test_lock_execution_when_already_locked_by_another_owner(): void + { + $this->expectException(LockAcquisitionTimedOutException::class); + + $cache = new GenericCache(new InMemoryCacheConfig()); + + // Lock externally + $externalLock = $cache->lock('processing'); + $externalLock->acquire(); + + // Try executing a callback, should timeout instantly + $cache->lock('processing')->execute(fn () => true); + } + + public function test_lock_execution_when_already_locked_by_another_owner_with_timeout(): void + { + $clock = $this->clock(); + $cache = new GenericCache( + cacheConfig: new InMemoryCacheConfig(), + adapter: new ArrayAdapter(clock: $clock->toPsrClock()), + ); + + // Lock externally for a set duration + $externalLock = $cache->lock('processing', expiration: Duration::hours(1)); + $externalLock->acquire(); + + // Skip the set duration + $clock->plus(Duration::hours(1)); + + // Try executing a callback for the specified duration + $this->assertTrue($cache->lock('processing')->execute(fn () => true, wait: Duration::hours(1))); // @phpstan-ignore method.alreadyNarrowedType + } +} diff --git a/tests/Integration/Console/Commands/MakeConfigCommandTest.php b/tests/Integration/Console/Commands/MakeConfigCommandTest.php index 45ca2f262..2fadbfa77 100644 --- a/tests/Integration/Console/Commands/MakeConfigCommandTest.php +++ b/tests/Integration/Console/Commands/MakeConfigCommandTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use Tempest\Cache\CacheConfig; use Tempest\CommandBus\CommandBusConfig; use Tempest\Console\ConsoleConfig; use Tempest\Console\Enums\ConfigType; @@ -92,10 +91,6 @@ public static function config_type_provider(): array 'configType' => ConfigType::LOG, 'expectedConfigClass' => LogConfig::class, ], - 'cache_config' => [ - 'configType' => ConfigType::CACHE, - 'expectedConfigClass' => CacheConfig::class, - ], 'console_config' => [ 'configType' => ConfigType::CONSOLE, 'expectedConfigClass' => ConsoleConfig::class, diff --git a/tests/Integration/Core/AboutCommandTest.php b/tests/Integration/Core/AboutCommandTest.php index f13ebd0d7..e8dd8abe6 100644 --- a/tests/Integration/Core/AboutCommandTest.php +++ b/tests/Integration/Core/AboutCommandTest.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\Core; +use Tempest\Cache\Config\InMemoryCacheConfig; use Tempest\Console\Commands\AboutCommand; use Tempest\Core\AppConfig; use Tempest\Core\Kernel; @@ -52,4 +53,22 @@ public function test_json(): void ->call(AboutCommand::class, ['json' => true]) ->assertJson(); } + + public function test_cache(): void + { + $this->console + ->call(AboutCommand::class) + ->assertSee('INTERNAL CACHES') + ->assertSee('USER CACHES') + ->assertSee('Filesystem,'); + } + + public function test_another_cache(): void + { + $this->container->config(new InMemoryCacheConfig()); + + $this->console + ->call(AboutCommand::class) + ->assertSee('In-memory,'); + } } diff --git a/tests/Integration/View/IconComponentTest.php b/tests/Integration/View/IconComponentTest.php index a5cf0ecd7..f4e761d6c 100644 --- a/tests/Integration/View/IconComponentTest.php +++ b/tests/Integration/View/IconComponentTest.php @@ -4,13 +4,13 @@ namespace Tests\Tempest\Integration\View; -use Tempest\Cache\IconCache; use Tempest\Core\AppConfig; use Tempest\Core\ConfigCache; use Tempest\Core\Environment; use Tempest\Http\GenericResponse; use Tempest\Http\Status; use Tempest\HttpClient\HttpClient; +use Tempest\View\IconCache; use Tempest\View\IconConfig; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -22,8 +22,11 @@ protected function setUp(): void { parent::setUp(); - $this->container->get(IconCache::class)->clear(); $this->container->get(ConfigCache::class)->clear(); + + $iconCache = $this->container->get(IconCache::class); + $iconCache->enabled = true; + $iconCache->clear(); } public function test_it_renders_an_icon(): void diff --git a/tests/Integration/View/ViewCacheTest.php b/tests/Integration/View/ViewCacheTest.php index cc952eca8..86b609b99 100644 --- a/tests/Integration/View/ViewCacheTest.php +++ b/tests/Integration/View/ViewCacheTest.php @@ -4,7 +4,6 @@ namespace Tests\Tempest\Integration\View; -use Tempest\Cache\CacheConfig; use Tempest\View\ViewCache; use Tempest\View\ViewCachePool; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; @@ -18,19 +17,15 @@ final class ViewCacheTest extends FrameworkIntegrationTestCase { private const string DIRECTORY = __DIR__ . '/.cache'; - private CacheConfig $cacheConfig; - - private ViewCache $cache; + private ViewCache $viewCache; protected function setUp(): void { parent::setUp(); - $this->cacheConfig = new CacheConfig(); - - $this->cache = new ViewCache( - $this->cacheConfig, - new ViewCachePool( + $this->viewCache = new ViewCache( + enabled: true, + pool: new ViewCachePool( directory: self::DIRECTORY, ), ); @@ -52,7 +47,7 @@ protected function tearDown(): void public function test_view_cache(): void { - $path = $this->cache->getCachedViewPath('path', fn () => 'hi'); + $path = $this->viewCache->getCachedViewPath('path', fn () => 'hi'); $this->assertFileExists($path); $this->assertSame('hi', file_get_contents($path)); @@ -62,7 +57,7 @@ public function test_view_cache_when_disabled(): void { $hit = 0; - $this->cacheConfig->enable = false; + $this->viewCache->enabled = false; $compileFunction = function () use (&$hit) { $hit += 1; @@ -70,8 +65,8 @@ public function test_view_cache_when_disabled(): void return 'hi'; }; - $this->cache->getCachedViewPath('path', $compileFunction); - $path = $this->cache->getCachedViewPath('path', $compileFunction); + $this->viewCache->getCachedViewPath('path', $compileFunction); + $path = $this->viewCache->getCachedViewPath('path', $compileFunction); $this->assertFileExists($path); $this->assertSame('hi', file_get_contents($path)); @@ -82,7 +77,7 @@ public function test_view_cache_when_enabled(): void { $hit = 0; - $this->cacheConfig->enable = true; + $this->viewCache->enabled = true; $compileFunction = function () use (&$hit) { $hit += 1; @@ -90,8 +85,8 @@ public function test_view_cache_when_enabled(): void return 'hi'; }; - $this->cache->getCachedViewPath('path', $compileFunction); - $path = $this->cache->getCachedViewPath('path', $compileFunction); + $this->viewCache->getCachedViewPath('path', $compileFunction); + $path = $this->viewCache->getCachedViewPath('path', $compileFunction); $this->assertFileExists($path); $this->assertSame('hi', file_get_contents($path)); diff --git a/tests/Integration/Vite/ViteTagsComponentTest.php b/tests/Integration/Vite/ViteTagsComponentTest.php index c97268f3a..f1e6ecb69 100644 --- a/tests/Integration/Vite/ViteTagsComponentTest.php +++ b/tests/Integration/Vite/ViteTagsComponentTest.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\Vite; +use Tempest\View\ViewCache; use Tempest\Vite\ViteConfig; use Tests\Tempest\Integration\FrameworkIntegrationTestCase;