Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
dd9d986
feat(cache): add NullSentinel primitive for nullable cache methods
binaryfire Apr 21, 2026
0580499
feat(cache): add RawReadable capability interface for sentinel-preser…
binaryfire Apr 21, 2026
1c90901
feat(cache): add nullable method declarations to Repository contract
binaryfire Apr 21, 2026
586ee95
feat(cache): implement nullable cache methods and sentinel-aware read…
binaryfire Apr 21, 2026
d95f116
feat(cache): unwrap NullSentinel in AllTaggedCache remember/rememberF…
binaryfire Apr 21, 2026
20851e8
feat(cache): unwrap NullSentinel and reject getRaw/manyRaw in AnyTagg…
binaryfire Apr 21, 2026
92cb3b3
feat(cache): implement RawReadable in MemoizedStore to preserve senti…
binaryfire Apr 21, 2026
7469ad7
feat(cache): implement RawReadable in FailoverStore to preserve senti…
binaryfire Apr 21, 2026
d92e72f
docs(support): advertise nullable cache methods and getTagMode on Cac…
binaryfire Apr 21, 2026
6be7a3b
fix(collections): bind generic type on IteratorIterator in makeIterat…
binaryfire Apr 21, 2026
df0018a
chore(phpstan): flip treatPhpDocTypesAsCertain to false
binaryfire Apr 21, 2026
46e0cea
test(cache): add Repository nullable method + sentinel coverage
binaryfire Apr 21, 2026
3a4bb1e
test(cache): add AllTaggedCache nullable coverage
binaryfire Apr 21, 2026
73b1123
test(cache): add AnyTaggedCache nullable coverage and flexibleNullabl…
binaryfire Apr 21, 2026
50758dd
test(cache): assert tag flush invalidates sentinel and re-runs callback
binaryfire Apr 21, 2026
9d734a0
test(cache): RawReadable regression coverage through the memo layer
binaryfire Apr 21, 2026
9a81baa
test(cache): rememberNullable always re-runs the callback on NullStore
binaryfire Apr 21, 2026
a194a8d
test(cache): sentinel round-trips through SwooleStore
binaryfire Apr 21, 2026
97db659
test(cache): sentinel propagates through stacked stores
binaryfire Apr 21, 2026
3ec0108
test(cache): Redis integration coverage for nullable cache methods
binaryfire Apr 21, 2026
dfdfde3
test(cache): Repository-level integration coverage for nullable methods
binaryfire Apr 21, 2026
40e7108
test(cache): failover-stack nullable coverage and getRaw mock update
binaryfire Apr 21, 2026
f336f5a
test(cache): memoized-stack nullable coverage
binaryfire Apr 21, 2026
8205fe9
docs(cache): drop stale EloquentUserProvider reference from NullSenti…
binaryfire Apr 21, 2026
64ff688
docs(cache): lowercase 'array' in NullSentinel docblock
binaryfire Apr 21, 2026
48eed94
Merge branch '0.4' into feat/cache-null-sentinel
binaryfire Apr 28, 2026
5c009b7
fix(cache): unwrap NullSentinel in Repository event payloads
binaryfire Apr 30, 2026
5108a75
fix(cache): unwrap NullSentinel in Redis tagged-cache event payloads
binaryfire Apr 30, 2026
35a976d
test(cache): event payloads carry null, not the sentinel
binaryfire Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/cache/src/FailoverStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Cache\Lock as LockContract;
use Hypervel\Contracts\Cache\LockProvider;
use Hypervel\Contracts\Cache\RawReadable;
use Hypervel\Contracts\Cache\Repository as RepositoryContract;
use Hypervel\Contracts\Events\Dispatcher;
use RuntimeException;
use Throwable;
use UnitEnum;

