diff --git a/.gitattributes b/.gitattributes index 9670e954..ed810355 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,10 @@ -.gitattributes export-ignore -.gitignore export-ignore -.github export-ignore -ncs.* export-ignore -phpstan.neon export-ignore -tests/ export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +CLAUDE.md export-ignore +ncs.* export-ignore +phpstan*.neon export-ignore +tests/ export-ignore -*.sh eol=lf -*.php* diff=php linguist-language=PHP +*.php* diff=php +*.sh text eol=lf diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af0e88ea..eb15ea31 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.1', '8.2', '8.3', '8.4', '8.5'] + php: ['8.2', '8.3', '8.4', '8.5'] fail-fast: false @@ -21,7 +21,7 @@ jobs: coverage: none - run: composer install --no-progress --prefer-dist - - run: vendor/bin/tester tests -s -C + - run: composer tester - if: failure() uses: actions/upload-artifact@v4 with: @@ -37,11 +37,11 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable - - run: vendor/bin/tester tests -s -C + - run: composer tester code_coverage: @@ -52,11 +52,11 @@ jobs: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none - run: composer install --no-progress --prefer-dist - - run: vendor/bin/tester -p phpdbg tests -s -C --coverage ./coverage.xml --coverage-src ./src + - run: composer tester -- -p phpdbg --coverage ./coverage.xml --coverage-src ./src - run: wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.4.3/php-coveralls.phar - env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index de4a392c..d49bcd46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /vendor /composer.lock +tests/lock diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..20ff9365 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,284 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Nette Caching is a PHP library providing flexible caching with multiple storage backends and advanced dependency tracking. It's part of the Nette Framework ecosystem. + +**Key features:** +- Multiple storage backends (FileStorage, MemcachedStorage, SQLiteStorage, MemoryStorage) +- Advanced dependency tracking (tags, priorities, file changes, callbacks) +- Cache stampede prevention in FileStorage +- Atomic operations with file locking +- PSR-16 SimpleCache adapter +- Latte template integration with `{cache}` tag +- Nette DI integration + +**Requirements:** PHP 8.1-8.5 + +## Essential Commands + +### Testing + +```bash +# Run all tests +vendor/bin/tester tests -C -s -C + +# Run specific test directory +vendor/bin/tester tests/Caching -C -s -C +vendor/bin/tester tests/Storages -C -s -C + +# Run single test file +php tests/Caching/Cache.bulkLoad.phpt + +# Flags used: +# -C = Use system-wide php.ini +# -s -C = Show skipped tests +``` + +### Static Analysis + +```bash +# Run PHPStan (level 5) +composer run phpstan + +# Or directly +vendor/bin/phpstan analyse +``` + +### Linting + +```bash +# Nette coding standard checks +composer run tester +``` + +## Architecture Overview + +### Core Layering + +The library follows a clean separation of concerns: + +``` +Cache (high-level API) + ↓ +Storage interface (abstraction) + ↓ +Storage implementations (FileStorage, MemcachedStorage, etc.) + ↓ +Journal interface (for tags/priorities) + ↓ +SQLiteJournal implementation +``` + +**Cache** (`src/Caching/Cache.php`): Primary API for caching operations. Provides namespace isolation, dependency tracking, memoization (`wrap()`, `call()`), and output capturing (`capture()`, was `start()` in v3.0). + +**Storage interface** (`src/Caching/Storage.php`): Defines the contract all storage backends must implement: +- `read(string $key): mixed` +- `write(string $key, $data, array $dependencies): void` +- `remove(string $key): void` +- `clean(array $conditions): void` +- `lock(string $key): void` - Prevents concurrent writes + +**Journal interface** (`src/Caching/Storages/Journal.php`): Tracks metadata for tags and priorities. Required for: +- `Cache::Tags` - Tag-based invalidation +- `Cache::Priority` - Priority-based cleanup + +Default implementation: SQLiteJournal using SQLite database at `{tempDir}/journal.s3db`. + +### Storage Implementations + +All in `src/Caching/Storages/`: + +- **FileStorage** - Production default. Files stored in temp directory with atomic operations via file locking (LOCK_SH for reads, LOCK_EX for writes). Implements cache stampede prevention: when cache miss occurs with concurrent requests, only first thread generates value, others wait. File format: 6-byte header with meta size + serialized metadata + data. + +- **SQLiteStorage** - Single-file database storage. Good for shared hosting environments. + +- **MemcachedStorage** - Distributed caching via Memcached server. Requires `memcached` PHP extension. + +- **MemoryStorage** - In-memory array storage, lost after request. Used for testing or request-scoped caching. + +- **DevNullStorage** - No-op storage for testing when you want to disable caching. + +### Dependency System + +Cache dependencies control expiration and invalidation. All use Cache class constants: + +- `Cache::Expire` - Time-based expiration (timestamp, seconds, or string like "20 minutes") +- `Cache::Sliding` - Extends expiration on each read +- `Cache::Files` - Invalidate when file(s) modified (checks filemtime) +- `Cache::Items` - Invalidate when other cache items expire +- `Cache::Tags` - Tag-based invalidation (requires Journal) +- `Cache::Priority` - Priority-based cleanup (requires Journal) +- `Cache::Callbacks` - Custom validation callbacks +- `Cache::Constants` - Invalidate when PHP constants change + +Dependencies can be combined; cache expires when ANY criterion fails. + +### Bridge Components + +**Nette DI Bridge** (`src/Bridges/CacheDI/CacheExtension.php`): +- Auto-registers Storage service (FileStorage by default) +- Auto-registers Journal service (SQLiteJournal if pdo_sqlite available) +- Validates and creates temp directory +- Services registered: `cache.storage`, `cache.journal` + +**Latte Bridge** (`src/Bridges/CacheLatte/`): +- Provides `{cache}` tag for template caching +- Runtime in `Runtime.php` manages cache lifecycle +- Node compilation in `Nodes/CacheNode.php` +- Automatic invalidation when template source changes +- Supports parameters: `{cache $id, expire: '20 minutes', tags: [tag1, tag2]}` +- Can be conditional: `{cache $id, if: !$form->isSubmitted()}` + +**PSR-16 Bridge** (`src/Bridges/Psr/PsrCacheAdapter.php`): +- Adapts Nette Storage to PSR-16 SimpleCache interface +- Used for PSR compatibility in third-party integrations + +### Bulk Operations + +Two specialized classes enable efficient bulk operations: + +- **BulkReader** (`src/Caching/BulkReader.php`) - Interface for storages supporting bulk reads +- **BulkWriter** (`src/Caching/BulkWriter.php`) - Interface for storages supporting bulk writes + +Used by `Cache::bulkLoad()` and `Cache::bulkSave()` to reduce storage round-trips. + +## Testing Structure + +Tests organized by component in `tests/`: +- `Caching/` - Cache class tests +- `Storages/` - Storage implementation tests +- `Bridges.DI/` - Nette DI integration tests +- `Bridges.Latte3/` - Latte 3.x template caching tests +- `Bridges.Psr/` - PSR-16 adapter tests + +Test utilities: +- `bootstrap.php` - Test environment setup with `test()` helper function +- `getTempDir()` - Creates isolated temp directory per test process +- Uses Nette Tester with `.phpt` format + +## Development Notes + +### File Locking Strategy (FileStorage) + +Three atomic operation types documented in FileStorage.php: +1. **Reading**: open(r+b) → lock(LOCK_SH) → read → close +2. **Deleting**: unlink, if fails lock(LOCK_EX) → truncate → close → unlink +3. **Writing**: open(r+b or wb) → lock(LOCK_EX) → truncate → write data → write meta → close + +This ensures atomicity on both NTFS and ext3 filesystems. + +### Cache Stampede Prevention + +FileStorage prevents cache stampede through locking: when multiple concurrent threads request non-existent cache item, `lock()` ensures only first thread generates value while others wait. Others then use the generated result. + +### Namespace Handling + +Cache uses internal null byte separator (`Cache::NamespaceSeparator = "\x00"`) to isolate namespaces. Keys are prefixed with `{namespace}\x00{key}`. + +### Constants Naming + +Library uses modern PascalCase constants (e.g., `Cache::Expire`) with deprecated UPPERCASE aliases (e.g., `Cache::EXPIRATION`) for backward compatibility. + +**Version 3.0 compatibility note:** In version 3.0, the Storage interface was named `IStorage` (with `I` prefix) and constants were UPPERCASE (e.g., `Cache::EXPIRE` instead of `Cache::Expire`). + +## Using Cache in Code + +Two approaches for dependency injection: + +**Approach 1: Inject Storage, create Cache manually** +```php +class ClassOne +{ + private Nette\Caching\Cache $cache; + + public function __construct(Nette\Caching\Storage $storage) + { + $this->cache = new Nette\Caching\Cache($storage, 'my-namespace'); + } +} +``` + +**Approach 2: Inject Cache directly** +```php +class ClassTwo +{ + public function __construct( + private Nette\Caching\Cache $cache, + ) { + } +} +``` + +Configuration for Approach 2: +```neon +services: + - ClassTwo( Nette\Caching\Cache(namespace: 'my-namespace') ) +``` + +## DI Services + +Services automatically registered by CacheExtension: + +| Service Name | Type | Description | +|--------------|------|-------------| +| `cache.storage` | `Nette\Caching\Storage` | Primary cache storage (FileStorage by default) | +| `cache.journal` | `Nette\Caching\Storages\Journal` | Journal for tags/priorities (SQLiteJournal, requires pdo_sqlite) | + +## Configuration Examples + +### Change Storage Backend + +```neon +services: + cache.storage: Nette\Caching\Storages\DevNullStorage +``` + +### Use MemcachedStorage + +```neon +services: + cache.storage: Nette\Caching\Storages\MemcachedStorage('10.0.0.5') +``` + +### Use SQLiteStorage + +```neon +services: + cache.storage: Nette\Caching\Storages\SQLiteStorage('%tempDir%/cache.db') +``` + +### Custom Journal + +```neon +services: + cache.journal: MyJournal +``` + +### Disable Caching (for testing) + +```neon +services: + cache.storage: Nette\Caching\Storages\DevNullStorage +``` + +**Note:** This doesn't affect Latte template caching or DI container caching, as those are managed independently and [don't need to be disabled during development](https://doc.nette.org/troubleshooting#How-to-Disable-Cache-During-Development). + +## PSR-16 Usage + +The `PsrCacheAdapter` provides PSR-16 SimpleCache compatibility (available since v3.3.1): + +```php +$psrCache = new Nette\Bridges\Psr\PsrCacheAdapter($storage); + +// PSR-16 interface +$psrCache->set('key', 'value', 3600); +$value = $psrCache->get('key', 'default'); + +// Supports all PSR-16 methods +$psrCache->getMultiple(['key1', 'key2']); +$psrCache->setMultiple(['key1' => 'val1', 'key2' => 'val2']); +$psrCache->deleteMultiple(['key1', 'key2']); +``` diff --git a/composer.json b/composer.json index 510986f3..0582b985 100644 --- a/composer.json +++ b/composer.json @@ -15,11 +15,11 @@ } ], "require": { - "php": "8.1 - 8.5", + "php": "8.2 - 8.5", "nette/utils": "^4.0" }, "require-dev": { - "nette/tester": "^2.4", + "nette/tester": "^2.5", "nette/di": "^3.1 || ^4.0", "latte/latte": "^3.0.12", "tracy/tracy": "^2.9", @@ -45,7 +45,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.4-dev" + "dev-master": "4.0-dev" } } } diff --git a/src/Bridges/CacheDI/CacheExtension.php b/src/Bridges/CacheDI/CacheExtension.php index da6b1f89..31d9fb56 100644 --- a/src/Bridges/CacheDI/CacheExtension.php +++ b/src/Bridges/CacheDI/CacheExtension.php @@ -19,7 +19,7 @@ final class CacheExtension extends Nette\DI\CompilerExtension { public function __construct( - private string $tempDir, + private readonly string $tempDir, ) { } @@ -45,13 +45,5 @@ public function loadConfiguration(): void $builder->addDefinition($this->prefix('storage')) ->setType(Nette\Caching\Storage::class) ->setFactory(Nette\Caching\Storages\FileStorage::class, [$this->tempDir]); - - if ($this->name === 'cache') { - if (extension_loaded('pdo_sqlite')) { - $builder->addAlias('nette.cacheJournal', $this->prefix('journal')); - } - - $builder->addAlias('cacheStorage', $this->prefix('storage')); - } } } diff --git a/src/Bridges/CacheLatte/CacheExtension.php b/src/Bridges/CacheLatte/CacheExtension.php index 3d38a9d9..bbe6b81b 100644 --- a/src/Bridges/CacheLatte/CacheExtension.php +++ b/src/Bridges/CacheLatte/CacheExtension.php @@ -22,12 +22,11 @@ final class CacheExtension extends Latte\Extension { private bool $used; - private Storage $storage; - public function __construct(Storage $storage) - { - $this->storage = $storage; + public function __construct( + private readonly Storage $storage, + ) { } diff --git a/src/Bridges/CacheLatte/Runtime.php b/src/Bridges/CacheLatte/Runtime.php index 8b3c8cb6..e45b9356 100644 --- a/src/Bridges/CacheLatte/Runtime.php +++ b/src/Bridges/CacheLatte/Runtime.php @@ -27,7 +27,7 @@ class Runtime public function __construct( - private Nette\Caching\Storage $storage, + private readonly Nette\Caching\Storage $storage, ) { } @@ -45,6 +45,7 @@ public function initialize(Latte\Runtime\Template $template): void /** * Starts the output cache. Returns true if buffering was started. + * @param array|null $args {if?: bool, tags?: string[], expire?: string, expiration?: string, dependencies?: callable} */ public function createCache(string $key, ?array $args = null): bool { diff --git a/src/Bridges/Psr/PsrCacheAdapter.php b/src/Bridges/Psr/PsrCacheAdapter.php index e06ea069..3765f403 100644 --- a/src/Bridges/Psr/PsrCacheAdapter.php +++ b/src/Bridges/Psr/PsrCacheAdapter.php @@ -17,7 +17,7 @@ class PsrCacheAdapter implements Psr\SimpleCache\CacheInterface { public function __construct( - private Nette\Caching\Storage $storage, + private readonly Nette\Caching\Storage $storage, ) { } @@ -81,6 +81,7 @@ public function setMultiple(iterable $values, null|int|DateInterval $ttl = null) } + /** @param iterable $keys */ public function deleteMultiple(iterable $keys): bool { foreach ($keys as $value) { diff --git a/src/Caching/BulkReader.php b/src/Caching/BulkReader.php index 78dc1747..0cac60a5 100644 --- a/src/Caching/BulkReader.php +++ b/src/Caching/BulkReader.php @@ -17,7 +17,8 @@ interface BulkReader { /** * Reads from cache in bulk. - * @return array key => value pairs, missing items are omitted + * @param string[] $keys + * @return array key => value pairs, missing items are omitted */ function bulkRead(array $keys): array; } diff --git a/src/Caching/BulkWriter.php b/src/Caching/BulkWriter.php index 38d88fb4..33a74d8d 100644 --- a/src/Caching/BulkWriter.php +++ b/src/Caching/BulkWriter.php @@ -17,12 +17,14 @@ interface BulkWriter { /** * Writes to cache in bulk. - * @param array{string, mixed} $items + * @param array $items + * @param array $dependencies */ function bulkWrite(array $items, array $dependencies): void; /** - * Removes multiple items from cache + * Removes multiple items from cache. + * @param string[] $keys */ function bulkRemove(array $keys): void; } diff --git a/src/Caching/Cache.php b/src/Caching/Cache.php index ce1605a3..c6c89766 100644 --- a/src/Caching/Cache.php +++ b/src/Caching/Cache.php @@ -31,43 +31,41 @@ class Cache Namespaces = 'namespaces', All = 'all'; - /** @deprecated use Cache::Priority */ + #[\Deprecated('use Cache::Priority')] public const PRIORITY = self::Priority; - /** @deprecated use Cache::Expire */ + #[\Deprecated('use Cache::Expire')] public const EXPIRATION = self::Expire; - /** @deprecated use Cache::Expire */ + #[\Deprecated('use Cache::Expire')] public const EXPIRE = self::Expire; - /** @deprecated use Cache::Sliding */ + #[\Deprecated('use Cache::Sliding')] public const SLIDING = self::Sliding; - /** @deprecated use Cache::Tags */ + #[\Deprecated('use Cache::Tags')] public const TAGS = self::Tags; - /** @deprecated use Cache::Files */ + #[\Deprecated('use Cache::Files')] public const FILES = self::Files; - /** @deprecated use Cache::Items */ + #[\Deprecated('use Cache::Items')] public const ITEMS = self::Items; - /** @deprecated use Cache::Constants */ + #[\Deprecated('use Cache::Constants')] public const CONSTS = self::Constants; - /** @deprecated use Cache::Callbacks */ + #[\Deprecated('use Cache::Callbacks')] public const CALLBACKS = self::Callbacks; - /** @deprecated use Cache::Namespaces */ + #[\Deprecated('use Cache::Namespaces')] public const NAMESPACES = self::Namespaces; - /** @deprecated use Cache::All */ + #[\Deprecated('use Cache::All')] public const ALL = self::All; /** @internal */ - public const - NamespaceSeparator = "\x00", - NAMESPACE_SEPARATOR = self::NamespaceSeparator; + public const NamespaceSeparator = "\x00"; private Storage $storage; private string $namespace; @@ -159,7 +157,7 @@ public function bulkLoad(array $keys, ?callable $generator = null): array return $result; } - $storageKeys = array_map([$this, 'generateKey'], $keys); + $storageKeys = array_map($this->generateKey(...), $keys); $cacheData = $this->storage->bulkRead($storageKeys); foreach ($keys as $i => $key) { $storageKey = $storageKeys[$i]; @@ -235,7 +233,7 @@ public function bulkSave(array $items, ?array $dependencies = null): void $dependencies = $this->completeDependencies($dependencies); if (isset($dependencies[self::Expire]) && $dependencies[self::Expire] <= 0) { - $this->storage->bulkRemove(array_map(fn($key) => $this->generateKey($key), array_keys($items))); + $this->storage->bulkRemove(array_map($this->generateKey(...), array_keys($items))); return; } @@ -286,7 +284,7 @@ private function completeDependencies(?array $dp): array // add namespaces to items if (isset($dp[self::Items])) { - $dp[self::Items] = array_unique(array_map([$this, 'generateKey'], (array) $dp[self::Items])); + $dp[self::Items] = array_unique(array_map($this->generateKey(...), (array) $dp[self::Items])); } // convert CONSTS into CALLBACKS @@ -340,7 +338,7 @@ public function call(callable $function): mixed { $key = func_get_args(); if (is_array($function) && is_object($function[0])) { - $key[0][0] = get_class($function[0]); + $key[0][0] = $function[0]::class; } return $this->load($key, fn() => $function(...array_slice($key, 1))); @@ -355,7 +353,7 @@ public function wrap(callable $function, ?array $dependencies = null): \Closure return function () use ($function, $dependencies) { $key = [$function, $args = func_get_args()]; if (is_array($function) && is_object($function[0])) { - $key[0][0] = get_class($function[0]); + $key[0][0] = $function[0]::class; } return $this->load($key, function (&$deps) use ($function, $args, $dependencies) { @@ -381,11 +379,10 @@ public function capture(mixed $key): ?OutputHelper } - /** - * @deprecated use capture() - */ + #[\Deprecated('use capture()')] public function start($key): ?OutputHelper { + trigger_error(__METHOD__ . '() was renamed to capture()', E_USER_DEPRECATED); return $this->capture($key); } diff --git a/src/Caching/OutputHelper.php b/src/Caching/OutputHelper.php index 2256a9bb..086582dc 100644 --- a/src/Caching/OutputHelper.php +++ b/src/Caching/OutputHelper.php @@ -17,21 +17,21 @@ */ class OutputHelper { + /** @var array */ public array $dependencies = []; - private ?Cache $cache; - private mixed $key; - public function __construct(Cache $cache, mixed $key) - { - $this->cache = $cache; - $this->key = $key; + public function __construct( + private ?Cache $cache, + private mixed $key, + ) { ob_start(); } /** * Stops and saves the cache. + * @param array $dependencies */ public function end(array $dependencies = []): void { diff --git a/src/Caching/Storage.php b/src/Caching/Storage.php index e9565d8b..32cd28e2 100644 --- a/src/Caching/Storage.php +++ b/src/Caching/Storage.php @@ -17,9 +17,8 @@ interface Storage { /** * Read from cache. - * @return mixed */ - function read(string $key); + function read(string $key): mixed; /** * Prevents item reading and writing. Lock is released by write() or remove(). @@ -28,6 +27,7 @@ function lock(string $key): void; /** * Writes item into the cache. + * @param array $dependencies */ function write(string $key, $data, array $dependencies): void; @@ -38,6 +38,7 @@ function remove(string $key): void; /** * Removes items from the cache by conditions. + * @param array $conditions */ function clean(array $conditions): void; } diff --git a/src/Caching/Storages/FileStorage.php b/src/Caching/Storages/FileStorage.php index 19eff36a..64a165c5 100644 --- a/src/Caching/Storages/FileStorage.php +++ b/src/Caching/Storages/FileStorage.php @@ -52,6 +52,8 @@ class FileStorage implements Nette\Caching\Storage private string $dir; private ?Journal $journal; + + /** @var array key => file handle */ private array $locks; @@ -81,6 +83,7 @@ public function read(string $key): mixed /** * Verifies dependencies. + * @param array $meta */ private function verify(array $meta): bool { @@ -135,6 +138,7 @@ public function lock(string $key): void } + /** @param array $dp */ public function write(string $key, $data, array $dp): void { $meta = [ @@ -223,6 +227,7 @@ public function remove(string $key): void } + /** @param array $conditions */ public function clean(array $conditions): void { $all = !empty($conditions[Cache::All]); @@ -292,6 +297,7 @@ public function clean(array $conditions): void /** * Reads cache data from disk. + * @return array|null meta data with 'file' and 'handle' keys added, or null if not found */ protected function readMetaAndLock(string $file, int $lock): ?array { @@ -319,6 +325,7 @@ protected function readMetaAndLock(string $file, int $lock): ?array /** * Reads cache data from disk and closes cache file handle. + * @param array $meta */ protected function readData(array $meta): mixed { diff --git a/src/Caching/Storages/Journal.php b/src/Caching/Storages/Journal.php index 1815073a..e301dc44 100644 --- a/src/Caching/Storages/Journal.php +++ b/src/Caching/Storages/Journal.php @@ -17,12 +17,14 @@ interface Journal { /** * Writes entry information into the journal. + * @param array $dependencies {Cache::Tags => string[], Cache::Priority => int} */ function write(string $key, array $dependencies): void; /** * Cleans entries from journal. - * @return array|null of removed items or null when performing a full cleanup + * @param array $conditions {Cache::Tags => string[], Cache::Priority => int, Cache::All => bool} + * @return string[]|null array of removed keys or null when performing a full cleanup */ function clean(array $conditions): ?array; } diff --git a/src/Caching/Storages/MemcachedStorage.php b/src/Caching/Storages/MemcachedStorage.php index b9430f11..22732c89 100644 --- a/src/Caching/Storages/MemcachedStorage.php +++ b/src/Caching/Storages/MemcachedStorage.php @@ -25,9 +25,7 @@ class MemcachedStorage implements Nette\Caching\Storage, Nette\Caching\BulkReade MetaData = 'data', MetaDelta = 'delta'; - private \Memcached $memcached; - private string $prefix; - private ?Journal $journal; + private readonly \Memcached $memcached; /** @@ -42,15 +40,12 @@ public static function isAvailable(): bool public function __construct( string $host = 'localhost', int $port = 11211, - string $prefix = '', - ?Journal $journal = null, + private readonly string $prefix = '', + private readonly ?Journal $journal = null, ) { if (!static::isAvailable()) { throw new Nette\NotSupportedException("PHP extension 'memcached' is not loaded."); } - - $this->prefix = $prefix; - $this->journal = $journal; $this->memcached = new \Memcached; if ($host) { $this->addServer($host, $port); diff --git a/src/Caching/Storages/MemoryStorage.php b/src/Caching/Storages/MemoryStorage.php index 55abc051..3678166e 100644 --- a/src/Caching/Storages/MemoryStorage.php +++ b/src/Caching/Storages/MemoryStorage.php @@ -10,6 +10,8 @@ namespace Nette\Caching\Storages; use Nette; +use Nette\Caching\Cache; +use function time; /** @@ -17,12 +19,39 @@ */ class MemoryStorage implements Nette\Caching\Storage { + private const + // meta structure: array keys + MetaTags = 'tags', + MetaData = 'data', // value store + MetaExpire = 'expire', // expiration timestamp + MetaDelta = 'delta', // relative (sliding) expiration + MetaPriority = 'priority'; + + /** @var array key => entry */ private array $data = []; public function read(string $key): mixed { - return $this->data[$key] ?? null; + if (!isset($this->data[$key])) { + return null; + } + + $entry = $this->data[$key]; + + if ($entry[self::MetaDelta] !== null) { + if ($entry[self::MetaExpire] < time()) { + unset($this->data[$key]); + return null; + } + + $this->data[$key][self::MetaExpire] = time() + $entry[self::MetaDelta]; + } elseif ($entry[self::MetaExpire] !== null && $entry[self::MetaExpire] < time()) { + unset($this->data[$key]); + return null; + } + + return $entry[self::MetaData]; } @@ -33,7 +62,20 @@ public function lock(string $key): void public function write(string $key, $data, array $dependencies): void { - $this->data[$key] = $data; + $expire = isset($dependencies[Cache::Expire]) + ? $dependencies[Cache::Expire] + time() + : null; + $delta = isset($dependencies[Cache::Sliding]) + ? (int) $dependencies[Cache::Expire] + : null; + + $this->data[$key] = [ + self::MetaData => $data, + self::MetaExpire => $expire, + self::MetaDelta => $delta, + self::MetaTags => $dependencies[Cache::Tags] ?? [], + self::MetaPriority => $dependencies[Cache::Priority] ?? null, + ]; } @@ -45,8 +87,27 @@ public function remove(string $key): void public function clean(array $conditions): void { - if (!empty($conditions[Nette\Caching\Cache::All])) { + if (!empty($conditions[Cache::All])) { $this->data = []; + return; + } + + if (!empty($conditions[Cache::Tags])) { + $tags = (array) $conditions[Cache::Tags]; + foreach ($this->data as $key => $entry) { + if (array_intersect($tags, $entry[self::MetaTags])) { + unset($this->data[$key]); + } + } + } + + if (isset($conditions[Cache::Priority])) { + $limit = (int) $conditions[Cache::Priority]; + foreach ($this->data as $key => $entry) { + if ($entry[self::MetaPriority] !== null && $entry[self::MetaPriority] <= $limit) { + unset($this->data[$key]); + } + } } } } diff --git a/src/Caching/Storages/SQLiteJournal.php b/src/Caching/Storages/SQLiteJournal.php index 4de19120..2396d7c9 100644 --- a/src/Caching/Storages/SQLiteJournal.php +++ b/src/Caching/Storages/SQLiteJournal.php @@ -19,18 +19,15 @@ */ class SQLiteJournal implements Journal { - /** @string */ - private $path; private \PDO $pdo; - public function __construct(string $path) - { + public function __construct( + private readonly string $path, + ) { if (!extension_loaded('pdo_sqlite')) { throw new Nette\NotSupportedException('SQLiteJournal requires PHP extension pdo_sqlite which is not loaded.'); } - - $this->path = $path; } diff --git a/src/Caching/Storages/SQLiteStorage.php b/src/Caching/Storages/SQLiteStorage.php index 576ceb43..4670a605 100644 --- a/src/Caching/Storages/SQLiteStorage.php +++ b/src/Caching/Storages/SQLiteStorage.php @@ -19,7 +19,7 @@ */ class SQLiteStorage implements Nette\Caching\Storage, Nette\Caching\BulkReader { - private \PDO $pdo; + private readonly \PDO $pdo; public function __construct(string $path) diff --git a/tests/Bridges.DI/CacheExtension.phpt b/tests/Bridges.DI/CacheExtension.phpt index cc735739..00603dd5 100644 --- a/tests/Bridges.DI/CacheExtension.phpt +++ b/tests/Bridges.DI/CacheExtension.phpt @@ -2,6 +2,7 @@ /** * Test: CacheExtension. + * @phpExtension pdo_sqlite */ declare(strict_types=1); @@ -28,8 +29,4 @@ test('', function () { $storage = $container->getService('cache.storage'); Assert::type(Nette\Caching\Storages\FileStorage::class, $storage); - - // aliases - Assert::same($journal, $container->getService('nette.cacheJournal')); - Assert::same($storage, $container->getService('cacheStorage')); }); diff --git a/tests/Bridges.Latte3/expected/cache.inc.php b/tests/Bridges.Latte3/expected/cache.inc.php index 111dde50..cadd9ce8 100644 --- a/tests/Bridges.Latte3/expected/cache.inc.php +++ b/tests/Bridges.Latte3/expected/cache.inc.php @@ -1,12 +1,12 @@ global->cache->createCache('%a%')) /* line %a% */ + if ($this->global->cache->createCache('%a%')) /* pos %a% */ try { echo ' '; - echo LR\%a%(($this->filters->lower)($title)) /* line %a% */; + echo LR\%a%(($this->filters->lower)($title)) /* pos %a% */; echo "\n"; - $this->global->cache->end() /* line %a% */; + $this->global->cache->end() /* pos %a% */; } catch (\Throwable $ʟ_e) { $this->global->cache->rollback(); throw $ʟ_e; diff --git a/tests/Bridges.Latte3/expected/cache.php b/tests/Bridges.Latte3/expected/cache.php index 1dee4fee..6418cd6d 100644 --- a/tests/Bridges.Latte3/expected/cache.php +++ b/tests/Bridges.Latte3/expected/cache.php @@ -3,18 +3,18 @@ echo 'Noncached content '; - if ($this->global->cache->createCache('%a%', [$id, 'tags' => 'mytag'])) /* line %a% */ + if ($this->global->cache->createCache('%a%', [$id, 'tags' => 'mytag'])) /* pos %a% */ try { echo '

