Skip to content

Commit 0e52d95

Browse files
committed
feat: Add the ability to memoize bag instantiation
1 parent f0c0a8b commit 0e52d95

File tree

7 files changed

+292
-0
lines changed

7 files changed

+292
-0
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bag\Attributes;
6+
7+
use Attribute;
8+
use Bag\Attributes\Attribute as AttributeInterface;
9+
use Illuminate\Support\Collection;
10+
11+
#[Attribute(Attribute::TARGET_CLASS)]
12+
class MemoizeUsing implements AttributeInterface
13+
{
14+
/**
15+
* @param string|array<string>|Collection<array-key, string> $cacheKeyAttributeNames
16+
*/
17+
public function __construct(public string|array|Collection $cacheKeyAttributeNames)
18+
{
19+
}
20+
}

src/Bag/Bag.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Bag\Concerns\WithCollections;
99
use Bag\Concerns\WithEloquentCasting;
1010
use Bag\Concerns\WithJson;
11+
use Bag\Concerns\WithMemoization;
1112
use Bag\Concerns\WithOptionals;
1213
use Bag\Concerns\WithOutput;
1314
use Bag\Concerns\WithValidation;
@@ -34,6 +35,7 @@
3435
use WithOptionals;
3536
use WithOutput;
3637
use WithValidation;
38+
use WithMemoization;
3739