class FailoverStore extends TaggableStore implements LockProvider
class FailoverStore extends TaggableStore implements LockProvider, RawReadable
{
/**
* Context key prefix for the caches which failed on the last action.
Expand Down Expand Up @@ -41,10 +43,18 @@ public function __construct(

/**
* Retrieve an item from the cache by key.
*
* Store contract method — unwraps sentinels to null, matching the
* pre-refactor behavior (inner Repository's get() also unwrapped).
*/
public function get(string $key): mixed
{
return $this->attemptOnAllStores(__FUNCTION__, func_get_args());
return NullSentinel::unwrap($this->getRaw($key));
}

public function getRaw(UnitEnum|string $key): mixed
{
return $this->attemptOnAllStores('getRaw', [$key]);
}

/**
Expand All @@ -54,7 +64,15 @@ public function get(string $key): mixed
*/
public function many(array $keys): array
{
return $this->attemptOnAllStores(__FUNCTION__, func_get_args());
return array_map(
NullSentinel::unwrap(...),
$this->manyRaw(array_map(fn ($k) => (string) $k, $keys))
);
}

public function manyRaw(array $keys): array
{
return $this->attemptOnAllStores('manyRaw', [$keys]);
}

/**
Expand Down
51 changes: 35 additions & 16 deletions src/cache/src/MemoizedStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
use BadMethodCallException;
use Hypervel\Contracts\Cache\Lock as LockContract;
use Hypervel\Contracts\Cache\LockProvider;
use Hypervel\Contracts\Cache\RawReadable;
use Hypervel\Contracts\Cache\Store;
use UnitEnum;

class MemoizedStore implements LockProvider, Store
use function Hypervel\Support\enum_value;

class MemoizedStore implements LockProvider, RawReadable, Store
{
/**
* The memoized cache values.
Expand All @@ -29,16 +33,27 @@ public function __construct(

/**
* Retrieve an item from the cache by key.
*
* Store contract method — returns the value with sentinels unwrapped to null,
* matching the pre-refactor behavior (which returned whatever the inner
* Repository's get() returned, i.e., unwrapped). Memoizes the raw value so
* subsequent getRaw() calls see the sentinel.
*/
public function get(string $key): mixed
{
$prefixedKey = $this->prefix($key);
return NullSentinel::unwrap($this->getRaw($key));
}

public function getRaw(UnitEnum|string $key): mixed
{
$stringKey = (string) (is_object($key) ? enum_value($key) : $key);
$prefixedKey = $this->prefix($stringKey);

if (array_key_exists($prefixedKey, $this->cache)) {
return $this->cache[$prefixedKey];
}

return $this->cache[$prefixedKey] = $this->repository->get($key);
return $this->cache[$prefixedKey] = $this->repository->getRaw($stringKey);
}

/**
Expand All @@ -48,35 +63,39 @@ public function get(string $key): mixed
*/
public function many(array $keys): array
{
[$memoized, $retrieved, $missing] = [[], [], []];
return array_map(
NullSentinel::unwrap(...),
$this->manyRaw(array_map(fn ($k) => (string) $k, $keys))
);
}

public function manyRaw(array $keys): array
{
[$memoized, $missing] = [[], []];

foreach ($keys as $key) {
$stringKey = (string) $key;
$prefixedKey = $this->prefix($stringKey);

if (array_key_exists($prefixedKey, $this->cache)) {
$memoized[$key] = $this->cache[$prefixedKey];
$memoized[$stringKey] = $this->cache[$prefixedKey];
} else {
$missing[] = $stringKey;
}
}

$retrieved = [];
if (count($missing) > 0) {
$retrieved = tap($this->repository->many($missing), function ($values) {
foreach ($values as $key => $value) {
$this->cache[$this->prefix((string) $key)] = $value;
}
});
$retrieved = $this->repository->manyRaw($missing);
foreach ($retrieved as $key => $value) {
$this->cache[$this->prefix((string) $key)] = $value;
}
}

$result = [];

foreach ($keys as $key) {
if (array_key_exists($key, $memoized)) {
$result[$key] = $memoized[$key];
} else {
$result[$key] = $retrieved[$key];
}
$stringKey = (string) $key;
$result[$stringKey] = $memoized[$stringKey] ?? $retrieved[$stringKey] ?? null;
}

return $result;
Expand Down
47 changes: 47 additions & 0 deletions src/cache/src/NullSentinel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Hypervel\Cache;

/**
* Namespace for the sentinel array value stored in place of null by the
* nullable cache wrapper methods.
*
* Used by Repository::rememberNullable(), rememberForeverNullable(),
* searNullable(), and flexibleNullable() to distinguish "key absent" from
* "key present with null value" — the cache contract itself can't express
* that distinction via get() alone.
*
* The sentinel is an array constant, not an object. This is deliberate:
* PHP's unserialize() allowed_classes option (used by stores when the
* cache.serializable_classes config is set) only restricts object
* deserialization. Scalars and arrays round-trip unchanged regardless of
* the allowed_classes value. An object sentinel would silently become
* __PHP_Incomplete_Class on any cache configured with a restrictive
* serializable_classes list that didn't include the sentinel class.
*
* Identity is checked with strict === against NullSentinel::VALUE, which
* is safe because PHP's array equality compares keys and values recursively.
* Collision risk is effectively zero: a caller would have to independently
* cache a value whose structure is exactly
* ['__hypervel_cache_null_sentinel' => true].
*/
final class NullSentinel
{
/**
* Sentinel value stored in place of null by the nullable cache methods.
*/
public const VALUE = ['__hypervel_cache_null_sentinel' => true];

/**
* Unwrap a value read from the cache — sentinel becomes null; anything else passes through.
*
* Used at the boundaries where the cache layer returns to public API callers,
* so the sentinel never leaks through get/many/remember/flexible/etc.
*/
public static function unwrap(mixed $value): mixed
{
return $value === self::VALUE ? null : $value;
}
}
21 changes: 11 additions & 10 deletions src/cache/src/Redis/AllTaggedCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Hypervel\Cache\Events\CacheHit;
use Hypervel\Cache\Events\CacheMissed;
use Hypervel\Cache\Events\KeyWritten;
use Hypervel\Cache\NullSentinel;
use Hypervel\Cache\RedisStore;
use Hypervel\Cache\TaggedCache;
use Hypervel\Contracts\Cache\Store;
Expand Down Expand Up @@ -66,7 +67,7 @@ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt
);

if ($result) {
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
}

return $result;
Expand Down Expand Up @@ -104,7 +105,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT
);