'; - echo LR\%a%(($this->filters->upper)($title)) /* line %a% */; + echo LR\%a%(($this->filters->upper)($title)) /* pos %a% */; echo '

'; - $this->createTemplate('include.cache.latte', ['localvar' => 11] + $this->params, 'include')->renderToContentType('html') /* line %a% */; + $this->createTemplate('include.cache.latte', ['localvar' => 11] + $this->params, 'include')->renderToContentType('html') /* pos %a% */; echo "\n"; - $this->global->cache->end() /* line %a% */; + $this->global->cache->end() /* pos %a% */; } catch (\Throwable $ʟ_e) { $this->global->cache->rollback(); throw $ʟ_e; diff --git a/tests/Bridges.Latte3/{cache}.phpt b/tests/Bridges.Latte3/{cache}.phpt index cb5ea876..26fe6fdd 100644 --- a/tests/Bridges.Latte3/{cache}.phpt +++ b/tests/Bridges.Latte3/{cache}.phpt @@ -2,6 +2,8 @@ /** * Test: {cache ...} + * @phpExtension tokenizer + * @phpExtension mbstring */ declare(strict_types=1); diff --git a/tests/Storages/MemoryStorage.expiration.phpt b/tests/Storages/MemoryStorage.expiration.phpt new file mode 100644 index 00000000..b1a7768d --- /dev/null +++ b/tests/Storages/MemoryStorage.expiration.phpt @@ -0,0 +1,36 @@ +save($key, $value, [ + Cache::Expire => time() + 3, +]); + + +// Sleeping 1 second +sleep(1); +Assert::truthy($cache->load($key)); + + +// Sleeping 3 seconds +sleep(3); +Assert::null($cache->load($key)); diff --git a/tests/Storages/MemoryStorage.priority.phpt b/tests/Storages/MemoryStorage.priority.phpt new file mode 100644 index 00000000..158ea9cc --- /dev/null +++ b/tests/Storages/MemoryStorage.priority.phpt @@ -0,0 +1,44 @@ +save('key1', 'value1', [ + Cache::Priority => 100, +]); + +$cache->save('key2', 'value2', [ + Cache::Priority => 200, +]); + +$cache->save('key3', 'value3', [ + Cache::Priority => 300, +]); + +$cache->save('key4', 'value4'); + + +// Cleaning by priority... +$cache->clean([ + Cache::Priority => '200', +]); + +Assert::null($cache->load('key1')); +Assert::null($cache->load('key2')); +Assert::truthy($cache->load('key3')); +Assert::truthy($cache->load('key4')); diff --git a/tests/Storages/MemoryStorage.sliding.phpt b/tests/Storages/MemoryStorage.sliding.phpt new file mode 100644 index 00000000..3c840040 --- /dev/null +++ b/tests/Storages/MemoryStorage.sliding.phpt @@ -0,0 +1,40 @@ +save($key, $value, [ + Cache::Expire => time() + 3, + Cache::Sliding => true, +]); + + +for ($i = 0; $i < 5; $i++) { + // Sleeping 1 second + sleep(1); + + Assert::truthy($cache->load($key)); +} + +// Sleeping few seconds... +sleep(5); + +Assert::null($cache->load($key)); diff --git a/tests/Storages/MemoryStorage.tags.phpt b/tests/Storages/MemoryStorage.tags.phpt new file mode 100644 index 00000000..798d6f99 --- /dev/null +++ b/tests/Storages/MemoryStorage.tags.phpt @@ -0,0 +1,44 @@ +save('key1', 'value1', [ + Cache::Tags => ['one', 'two'], +]); + +$cache->save('key2', 'value2', [ + Cache::Tags => ['one', 'three'], +]); + +$cache->save('key3', 'value3', [ + Cache::Tags => ['two', 'three'], +]); + +$cache->save('key4', 'value4'); + + +// Cleaning by tags... +$cache->clean([ + Cache::Tags => ['one', 'xx'], +]); + +Assert::null($cache->load('key1')); +Assert::null($cache->load('key2')); +Assert::truthy($cache->load('key3')); +Assert::truthy($cache->load('key4')); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 02cd1ef2..18385fa9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -13,6 +13,7 @@ // configure environment Tester\Environment::setup(); +Tester\Environment::setupFunctions(); date_default_timezone_set('Europe/Prague'); @@ -35,9 +36,3 @@ function getTempDir(): string return $dir; } - - -function test(string $title, Closure $function): void -{ - $function(); -}