diff --git a/.gitignore b/.gitignore index 8d8dc39..ff7ba29 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ node_modules docs/.vitepress/dist/ docs/.vitepress/cache/ + +tests/storage/logs/ diff --git a/composer.json b/composer.json index c75fcb6..2b528f0 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "docs": "https://craft-toolbelt.zaengle.com" }, "require": { - "php": "^8.0 | ^8.1 | ^8.2", + "php": "^8.0 | ^8.1 | ^8.2 | ^8.3", "newridetech/php-classnames": "^1.2" }, "require-dev": { @@ -46,7 +46,7 @@ } }, "scripts": { - "phpstan": "vendor/bin/phpstan analyse", + "phpstan": "vendor/bin/phpstan analyse --memory-limit=1G --ansi", "test": "vendor/bin/pest", "lint": "vendor/bin/pest coverage", "check-cs": "ecs check --ansi", diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 26d09f3..6481a45 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -25,6 +25,7 @@ export default { { text: 'Eager Loading Helpers', link: '/04-eager-loading-helpers' }, { text: 'Debugging Helpers', link: '/05-debugging-helpers' }, { text: 'Media Helpers', link: '/08-media-helpers' }, + { text: 'The Stash', link: '/09-stash' }, { text: 'Operators', link: '/06-operators' }, { text: 'Custom Helpers', link: '/07-custom' }, ] diff --git a/docs/09-stash.md b/docs/09-stash.md new file mode 100644 index 0000000..21e519c --- /dev/null +++ b/docs/09-stash.md @@ -0,0 +1,146 @@ +# The Stash + +Stash provides a simple and efficient way to store and retrieve data in memory over the course of a request. It's useful for storing data that needs to be accessed multiple times in a single request, without having to re-fetch it each time from the DB, or drill it down through props / params passed to includes or other component patterns. + +## Using the Stash + +The Stash is accessible either via the global `stash` object in Twig, or via the `craft.toolbelt.stash()` template variable. Both are references to the same service, and provide the same API. + +### Quick API reference + +```twig +{{ stash.set('key.optionaly.in.dot.notation', value) }} +{{ stash.get('key.optionaly.in.dot.notation', optionalDefaultValue) }} +{{ stash.push('key.optionaly.in.dot.notation', value2) }} +{{ stash.pop('key.optionaly.in.dot.notation') }} +{{ stash.has('key.optionaly.in.dot.notation') }} +{{ stash.drop('key.optionaly.in.dot.notation') }} +{{ stash.clear() }} +{{ stash.getAll() }} +{{ stash.getKeys() }} +{{ stash.getValues() }} +{{ stash.getCount() }} +``` + +See below for more detailed information on each method. + + +## Stashing and retrieving values + +Values can be added to the stash using the `set()` method and retrieved using `get()`. The value can be any valid PHP value, though it's safest to stick to primitive values and instances / collections of models. Stashing something like a singleton service instance is probably a bad idea and may lead to unexpected effects. It also really shouldn't ever be necessary. + +```twig +{{ stash.set('areApplesNice', true) }} + +{# then later, in some other template... #} + +{{ dump(stash.get('areApplesNice')) }} +{# `true` #} +``` + +### Default values + +The `get()` method also accepts an optional second argument, which will be returned if the key does not exist in the stash. This can be useful for providing a default value for a key that may or may not be set. + +```twig +{{ dump(stash.get('keyThatDoesntExist', 'default value')) }} +{# `default value` #} +``` + + +## Pushing and popping values + +The stash also supports a stack-like interface, allowing you to `push()` and `pop()` values onto and off of a named stack. This can be useful for storing and retrieving values in collection that you will want to manipulate or iterate over later. + +### Push / pop example: + +```twig +{{ stash.push('myStack', 'first') }} +{{ stash.push('myStack', 'second') }} +{{ stash.push('myStack', 'third') }} + +{{ dump(stash.pop('myStack')) }} +{# `third` #} +{{ dump(stash.pop('myStack')) }} +{# `second` #} +{{ dump(stash.pop('myStack')) }} +{# `first` #} +``` +Stacks are created on demand, so you don't need to worry about creating them before you use them. If you `push()` to a stack that doesn't exist, it will be created for you using a Laravel Collection. If you initialise a stack with an empty array using `set()` first, that array will be used as the initial/return value. Attempting to `push()` or `pop()` to a previously initialized non-array or non-Collection value will throw an exception. + +### Push-then-iterate example + +```twig +{{ stash.push('myStack', { name: 'Phil', hasGoodHair: false }) }} +{{ stash.push('myStack', { name: 'Patrick', goodHair: true }) }} +{{ stash.push('myStack', { name: 'Tom', hasGoodHair: false }) }} + +{# stash.get('myStack') is automatically created as a collection, so we can use collection methods on it, as well as iterate over it #} +{% for person in stash.get('myStack').filter((person) => person.hasGoodHair == false) %} + {{ person.name }} +{% endfor %} +{# `Phil` `Tom` #} +``` + + + +## Deleting values and clearing the stash + +Values can be removed from the stash using the `drop()` method, which will remove the value at the specified key. The `clear()` method will remove all values from the stash. + +```twig +{{ stash.drop('key') }} +{{ stash.clear() }} +``` + + +## Checking for the existence of a value + +You can check if a value exists in the stash using the `has()` method. This will return `true` if the value exists, and `false` if it does not. + +```twig +{{ stash.has('key') }} +``` + +## Getting everything, all keys, values, and the count + +You can get a shallow array copy of the current stash state, of all keys, or of all values, or the count of values in the stash using the `getAll()`, `getKeys()`, `getValues()`, and `getCount()` methods respectively: + +```twig +{{ stash.getAll() }} / {{ stash.all }} - returns a shallow copy of the current stash state +{{ stash.getKeys() }} / {{ stash.keys }} - returns an array of all keys in the stash +{{ stash.getValues() }} / {{ stash.values }} - returns an array of all values in the stash +{{ stash.getCount() }} / {{ stash.count }} - returns the number of values in the stash +``` + + +## Dot notation + +The following methods all support dot notation for keys: + +- `set()` +- `get()` +- `push()` +- `drop()` +- `has()` + +This allows you to store and retrieve nested values in the stash. For example: + +```twig +{{ stash.set('key.optionaly.in.dot.notation', value) }} +{{ stash.get('key.optionaly.in.dot.notation') }} +``` + +Setting a deeply nested value will create the necessary intermediate arrays or collections as needed. Attempting to `get()` a deeply nested value that doesn't exist will return `null`. Setting a deeply nested value using an intermediate key that is not an array or collection will throw an exception. + +## Caveats, limitations and internals + +The stash is deliberately simple by design. It is in-memory only, so it's not suitable for storing data that needs to persist between requests. It's also not shared between requests, so you can't use it to store data that needs to be shared between different requests. + +The stash works because Craft/Yii modules and their component services are singletons, and thus are shared for a single request. It's not a replacement for proper query caching or other more robust caching strategies, but a compliment to them to be used sparingly. It may electrocute your dog if you try to use it for something it's not designed for. + +Internally the Stash uses Laravel Collections by default, along with Laravel's robust `Illuminate\Support\Arr` helper functions to support features like dot notation. + +## Tests + +Are written using [Pest](https://pestphp.com/) and are located in `tests/Unit/StashTest.php`. diff --git a/docs/index.md b/docs/index.md index b7b5d11..01e7237 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,8 @@ power and flexibility means it's very easy to end up with a lot of logic in your 6. [Debugging helpers](./05-debugging-helpers) 7. [Operators](./06-operators) 8. [Media helpers](./08-media-helpers) -9. [The ability to define custom/one-off helpers](./07-custom) +9. [The Stash](./09-stash) +10. [The ability to define custom/one-off helpers](./07-custom) ## How? diff --git a/src/Services/StashService.php b/src/Services/StashService.php new file mode 100644 index 0000000..5e0e142 --- /dev/null +++ b/src/Services/StashService.php @@ -0,0 +1,161 @@ + + */ + protected Collection $data; + + public function init(): void + { + $this->data = new Collection([]); + parent::init(); + } + public function set(string $key, mixed $value): void + { + // non dot notation + if (!self::isDotNotationKey($key)) { + $this->data->put($key, $value); + return; + } + // unset any existing value + if ($this->has($key)) { + $this->drop($key); + } + // set the new value + $update = collect([$key => $value])->undot(); + $this->data = $this->data->mergeRecursive($update); + } + + /** + * Supports dot notation + */ + public function get(string $key, mixed $default = null): mixed + { + return Arr::get($this->data, $key) ?? $default; + } + + public function push(string $key, mixed $value): void + { + $existing = $this->get($key) ?? collect(); + + if ($existing instanceof Collection) { + $existing->push($value); + } elseif (is_array($existing)) { + $existing[] = $value; + } else { + throw new TypeError("Cannot push value, as existing stash for $key is not a Collection or array"); + } + + $this->set($key, $existing); + } + public function pop(string $key): mixed + { + $existing = $this->get($key); + + if ($existing instanceof Collection) { + $value = $existing->pop(); + + $this->set($key, $existing); + + return $value; + } + + if (is_array($existing)) { + $value = array_pop($existing); + + $this->set($key, $existing); + + return $value; + } + + throw new TypeError("Cannot pop value, as existing stash for $key is not a Collection or array"); + } + + /** + * Supports dot notation + */ + public function has(string $key): bool + { + return Arr::has($this->data, $key); + } + /** + * Supports dot notation + */ + public function drop(string $key): void + { + if (!$this->has($key)) { + return; + } + // non dot notation + if (!self::isDotNotationKey($key)) { + $this->data->forget($key); + return; + } + // update the parent stash + $segments = collect(explode('.', $key)); + $dropKey = $segments->last(); + $parentPath = $segments->slice(0, -1)->implode('.'); + + $parent = $this->get($parentPath); + + if ($parent instanceof Collection) { + $parent->forget($dropKey); + } elseif (is_array($parent)) { + unset($parent[$dropKey]); + } else { + throw new TypeError("Cannot drop value, as parent stash for $key is not a Collection or array"); + } + $this->set($parentPath, $parent); + } + public function clear(): void + { + $this->data = collect(); + } + /** + * @return array + */ + public function getAll(): array + { + return $this->data->toArray(); + } + /** + * @return array + */ + public function getKeys(): array + { + return $this->data->keys()->toArray(); + } + /** + * @return array + */ + public function getValues(): array + { + return $this->data->values()->toArray(); + } + public function getCount(): int + { + return $this->data->count(); + } + + public static function isDotNotationKey(string $key): bool + { + return str_contains($key, '.'); + } +} diff --git a/src/Services/ToolbeltService.php b/src/Services/ToolbeltService.php deleted file mode 100644 index a058fc6..0000000 --- a/src/Services/ToolbeltService.php +++ /dev/null @@ -1,13 +0,0 @@ -setComponents([ - 'tools' => ToolbeltService::class, + 'stash' => StashService::class, ]); Craft::$app->view->registerTwigExtension(new ToolbeltTwigExtension()); diff --git a/src/Twig/Extensions/ToolbeltTwigExtension.php b/src/Twig/Extensions/ToolbeltTwigExtension.php index 07f62e5..f3d2e6c 100644 --- a/src/Twig/Extensions/ToolbeltTwigExtension.php +++ b/src/Twig/Extensions/ToolbeltTwigExtension.php @@ -16,6 +16,7 @@ use zaengle\Toolbelt\Helpers\SvgHelper; use zaengle\Toolbelt\Helpers\VideoHelper; use zaengle\Toolbelt\Node\Expression\EmptyCoalesceExpression; +use zaengle\Toolbelt\Toolbelt; class ToolbeltTwigExtension extends AbstractExtension { @@ -50,7 +51,7 @@ public function getFunctions(): array new TwigFunction('slugify', [CraftStringHelper::class, 'slugify']), new TwigFunction('basename', [CraftStringHelper::class, 'basename']), new TwigFunction('titleize', [CraftStringHelper::class, 'titleize']), - new TwigFunction('titleizeForHumans', fn (?string $str) => CraftStringHelper::titleizeForHumans($str)), + new TwigFunction('titleizeForHumans', fn(?string $str) => CraftStringHelper::titleizeForHumans($str)), // Extra String helpers new TwigFunction('stringify', [Stringy::class, 'create']), @@ -120,9 +121,12 @@ public function getFilters(): array // Attribution: this is robbed from https://github.com/nystudio107/craft-emptycoalesce // thanks Andrew W - /** @noinspection MissedFieldInspection */ + /** + * @noinspection MissedFieldInspection + */ public function getOperators(): array { + // @phpstan-ignore-next-line return [ // Unary operators [], @@ -137,6 +141,13 @@ public function getOperators(): array ]; } + public function getGlobals(): array + { + return [ + 'stash' => Toolbelt::getInstance()->stash, + ]; + } + /** * @param mixed ...$vars */ diff --git a/src/Twig/Parsers/ClosureExpressionParser.php b/src/Twig/Parsers/ClosureExpressionParser.php index e1b0f7d..b00ac96 100644 --- a/src/Twig/Parsers/ClosureExpressionParser.php +++ b/src/Twig/Parsers/ClosureExpressionParser.php @@ -22,6 +22,7 @@ class ClosureExpressionParser extends ExpressionParser { /** * @inerhitdoc + * @phpstan-ignore-next-line */ public function parseExpression($precedence = 0, $allowArrow = true) { @@ -29,7 +30,10 @@ public function parseExpression($precedence = 0, $allowArrow = true) } /** - * @inerhitdoc + * @param bool $namedArguments + * @param bool $definition + * @param bool $allowArrow + * @return \Twig\Node\Node */ public function parseArguments($namedArguments = false, $definition = false, $allowArrow = true) { diff --git a/tests/Unit/StashTest.php b/tests/Unit/StashTest.php new file mode 100644 index 0000000..3d9aac3 --- /dev/null +++ b/tests/Unit/StashTest.php @@ -0,0 +1,226 @@ +set('foo', 'bar'); + + expect($stash->get('foo'))->toBe('bar'); + }); + + it('supports dot notation', function() { + $stash = new StashService(); + $stash->set('foo.bar', 'baz'); + + expect($stash->get('foo.bar'))->toBe('baz'); + }); + + it('supports dot notation with arrays', function() { + $stash = new StashService(); + $stash->set('foo.bar', ['baz' => 'qux']); + + expect($stash->get('foo.bar.baz'))->toBe('qux'); + }); + + it('supports dot notation with arrays and overwrites', function() { + $stash = new StashService(); + $stash->set('foo.bar', ['baz' => 'qux']); + $stash->set('foo.bar', ['baz' => 'quux']); + + expect($stash->get('foo.bar.baz'))->toBe('quux'); + }); + }); + + describe('get()', function() { + it('returns a default value if key does not exist', function() { + $stash = new StashService(); + + expect($stash->get('foo', 'bar'))->toBe('bar'); + }); + + it('supports dot notation', function() { + $stash = new StashService(); + $stash->set('foo.bar', 'baz'); + + expect($stash->get('foo.bar'))->toBe('baz'); + }); + + it('returns null if key does not exist and no default value is provided', function() { + $stash = new StashService(); + + expect($stash->get('foo'))->toBeNull(); + }); + }); + + describe('push()', function() { + it('pushes a value onto an array', function() { + $stash = new StashService(); + $stash->set('foo', ['bar']); + + $stash->push('foo', 'baz'); + + expect($stash->get('foo'))->toBe(['bar', 'baz']); + }); + + it('uses a Collection if one does not exist', function() { + $stash = new StashService(); + $stash->push('foo', 'bar'); + + $stash->push('foo', 'baz'); + + expect($stash->get('foo'))->toBeInstanceOf(\Illuminate\Support\Collection::class) + ->and($stash->get('foo')->toArray())->toBe(['bar', 'baz']); + }); + + it('throws an error if existing stash is not a Collection or array', function() { + $stash = new StashService(); + $stash->set('foo', 'bar'); + + expect(function() use ($stash) { + $stash->push('foo', 'baz'); + })->toThrow(new TypeError('Cannot push value, as existing stash for foo is not a Collection or array')); + }); + }); + + describe('pop()', function() { + it('pops a value from an array', function() { + $stash = new StashService(); + $stash->set('foo', ['bar', 'baz', 'qux']); + + $popped = $stash->pop('foo'); + + expect($popped)->toBe('qux') + ->and($stash->get('foo')) + ->toHaveLength(2) + ->toMatchArray(['bar', 'baz']); + }); + + it('pops a value from a Collection', function() { + $stash = new StashService(); + $stash->set('foo', collect(['bar', 'baz'])); + + expect($stash->pop('foo')) + ->toBe('baz') + ->and($stash->get('foo')) + ->and($stash->get('foo')->toArray())->toBe(['bar']); + }); + + it('throws an error if existing stash is not a Collection or array', function() { + $stash = new StashService(); + $stash->set('foo', 'bar'); + + expect(function() use ($stash) { + $stash->pop('foo'); + })->toThrow(new TypeError('Cannot pop value, as existing stash for foo is not a Collection or array')); + }); + }); + + describe('has', function() { + it('returns true if key exists', function() { + $stash = new StashService(); + $stash->set('foo', 'bar'); + + expect($stash->has('foo'))->toBeTrue(); + }); + it('returns false if key does not exist', function() { + $stash = new StashService(); + + expect($stash->has('foo'))->toBeFalse(); + }); + it('supports dot notation', function(){ + $stash = new StashService(); + $stash->set('foo.bar', 'baz'); + + expect($stash->has('foo.bar'))->toBeTrue(); + }); + }); + + describe('drop()', function(){ + it('removes a key', function(){ + $stash = new StashService(); + $stash->set('foo', 'bar'); + $stash->drop('foo'); + + expect($stash->has('foo'))->toBeFalse(); + }); + + it('supports dot notation, leaving the parent tree intact', function(){ + $stash = new StashService(); + $stash->set('foo.bar.baz', 'qux'); + $stash->drop('foo.bar.baz'); + + expect($stash->has('foo.bar.baz')) + ->toBeFalse() + ->and($stash->has('foo.bar'))->toBeTrue(); + }); + }); + + describe('clear', function() { + it('clears all data', function() { + $stash = new StashService(); + $stash->set('foo', 'bar'); + $stash->set('baz', 'qux'); + + $stash->clear(); + + expect($stash->get('foo'))->toBeNull() + ->and($stash->get('baz'))->toBeNull() + ->and($stash->all)->toBe([]); + }); + }); + + describe('all', function() { + it('returns all data', function() { + $stash = new StashService(); + $stash->set('foo', 'bar'); + $stash->set('baz', 'qux'); + + expect($stash->all)->toBe(['foo' => 'bar', 'baz' => 'qux']); + }); + }); + + describe('values', function() { + it('returns all values', function() { + $stash = new StashService(); + $stash->set('foo.foo2', 'bar'); + $stash->set('baz', 'qux'); + + expect($stash->values)->toBe([ + ['foo2' => 'bar'], + 'qux'] + ); + }); + }); + + describe('keys', function() { + it('returns all keys', function() { + $stash = new StashService(); + $stash->set('foo.foo2', 'bar'); + $stash->set('baz', 'qux'); + + expect($stash->keys)->toBe(['foo', 'baz']); + }); + }); + + describe('count', function() { + it('returns the number of items in the stash', function() { + $stash = new StashService(); + $stash->set('foo', 'bar'); + $stash->set('baz', 'qux'); + + expect($stash->count)->toBe(2); + }); + }); + + describe('isDotNotationKey()', function() { + it('returns true for dot notation keys', function() { + expect(StashService::isDotNotationKey('foo.bar'))->toBeTrue(); + }); + it('returns false for non-dot notation keys', function() { + expect(StashService::isDotNotationKey('foo'))->toBeFalse(); + }); + }); +});