if ($result) {
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
}

return $result;
Expand Down Expand Up @@ -134,7 +135,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $

if ($result) {
foreach ($values as $key => $value) {
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, $value, $seconds));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, NullSentinel::unwrap($value), $seconds));
}
}

Expand Down Expand Up @@ -179,7 +180,7 @@ public function forever(UnitEnum|string $key, mixed $value): bool
);

if ($result) {
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
}

return $result;
Expand Down Expand Up @@ -242,13 +243,13 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in
);

if ($wasHit) {
$this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, $value));
$this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
} else {
$this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
}

return $value;
return NullSentinel::unwrap($value);
}

/**
Expand All @@ -271,13 +272,13 @@ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed
);

if ($wasHit) {
$this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, $value));
$this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
} else {
$this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
}

return $value;
return NullSentinel::unwrap($value);
}

/**
Expand Down
41 changes: 32 additions & 9 deletions src/cache/src/Redis/AnyTaggedCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Hypervel\Cache\Events\CacheHit;
use Hypervel\Cache\Events\CacheMissed;
use Hypervel\Cache\Events\KeyWritten;
use Hypervel\Cache\NullSentinel;
use Hypervel\Cache\RedisStore;
use Hypervel\Cache\TaggedCache;
use Hypervel\Contracts\Cache\Store;
Expand Down Expand Up @@ -69,6 +70,17 @@ public function get(array|UnitEnum|string $key, mixed $default = null): mixed
);
}

/**
* @throws BadMethodCallException Always - tags are for writing/flushing only
*/
public function getRaw(UnitEnum|string $key): mixed
{
throw new BadMethodCallException(
'Cannot get items via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::get() directly with the full key.'
);
}

/**
* Retrieve multiple items from the cache by key.
*
Expand All @@ -82,6 +94,17 @@ public function many(array $keys): array
);
}

/**
* @throws BadMethodCallException Always - tags are for writing/flushing only
*/
public function manyRaw(array $keys): array
{
throw new BadMethodCallException(
'Cannot get items via tags in any mode. Tags are for writing and flushing only. '
. 'Use Cache::get() directly.'
);
}

/**
* Determine if an item exists in the cache.
*
Expand Down Expand Up @@ -146,7 +169,7 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT
$result = $this->store->anyTagOps()->put()->execute($key, $value, $seconds, $this->tags->getNames());

if ($result) {
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
}

return $result;
Expand All @@ -171,7 +194,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $

if ($result) {
foreach ($values as $key => $value) {
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, $value, $seconds));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, NullSentinel::unwrap($value), $seconds));
}
}

Expand Down Expand Up @@ -209,7 +232,7 @@ public function forever(UnitEnum|string $key, mixed $value): bool
$result = $this->store->anyTagOps()->forever()->execute($key, $value, $this->tags->getNames());

if ($result) {
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
}

return $result;
Expand Down Expand Up @@ -289,13 +312,13 @@ public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|in
);

if ($wasHit) {
$this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, $value));
$this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
} else {
$this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value, $seconds));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
}

return $value;
return NullSentinel::unwrap($value);
}

/**
Expand All @@ -318,13 +341,13 @@ public function rememberForever(UnitEnum|string $key, Closure $callback): mixed
);

if ($wasHit) {
$this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, $value));
$this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
} else {
$this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, $value));
$this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
}

return $value;
return NullSentinel::unwrap($value);
}

/**
Expand Down
Loading