diff --git a/README.md b/README.md index cb4c01e..719cb88 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ class ArticlePage extends \Kirby\Cms\Page { use \Bnomei\ModelWithNitro; } -`` +``` > [!NOTE] > You can also use the trait for user models. File models are patched automatically. diff --git a/classes/Nitro.php b/classes/Nitro.php index 74d94ad..1ccd085 100644 --- a/classes/Nitro.php +++ b/classes/Nitro.php @@ -4,7 +4,10 @@ use Bnomei\Nitro\DirInventory; use Bnomei\Nitro\SingleFileCache; +use Closure; +use Kirby\Cache\FileCache; use Kirby\Cms\App; +use Kirby\Cms\Files; use Kirby\Filesystem\Dir; use Kirby\Filesystem\F; use Kirby\Toolkit\Str; @@ -18,17 +21,20 @@ class Nitro private ?SingleFileCache $singleFileCache = null; + /** + * @var true + */ + private bool $_ready = false; + public function __construct(array $options = []) { $this->options = array_merge([ - 'enabled' => true, 'cacheDir' => realpath(__DIR__.'/../').'/cache', - 'cacheType' => 'json', 'json-encode-flags' => JSON_THROW_ON_ERROR, ], $options); foreach ($this->options as $key => $value) { - if ($value instanceof \Closure) { + if ($value instanceof Closure) { $this->options[$key] = $value(); } } @@ -66,6 +72,13 @@ public function ready(): void $this->replaceCacheFolderWithSymlink(); $this->patchFilesClass(); $this->dir()->patchDirClass(); + + $this->_ready = true; + } + + public function isReady(): bool + { + return $this->_ready; } /* @@ -73,12 +86,8 @@ public function ready(): void */ private function replaceCacheFolderWithSymlink(): bool { - if (! $this->options['enabled']) { - return false; - } - $internalDir = $this->options['cacheDir']; - /** @var \Kirby\Cache\FileCache $cache */ + /** @var FileCache $cache */ $cache = kirby()->cache('bnomei.nitro.dir'); $kirbyDir = $cache->root(); @@ -97,18 +106,18 @@ private function replaceCacheFolderWithSymlink(): bool return true; } - private function patchFilesClass(): void + public function patchFilesClass(): bool { if (option('bnomei.nitro.patch-files-class') !== true) { - return; + return false; } $patch = $this->options['cacheDir'].'/files.'.App::versionHash().'.patch'; if (file_exists($patch)) { - return; + return false; } - $filesClass = (new ReflectionClass(\Kirby\Cms\Files::class))->getFileName(); + $filesClass = (new ReflectionClass(Files::class))->getFileName(); if ($filesClass && F::exists($filesClass) && F::isWritable($filesClass)) { $code = F::read($filesClass); if ($code && Str::contains($code, '\Bnomei\NitroFile::factory') === false) { @@ -116,11 +125,14 @@ private function patchFilesClass(): void F::write($filesClass, $code); if (function_exists('opcache_invalidate')) { - opcache_invalidate($filesClass); + opcache_invalidate($filesClass); // @codeCoverageIgnore } } - F::write($patch, date('c')); + + return F::write($patch, date('c')); } + + return false; } public function modelIndex(): int diff --git a/classes/Nitro/AbortCachingExeption.php b/classes/Nitro/AbortCachingExeption.php index b64c763..caa3250 100644 --- a/classes/Nitro/AbortCachingExeption.php +++ b/classes/Nitro/AbortCachingExeption.php @@ -4,7 +4,7 @@ class AbortCachingExeption extends \Exception { - public function __construct($message = 'Abort Caching', $code = 0, ?\Throwable $previous = null) + public function __construct(string $message = 'Abort Caching', int $code = 0, ?\Throwable $previous = null) { parent::__construct($message, $code, $previous); } diff --git a/classes/Nitro/DirInventory.php b/classes/Nitro/DirInventory.php index 1ba81e2..971dead 100644 --- a/classes/Nitro/DirInventory.php +++ b/classes/Nitro/DirInventory.php @@ -14,7 +14,7 @@ class DirInventory private bool $isDirty; - private array $options = []; + private array $options; public function __construct(array $options = []) { @@ -28,54 +28,29 @@ public function __construct(array $options = []) public function __destruct() { - if (! $this->isDirty || ! $this->enabled()) { - return; - } - - $file = $this->file(); - - if ($this->cacheType() === 'php') { - F::write($file, 'data, true).';'); - if (function_exists('opcache_invalidate')) { - opcache_invalidate($file); - } - } else { - F::write($file, json_encode($this->data, $this->options['json-encode-flags'])); - } + $this->write(); } public function file(): string { - if ($this->cacheType() === 'php') { - return $this->cacheDir().'/dir-inventory.php'; - } - return $this->cacheDir().'/dir-inventory.json'; } private function load(): void { - if (! $this->enabled() || ! file_exists($this->file())) { + if (! file_exists($this->file())) { return; } - if ($this->cacheType() === 'php') { - $this->data = include $this->file(); - } else { - $data = file_get_contents($this->file()); - $data = $data ? json_decode($data, true) : []; - if (is_array($data) || is_null($data)) { - $this->data = $data; - } + $data = file_get_contents($this->file()); + $data = $data ? json_decode($data, true) : []; + if (is_array($data) || is_null($data)) { + $this->data = $data; } } public function get(string|array $key): ?array { - if (! $this->enabled()) { - return null; - } - $key = $this->key($key); return A::get($this->data, $key); @@ -83,10 +58,6 @@ public function get(string|array $key): ?array public function set(string|array $key, ?array $input = null): void { - if (! $this->enabled()) { - return; - } - $this->isDirty = true; $key = $this->key($key); $this->data[$key] = $input; @@ -108,31 +79,16 @@ private function key(string|array $key): string return is_array($key) ? hash('xxh3', print_r($key, true)) : $key; } - private function enabled(): bool - { - return $this->options['enabled']; - } - - private function cacheDir(): string + public function cacheDir(): string { return $this->options['cacheDir']; } - private function cacheType(): string + public function patchDirClass(): bool { - return $this->options['cacheType']; - } - - public function patchDirClass(): void - { - - if (! $this->enabled()) { - return; - } - $patch = $this->cacheDir().'/dir-inventory.'.App::versionHash().'.patch'; if (file_exists($patch)) { - return; + return false; } $reflection = new ReflectionClass(Dir::class); @@ -141,7 +97,7 @@ public function patchDirClass(): void $content = $file ? file_get_contents($file) : null; if (! $file || ! $content) { - return; + return false; } $head = <<<'CODE' @@ -171,10 +127,22 @@ public function patchDirClass(): void F::write($file, $content); if (function_exists('opcache_invalidate')) { - opcache_invalidate($file); + opcache_invalidate($file); // @codeCoverageIgnore } } - F::write($patch, date('c')); + return F::write($patch, date('c')); + } + + public function write(): bool + { + if (! $this->isDirty) { + return false; + } + + $file = $this->file(); + $this->isDirty = false; + + return F::write($file, json_encode($this->data, $this->options['json-encode-flags'])); } } diff --git a/classes/Nitro/SingleFileCache.php b/classes/Nitro/SingleFileCache.php index 766faff..882e3d4 100644 --- a/classes/Nitro/SingleFileCache.php +++ b/classes/Nitro/SingleFileCache.php @@ -80,7 +80,7 @@ public function set(string|array $key, mixed $value, int $minutes = 0): bool $this->data[$key] = (new Value($value, $minutes))->toArray(); $this->isDirty++; - if ($this->isDirty > $this->options['max-dirty-cache']) { + if ($this->isDirty >= $this->options['max-dirty-cache']) { $this->write(); } @@ -90,7 +90,7 @@ public function set(string|array $key, mixed $value, int $minutes = 0): bool /** * {@inheritDoc} */ - public function retrieve(string|array $key): ?Value + public function retrieve(string $key): ?Value { $value = A::get($this->data, $this->key($key)); @@ -101,12 +101,16 @@ public function retrieve(string|array $key): ?Value return is_array($value) ? Value::fromArray($value) : $value; } - public function get(string $key, mixed $default = null): mixed + public function get(array|string $key, mixed $default = null): mixed { if ($this->options['debug']) { return $default; } + if (is_array($key)) { + $key = print_r($key, true); + } + return parent::get($key, $default); } @@ -119,7 +123,7 @@ public function remove(string|array $key): bool if (array_key_exists($key, $this->data)) { unset($this->data[$key]); $this->isDirty++; - if ($this->isDirty > $this->options['max-dirty-cache']) { + if ($this->isDirty >= $this->options['max-dirty-cache']) { $this->write(); } } @@ -161,10 +165,9 @@ public function write(): bool if ($this->isDirty === 0) { return false; } - F::write($this->file(), json_encode($this->data, $this->options['json-encode-flags'])); $this->isDirty = 0; - return true; + return F::write($this->file(), json_encode($this->data, $this->options['json-encode-flags'])); } private static function isCallable(mixed $value): bool @@ -195,4 +198,9 @@ public function serialize(mixed $value): mixed return $value; } + + public function count(): int + { + return count($this->data); + } } diff --git a/index.php b/index.php index b183b47..9952133 100644 --- a/index.php +++ b/index.php @@ -43,9 +43,6 @@ function nitro(): \Bnomei\Nitro 'args' => [], 'command' => static function ($cli): void { - $kirby = $cli->kirby(); - $kirby->impersonate('kirby'); - $cli->out('Indexing...'); $count = nitro()->modelIndex(); $cli->out($count.' models indexed.'); diff --git a/tests/NitroTest.php b/tests/NitroTest.php index faaead4..f3f5e7b 100644 --- a/tests/NitroTest.php +++ b/tests/NitroTest.php @@ -1,6 +1,8 @@ flush(); // cleanup @@ -13,11 +15,27 @@ }); it('can use the cache', function () { + Nitro::$singleton = null; + + // force instant writes for this test + Nitro::singleton([ + 'max-dirty-cache' => 1, + ]); + $cache = nitro()->cache(); $cache->set('test', 'value'); $value = $cache->get('test'); expect($value)->toBe('value'); + + $cache->set(['test'], 'value'); + $value = $cache->get(['test']); + + expect($value)->toBe('value'); + + $cache->remove('test'); + + expect($cache->get('test'))->toBeNull(); }); it('will serialize kirby\content\fields to their value', function () { @@ -64,3 +82,151 @@ expect($cache->get('test'))->toBe('value'); }); + +it('can have a closure as option', function () { + Nitro::$singleton = null; + Nitro::singleton(['test' => function () { + return 'value'; + }]); + + expect(nitro()->option('test'))->toBe('value'); +}); + +it('will create the local cache dir if it is missing', function () { + Nitro::$singleton = null; + + expect(Dir::remove(__DIR__.'/../cache'))->toBeTrue(); + expect(Dir::exists(__DIR__.'/../cache'))->toBeFalse(); + + nitro(); + + expect(Dir::exists(nitro()->option('cacheDir')))->toBeTrue(); +}); + +it('has a ready method to apply the monkey patches', function () { + Nitro::$singleton = null; + nitro()->ready(); + + expect(nitro()->isReady())->toBeTrue(); +}); + +it('will remove files and folder when flushing but not break symlinks', function () { + $dir = __DIR__.'/../cache'; + $inode = fileinode($dir); + F::write($dir.'/test.txt', 'test'); + Dir::make($dir.'/test'); + + nitro()->flush(); + + expect(F::exists($dir.'/test.txt'))->toBeFalse() + ->and(Dir::exists($dir.'/test'))->toBeFalse() + ->and(fileinode($dir))->toBe($inode); +}); + +it('can update its index', function () { + $count = nitro()->modelIndex(); + + expect($count)->toBeGreaterThan(0); +}); + +it('will symlink the kirby cache folder to the local cache folder so the dir index is the same', function () { + + Nitro::$singleton = null; + + $internalDir = nitro()->option('cacheDir'); + $cache = kirby()->cache('bnomei.nitro.dir'); + $kirbyDir = $cache->root(); + + Dir::remove($internalDir); // should be created if missing + @unlink($kirbyDir); // is file but should be symlink + Dir::make($kirbyDir); // is dir but should be symlink + + nitro()->ready(); + + expect(is_link($kirbyDir))->toBeTrue() + ->and(readlink($kirbyDir))->toBe($internalDir); +}); + +it('can patch the files class', function () { + Nitro::$singleton = null; + + $patch = nitro()->option('cacheDir').'/files.'.kirby()->versionHash().'.patch'; + F::remove($patch); + + $success = nitro()->patchFilesClass(); + + expect(F::exists($patch))->toBeTrue() + ->and($success)->toBeTrue() + ->and(nitro()->patchFilesClass())->toBeFalse(); +}); + +it('can patch the dir class', function () { + Nitro::$singleton = null; + + $patch = nitro()->option('cacheDir').'/dir-inventory.'.kirby()->versionHash().'.patch'; + F::remove($patch); + + $success = nitro()->dir()->patchDirClass(); + + expect(F::exists($patch))->toBeTrue() + ->and($success)->toBeTrue() + ->and(nitro()->dir()->patchDirClass())->toBeFalse(); +}); + +it('can flush the dir cache', function () { + nitro()->modelIndex(); + $di = nitro()->dir(); + $di->write(); // force now, not on destruct + + expect(Dir::files($di->cacheDir()))->toHaveCount(1) + ->and($di->write())->toBeFalse(); + + $di->flush(); + + expect(Dir::files($di->cacheDir()))->toHaveCount(0); +}); + +it('can serialize even null values', function () { + $cache = nitro()->cache(); + $cache->set('null', null); + + $value = $cache->get('null'); + + expect($value)->toBeNull() + ->and($cache->count())->toBe(1); +}); + +it('will update the model cache if the model is updated', function () { + nitro()->modelIndex(); + $cache = nitro()->cache(); + + $home = page('home'); + kirby()->impersonate('kirby'); + $home = $home->update([ + 'title' => 'new title', + ]); + + $value = $cache->get($home->keyNitro()); + expect($value)->toHaveKey('title', 'new title'); + + // reset + $home->update([ + 'title' => 'Home', + ]); +}); + +it('will update the model cache if the model is deleted', function () { + $home = page('home'); + kirby()->impersonate('kirby'); + $page = $home->createChild([ + 'slug' => 'test', + ]); + + nitro()->modelIndex(); + $cache = nitro()->cache(); + $count = $cache->count(); + + $page->delete(true); + + expect($cache->count())->toBe($count - 1); +}); diff --git a/tests/content/home/home.en.txt b/tests/content/home/home.en.txt index f0c69ed..e8c56e3 100644 --- a/tests/content/home/home.en.txt +++ b/tests/content/home/home.en.txt @@ -2,4 +2,12 @@ Title: Home ---- +Text: + +---- + +Nt-text: + +---- + Uuid: EKIXBbdWvX54TqqQ \ No newline at end of file