Skip to content

Commit

Permalink
added: stash functionality [#23] (#24)
Browse files Browse the repository at this point in the history
* added: stash functionality [#23]

* fixed: docs typo
  • Loading branch information
tomdavies authored May 9, 2024
1 parent 478ade2 commit f35f16b
Show file tree
Hide file tree
Showing 11 changed files with 561 additions and 22 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ node_modules

docs/.vitepress/dist/
docs/.vitepress/cache/

tests/storage/logs/
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]
Expand Down
146 changes: 146 additions & 0 deletions docs/09-stash.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 2 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
161 changes: 161 additions & 0 deletions src/Services/StashService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

namespace zaengle\Toolbelt\Services;

use craft\base\Component;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use TypeError;

/**
* Stash service
*
* @property-read int $count
* @property-read array $keys
* @property-read array $values
* @property-read array $all
*/
class StashService extends Component
{
/**
* @var Collection<string, mixed>
*/
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<string, mixed>
*/
public function getAll(): array
{
return $this->data->toArray();
}
/**
* @return array<string>
*/
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, '.');
}
}
13 changes: 0 additions & 13 deletions src/Services/ToolbeltService.php

This file was deleted.

6 changes: 3 additions & 3 deletions src/Toolbelt.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
use yii\base\Event;
use zaengle\Toolbelt\Helpers\ReflectionHelper;
use zaengle\Toolbelt\Models\Settings;
use zaengle\Toolbelt\Services\ToolbeltService;
use zaengle\Toolbelt\Services\StashService;
use zaengle\Toolbelt\Twig\Extensions\CustomTwigExtension;
use zaengle\Toolbelt\Twig\Extensions\ToolbeltTwigExtension;
use zaengle\Toolbelt\Twig\Parsers\ClosureExpressionParser;
Expand All @@ -23,7 +23,7 @@
* @package Toolbelt
* @since 1.0.0
*
* @property ToolbeltService $tools
* @property StashService $stash
*/
class Toolbelt extends Plugin
{
Expand All @@ -44,7 +44,7 @@ public function init(): void
self::$plugin = $this;

$this->setComponents([
'tools' => ToolbeltService::class,
'stash' => StashService::class,
]);

Craft::$app->view->registerTwigExtension(new ToolbeltTwigExtension());
Expand Down
Loading

0 comments on commit f35f16b

Please sign in to comment.