3840
public const FROM_JSON = 'json';
3941

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bag\Concerns;
6+
7+
use Bag\Pipelines\MemoizePipeline;
8+
use Bag\Pipelines\Values\BagInput;
9+
use Illuminate\Support\Collection;
10+
11+
trait WithMemoization
12+
{
13+
public static function memoize(mixed ...$values): static
14+
{
15+
$input = new BagInput(static::class, collect($values));
16+
17+
return MemoizePipeline::process($input);
18+
}
19+
20+
/**
21+
* @param string|array<string>|Collection<array-key, string> $cacheKeyAttributeNames
22+
*/
23+
public static function memoizeUsing(string|array|Collection $cacheKeyAttributeNames, mixed ...$values): static
24+
{
25+
$input = new BagInput(static::class, collect($values));
26+
27+
return MemoizePipeline::process($input, $cacheKeyAttributeNames);
28+
}
29+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bag\Pipelines;
6+
7+
use Bag\Attributes\MemoizeUsing;
8+
use Bag\Bag;
9+
use Bag\Internal\Reflection;
10+
use Bag\Pipelines\Pipes\CastInputValues;
11+
use Bag\Pipelines\Pipes\ComputedValues;
12+
use Bag\Pipelines\Pipes\DebugCollection;
13+
use Bag\Pipelines\Pipes\ExtraParameters;
14+
use Bag\Pipelines\Pipes\FillBag;
15+
use Bag\Pipelines\Pipes\FillNulls;
16+
use Bag\Pipelines\Pipes\FillOptionals;
17+
use Bag\Pipelines\Pipes\IsVariadic;
18+
use Bag\Pipelines\Pipes\LaravelRouteParameters;
19+
use Bag\Pipelines\Pipes\MapInput;
20+
use Bag\Pipelines\Pipes\MissingProperties;
21+
use Bag\Pipelines\Pipes\ProcessArguments;
22+
use Bag\Pipelines\Pipes\ProcessParameters;
23+
use Bag\Pipelines\Pipes\StripExtraParameters;
24+
use Bag\Pipelines\Pipes\Transform;
25+
use Bag\Pipelines\Pipes\Validate;
26+
use Bag\Pipelines\Values\BagInput;
27+
use Illuminate\Support\Collection;
28+
use Illuminate\Support\Facades\Cache;
29+
use League\Pipeline\Pipeline;
30+
31+
class MemoizePipeline
32+
{
33+
/**
34+
* @template T of Bag
35+
* @param BagInput<T> $input
36+
* @param string|array<string>|Collection<array-key, string>|null $cacheKeyAttributeNames
37+
* @return T
38+
*/
39+
public static function process(BagInput $input, string|array|Collection|null $cacheKeyAttributeNames = null): Bag
40+
{
41+
if ($cacheKeyAttributeNames === null) {
42+
$attribute = Reflection::getAttribute(Reflection::getClass($input->bagClassname), MemoizeUsing::class);
43+
if ($attribute !== null) {
44+
$cacheKeyAttributeNames = Reflection::getAttributeInstance($attribute)?->cacheKeyAttributeNames;
45+
if ($cacheKeyAttributeNames !== null) {
46+
$cacheKeyAttributeNames = Collection::wrap($cacheKeyAttributeNames);
47+
}
48+
}
49+
}
50+
51+
if ($cacheKeyAttributeNames === null) {
52+
return static::runPipeline($input);
53+
}
54+
55+
$cacheKeyAttributeNames = Collection::wrap($cacheKeyAttributeNames);
56+
if ($input->input->count() === 1 && isset($input->input[0]) && is_array($input->input[0])) {
57+
$cacheKey = Collection::wrap($input->input[0])->filter(fn ($value, $key) => $cacheKeyAttributeNames->contains($key))->values()->implode(':');
58+
} else {
59+
$cacheKey = $input->input->filter(fn ($value, $key) => $cacheKeyAttributeNames->contains($key))->values()->implode(':');
60+
}
61+
62+
return Cache::driver('array')->rememberForever($input->bagClassname . ':' . $cacheKey, function () use ($input) {
63+
return static::runPipeline($input);
64+
});
65+
}
66+
67+
/**
68+
* @template T of Bag
69+
* @param BagInput<T> $input
70+
* @return T
71+
*/
72+
protected static function runPipeline(BagInput $input): Bag
73+
{
74+
$pipeline = new Pipeline(
75+
null,
76+
new Transform(),
77+
new ProcessParameters(),
78+
new ProcessArguments(),
79+
new IsVariadic(),
80+
new MapInput(),
81+
new LaravelRouteParameters(),
82+
new FillOptionals(),
83+
new FillNulls(),
84+
new MissingProperties(),
85+
new ExtraParameters(),
86+
new StripExtraParameters(),
87+
new Validate(),
88+
new CastInputValues(),
89+
new FillBag(),
90+
new ComputedValues(),
91+
new DebugCollection(),
92+
);
93+
94+
// @phpstan-ignore-next-line property.nonObject
95+
return $pipeline->process($input)->bag;
96+
}
97+
}

tests/Feature/BagTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Tests\Fixtures\Values\BagWithOptionals;
1212
use Tests\Fixtures\Values\BagWithSingleArrayParameter;
1313
use Tests\Fixtures\Values\BagWithUnionTypes;
14+
use Tests\Fixtures\Values\MemoizedBag;
1415
use Tests\Fixtures\Values\NullablePropertiesBag;
1516
use Tests\Fixtures\Values\NullableWithDefaultValueBag;
1617
use Tests\Fixtures\Values\OptionalPropertiesWithDefaultsBag;
@@ -385,3 +386,50 @@
385386
'name' => 'Davey Shafik',
386387
]);
387388
});
389+
390+
it('memoizes using key name', function () {
391+
$bag = TestBag::memoizeUsing('email', ['name' => 'Davey Shafik', 'age' => 40, 'email' => 'davey@php.net']);
392+
$bag2 = TestBag::memoizeUsing('email', ['name' => 'Not Davey', 'age' => 40, 'email' => 'davey@php.net']);
393+
$bag3 = TestBag::memoizeUsing('email', ['name' => 'Not Davey', 'age' => 40, 'email' => 'not-davey@php.net']);
394+
395+
expect($bag)
396+
->toBe($bag2)
397+
->not->toBe($bag3);
398+
});
399+
400+
it('memoizes using array of keys', function () {
401+
$bag = TestBag::memoizeUsing(['email', 'age'], ['name' => 'Davey Shafik', 'age' => 40, 'email' => 'davey@php.net']);
402+
$bag2 = TestBag::memoizeUsing(['email', 'age'], ['name' => 'Not Davey', 'age' => 40, 'email' => 'davey@php.net']);
403+
$bag3 = TestBag::memoizeUsing(['email', 'age'], ['name' => 'Not Davey', 'age' => 41, 'email' => 'davey@php.net']);
404+
405+
expect($bag)
406+
->toBe($bag2)
407+
->not->toBe($bag3);
408+
});
409+
410+
it('does not memoize without key', function () {
411+
$bag = TestBag::memoize(['name' => 'Davey Shafik', 'age' => 40, 'email' => 'davey@php.net']);
412+
$bag2 = TestBag::memoize(['name' => 'Davey Shafik', 'age' => 40, 'email' => 'davey@php.net']);
413+
414+
expect($bag)->not->toBe($bag2);
415+
});
416+
417+
it('memoizes using attribute', function () {
418+
$bag = MemoizedBag::memoize(['name' => 'Davey Shafik', 'age' => 40, 'email' => 'davey@php.net']);
419+
$bag2 = MemoizedBag::memoize(['name' => 'Not Davey', 'age' => 40, 'email' => 'davey@php.net']);
420+
$bag3 = MemoizedBag::memoize(['name' => 'Not Davey', 'age' => 40, 'email' => 'not-davey@php.net']);
421+
422+
expect($bag)
423+
->toBe($bag2)
424+
->not->toBe($bag3);
425+
});
426+
427+
it('memoizes overriding attribute', function () {
428+
$bag = MemoizedBag::memoizeUsing('name', ['name' => 'Davey Shafik', 'age' => 40, 'email' => 'davey@php.net']);
429+
$bag2 = MemoizedBag::memoizeUsing('name', ['name' => 'Davey Shafik', 'age' => 40, 'email' => 'not-davey@php.net']);
430+
$bag3 = MemoizedBag::memoizeUsing('name', ['name' => 'Not Davey Shafik', 'age' => 40, 'email' => 'davey@php.net']);
431+
432+
expect($bag)
433+
->toBe($bag2)
434+
->not->toBe($bag3);
435+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Fixtures\Values;
6+
7+
use Bag\Attributes\MemoizeUsing;
8+
use Bag\Bag;
9+
10+
#[MemoizeUsing('email')]
11+
readonly class MemoizedBag extends Bag
12+
{
13+
public function __construct(
14+
public string $name,
15+
public int $age,
16+
public string $email
17+
) {
18+
}
19+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Bag\Attributes\MemoizeUsing;
6+
use Bag\Pipelines\MemoizePipeline;
7+
use Bag\Pipelines\Values\BagInput;
8+
use Tests\Fixtures\Values\MemoizedBag;
9+
use Tests\Fixtures\Values\TestBag;
10+
11+
covers(BagInput::class, MemoizePipeline::class, MemoizeUsing::class);
12+
13+
test('it memoizes bags using cache key', function () {
14+
$input = new BagInput(TestBag::class, collect([
15+
'name' => 'Davey Shafik',
16+
'age' => 40,
17+
'email' => 'davey@php.net'
18+
]));
19+
20+
$bag = MemoizePipeline::process($input, 'email');
21+
22+
$input = new BagInput(TestBag::class, collect([
23+
'name' => 'Not Davey Shafik',
24+
'age' => 40,
25+
'email' => 'davey@php.net'
26+
]));
27+
28+
$bag2 = MemoizePipeline::process($input, 'email');
29+
30+
expect($bag)
31+
->toBeInstanceOf(TestBag::class)
32+
->toBe($bag2);
33+
});
34+
35+
test('it memoizes bags using attribute', function () {
36+
$input = new BagInput(MemoizedBag::class, collect([
37+
'name' => 'Davey Shafik',
38+
'age' => 40,
39+
'email' => 'davey@php.net'
40+
]));
41+
42+
$bag = MemoizePipeline::process($input);
43+
44+
$input = new BagInput(MemoizedBag::class, collect([
45+
'name' => 'Not Davey Shafik',
46+
'age' => 40,
47+
'email' => 'davey@php.net'
48+
]));
49+
50+
$bag2 = MemoizePipeline::process($input);
51+
52+
expect($bag)
53+
->toBeInstanceOf(MemoizedBag::class)
54+
->toBe($bag2);
55+
});
56+
57+
test('it does not memoize bags without attribute', function () {
58+
$input = new BagInput(TestBag::class, collect([
59+
'name' => 'Davey Shafik',
60+
'age' => 40,
61+
'email' => 'davey@php.net'
62+
]));
63+
64+
$bag = MemoizePipeline::process($input);
65+
66+
$input = new BagInput(TestBag::class, collect([
67+
'name' => 'Not Davey Shafik',
68+
'age' => 40,
69+
'email' => 'davey@php.net'
70+
]));
71+
72+
$bag2 = MemoizePipeline::process($input);
73+
74+
expect($bag)
75+
->toBeInstanceOf(TestBag::class)
76+
->not->toBe($bag2);
77+
});

0 commit comments

Comments
 (0)