From 801253f3bc3150dbb152510aec6109e884798369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20M=C3=A4ki?= Date: Fri, 12 Jun 2026 11:22:49 +0200 Subject: [PATCH] Add fluent config API and object-argument cache keys Replace the void setTtl()/setEnabled() setters with a chainable, Laravel-style grammar (ttl/enable/disable/prefix/withTags/tagCleaners/ exclude, all returning static) and support objects as method arguments via a new overridable normalizeArgument() seam. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 +- README.md | 54 ++++++++++- src/CacheDecorator.php | 122 ++++++++++++++++++++++--- src/tests/CachedStubRepositoryTest.php | 8 +- src/tests/CachedStubServiceTest.php | 116 +++++++++++++++++++++-- src/tests/Stubs/StubModel.php | 34 +++++++ src/tests/Stubs/StubService.php | 7 ++ 7 files changed, 317 insertions(+), 31 deletions(-) create mode 100644 src/tests/Stubs/StubModel.php diff --git a/CLAUDE.md b/CLAUDE.md index c3d9f19..063788c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,9 +23,10 @@ The package is intentionally small — four production files plus tests. - **`src/RepositoryCacheDecorator.php`** — repository-flavored subclass. Identical to `CacheDecorator` except `$config_key` is overridden to `'repository_cache'`, so it reads its config from a separate namespace. - **`initExcludes()`** auto-adds the decorator's own protected/public method names to `$excludes` so they aren't intercepted by `__call`. If you add new helper methods on `CacheDecorator`, add them to the `$defaults` list in `initExcludes()` or `__call` will try to forward them to the decorated object. - **`cacheMiss()` sentinel**: a shared `stdClass` instance (stored on `private static ?object $missMarker` and lazily initialised) returned by `getCache()` whenever the cache entry is absent or reads are bypassed (`ttl === null`). `__call()` compares the return of `getCache()` with `=== $this->cacheMiss()` to detect a true miss. This lets the decorator round-trip falsy cached values (`0`, `''`, `[]`, `false`, `null`) without refetching on every call. Custom per-method overrides in subclasses should follow the same pattern (`if ($res === $this->cacheMiss())`), not `if (!$res)`. -- **Cache key format** is `{prefix_key}.{method}.{argKey}={argVal}...` built by `generateCacheKey()` using `Illuminate\Support\Arr::dot($arguments)`. Note: arguments that are objects are not supported (a known TODO in the class header). +- **Cache key format** is `{prefix_key}.{method}.{argKey}={argVal}...` built by `generateCacheKey()` using `Illuminate\Support\Arr::dot($arguments)`. Each dotted leaf value is folded into the key through the protected, overridable `normalizeArgument(mixed): string` seam: scalars/null/bool cast as-is (so existing keys stay byte-identical), `UrlRoutable` uses `getRouteKey()`, `BackedEnum` uses `->value`, `Stringable`/`__toString` string-cast, and anything else falls back to `json_encode` (or `md5(serialize())`). Object arguments are therefore supported; subclasses can override `normalizeArgument()` to customize identity. - **TTL semantic**: `$ttl` is in **seconds** (Laravel 5.8+ `Cache::put` API) and may also be `DateInterval` / `DateTimeInterface`. `$ttl === null` is a sentinel that bypasses both reads and writes to the cache — `getCache()` short-circuits to the `cacheMiss()` sentinel without touching the `Cache` facade so `__call()` proceeds to invoke the decorated method, and `putCache()` returns `false` without storing anything. -- **`enabled` flag**: when `false` (set via the property, `setEnabled(false)`, or `{$config_key}.enabled = false`), `__call()` returns `callMethod()` directly at the top, skipping `isMethodCacheable`, cache get/put, and tag flushing. This is the on/off switch for caching. +- **`enabled` flag**: when `false` (set via the property, `disable()`, or `{$config_key}.enabled = false`), `__call()` returns `callMethod()` directly at the top, skipping `isMethodCacheable`, cache get/put, and tag flushing. This is the on/off switch for caching. +- **Fluent configuration**: the chainable setters `ttl()`, `enable()`, `disable()`, `prefix()`, `withTags()`, `tagCleaners()`, and `exclude(...)` all return `static`. There are no `setTtl()`/`setEnabled()` setters. Every fluent method name must be listed in the `initExcludes()` defaults or `__call()` would forward it to the decorated object instead of running on the decorator. - **Config** is read in `getConfig()` from `{$this->config_key}.*` (`ttl`, `enabled`, `use_tags`) plus `app.debug` for the `$debug` flag controlling `Log::debug` output. The base default is `'cache_decorator'`; `RepositoryCacheDecorator` overrides it to `'repository_cache'`. - **`src/ServiceProvider.php`** — publishes both config files: `config/cache_decorator.php` under the `cache-decorator-config` tag and `config/repository_cache.php` under the `repository-cache-config` tag; `register()` is empty. The provider is auto-discovered via `extra.laravel.providers` in `composer.json`. @@ -36,5 +37,5 @@ The classes use Laravel facades (`Cache`, `Config`, `Log` from `Illuminate\Suppo - Generic subclasses extend `CacheDecorator` (or `RepositoryCacheDecorator` if you want the `repository_cache.*` config namespace), set `$ttl`, `$prefix_key`, `$excludes`, `$tag_cleaners`, `$tags` as protected properties, and either inject the decorated instance via the constructor or override `decoratedClass()` returning a FQCN string. - Inside custom method overrides, reference the inner object as `$this->decorated`. - To customize caching for a single method, override it in the subclass and use the protected helpers `generateCacheKey()`, `getCache()`, `putCache()` (see README example). -- TTL values throughout (subclass `$ttl`, `cache_decorator.ttl` / `repository_cache.ttl` config, calls to `setTtl()`) are in seconds — this changed from "minutes" when upgrading from the Laravel 5.x line. +- TTL values throughout (subclass `$ttl`, `cache_decorator.ttl` / `repository_cache.ttl` config, calls to `ttl()`) are in seconds — this changed from "minutes" when upgrading from the Laravel 5.x line. - **Keep the README in sync.** Any `src/` change that affects the main classes (`CacheDecorator`, `RepositoryCacheDecorator`, `ServiceProvider`) and their public-facing surface — property types/signatures, subclassing conventions, config keys, the `__call` flow, or anything a user copy-pastes from a usage example — must be documented in `README.md` as part of the same change. This is a core part of the package's DX and ease of use: the README usage examples should compile and run cleanly against the current code and follow the same conventions as the test stubs under `src/tests/Stubs/`. diff --git a/README.md b/README.md index 36a0caa..4785497 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ class CachedReportingService extends CacheDecorator { } ``` -> **TTL is read from config**, not from a `$ttl` property. The constructor calls `getConfig()`, which overwrites `$ttl` from `cache_decorator.ttl` (default `300` seconds; `repository_cache.ttl` for `RepositoryCacheDecorator`). To override it per-instance, call `setTtl(...)` after construction (e.g. in your subclass constructor) — it accepts `int` seconds, a `DateInterval`, a `DateTimeInterface`, or `null` to bypass the cache entirely. +> **TTL is read from config**, not from a `$ttl` property. The constructor calls `getConfig()`, which overwrites `$ttl` from `cache_decorator.ttl` (default `300` seconds; `repository_cache.ttl` for `RepositoryCacheDecorator`). To override it per-instance, call `->ttl(...)` after construction (e.g. in your subclass constructor) — it accepts `int` seconds, a `DateInterval`, a `DateTimeInterface`, or `null` to bypass the cache entirely. See [Fluent configuration](#fluent-configuration) for the full chainable grammar. …and use it like this: @@ -64,7 +64,52 @@ $cached->dailyTotals('2026-05-12'); // cache miss → calls ReportingService::da $cached->dailyTotals('2026-05-12'); // cache hit → returns the cached value ``` -The decorator forwards any method not listed in `$excludes` to the underlying object via `__call()` and caches the result. Forwarding goes through Laravel's `ForwardsCalls` trait, so calls also reach methods the decorated object exposes through *its own* `__call()` magic — not just declared methods. Calling a method that exists nowhere on the decorated object throws `UndefinedMethodException` (see [Exceptions](#exceptions)) with the message `Call to undefined method {Decorator}::{method}()`. *The current version doesn't support objects as method arguments — coming in v1.0.0.* +The decorator forwards any method not listed in `$excludes` to the underlying object via `__call()` and caches the result. Forwarding goes through Laravel's `ForwardsCalls` trait, so calls also reach methods the decorated object exposes through *its own* `__call()` magic — not just declared methods. Calling a method that exists nowhere on the decorated object throws `UndefinedMethodException` (see [Exceptions](#exceptions)) with the message `Call to undefined method {Decorator}::{method}()`. Method arguments may be scalars, arrays, or objects — see [Object arguments in cache keys](#object-arguments-in-cache-keys). + +### Fluent configuration + +Every configuration setter is chainable (returns the decorator) and there's an expressive, self-documenting grammar for tuning an instance at runtime — no subclass property edits required: + +```PHP +$cached = (new CachedReportingService(new ReportingService)) + ->ttl(600) // TTL in seconds (also DateInterval / DateTimeInterface / null) + ->prefix('reports') // cache-key prefix + ->withTags(['reports']) // cache tags (tag-capable store) + ->tagCleaners(['recompute']) // methods that flush the tags after running + ->exclude('debugDump'); // never cache these methods + +$cached->totals(); // configured and cached +``` + +| Method | Returns | Behavior | +|---|---|---| +| `ttl($ttl)` | `static` | Set TTL (seconds / `DateInterval` / `DateTimeInterface` / `null` to bypass). | +| `enable()` | `static` | Turn caching on. | +| `disable()` | `static` | Turn caching off (forwards straight to the inner object). | +| `prefix(?string)` | `static` | Set the cache-key prefix. | +| `withTags(array)` | `static` | Set the cache tags. | +| `tagCleaners(array)` | `static` | Set the methods that flush the tags. | +| `exclude(string ...)` | `static` | Append method names to the never-cache list. | + +> If you add your own public methods to a `CacheDecorator` **subclass**, remember that any method *not* listed in `$excludes` is forwarded to the inner object by `__call()`. The fluent methods above are already excluded by the base class, so chaining always resolves on the decorator and never leaks to the inner instance. + +### Object arguments in cache keys + +Method arguments may be scalars, arrays, or **objects** — the decorator folds each argument into the cache key through a stable identity rule: + +1. **Scalars / `null` / `bool`** — cast as-is (existing scalar-only keys are byte-identical, so upgrading invalidates nothing). +2. **`UrlRoutable`** (Eloquent models, etc.) — uses `getRouteKey()`, the model's natural stable identity. Two distinct instances with the same route key share a cache entry. +3. **`BackedEnum`** — uses its `->value`; **`Stringable` / `__toString`** — string-cast. +4. **Anything else** (plain objects, nested arrays of objects) — a bounded, stable hash (`json_encode`, falling back to `md5(serialize(...))`). + +Arrays are flattened (`Arr::dot`) and each leaf runs through the same rule, so nested objects are handled too. + +```PHP +$cached->reportFor($user); // User implements UrlRoutable → keyed by getRouteKey() +$cached->reportFor($sameUserReloaded); // same route key → cache hit +``` + +To customize how a given argument type contributes to the key, override the protected `normalizeArgument(mixed $value): string` seam in your subclass — it sits alongside `generateCacheKey()`, `getCache()`, and `putCache()` as a first-class extension point. ### Fluent / self-returning methods @@ -242,10 +287,11 @@ Environment variables: A few breaking changes tightened the public contract: -- **TTL bypass uses `null`, not `false`.** The "skip the cache" sentinel for `$ttl` is now `null`. The property type is `int|DateInterval|DateTimeInterface|null` (default `null`) and `setTtl()` has the same typed signature — replace any `protected $ttl = false;` with `protected $ttl = null;` and any `setTtl(false)` with `setTtl(null)`. +- **TTL bypass uses `null`, not `false`.** The "skip the cache" sentinel for `$ttl` is now `null`. The property type is `int|DateInterval|DateTimeInterface|null` (default `null`) and `ttl()` has the same typed signature — replace any `protected $ttl = false;` with `protected $ttl = null;` and any `ttl(false)` with `ttl(null)`. +- **The `void` setters were dropped for a fluent grammar.** `setTtl()` / `setEnabled(bool)` have been removed in favor of the chainable `ttl()`, `enable()`, and `disable()` methods (see [Fluent configuration](#fluent-configuration)). Replace `setTtl($n)` with `ttl($n)`, `setEnabled(true)` with `enable()`, and `setEnabled(false)` with `disable()`. - **`$tags` and `$tag_cleaners` are plain arrays.** Both default to `[]` (no longer `array|false`). If the cache driver doesn't support tags (or `use_tags` is disabled in config), they are reset to `[]` rather than `false`. Custom subclasses that initialized either property to `false` should switch to `[]`. - **Falsy cached values round-trip correctly.** Previously a method returning `0`, `''`, `[]`, or `false` would look like a cache miss and be refetched on every call. `getCache()` now returns a `cacheMiss()` sentinel (a shared `stdClass`) on a true miss, and `__call()` compares with `===` — so falsy results are cached and served from cache as expected. If you wrote a custom method override with `if (!$res)` around `getCache()`, switch it to `if ($res === $this->cacheMiss())` (see the override example above). -- **The `enabled` flag now actually short-circuits caching.** Setting `$enabled = false` (via the property, `setEnabled(false)`, or `{$config_key}.enabled = false`) now causes `__call()` to forward straight to the decorated object, skipping cache reads, writes, and tag flushing. +- **The `enabled` flag now actually short-circuits caching.** Setting `$enabled = false` (via the property, `disable()`, or `{$config_key}.enabled = false`) now causes `__call()` to forward straight to the decorated object, skipping cache reads, writes, and tag flushing. ### From the repository-only version diff --git a/src/CacheDecorator.php b/src/CacheDecorator.php index fc138a1..2ba349b 100644 --- a/src/CacheDecorator.php +++ b/src/CacheDecorator.php @@ -4,13 +4,16 @@ // At least for now there's a Laravel dependency, if there's need, this can be // converted to something more generic +use BackedEnum; use DateInterval; use DateTimeInterface; +use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use Illuminate\Support\Traits\ForwardsCalls; +use Stringable; use Trm42\CacheDecorator\Exceptions\MissingDecoratedObjectException; use Trm42\CacheDecorator\Exceptions\UndefinedMethodException; @@ -39,7 +42,6 @@ * @todo Add some kind of timer functionality to monitor result and cache speed * @todo How to handle empty returns (maybe config whether to cache empty or not and the placeholder) * @todo How to live without Laravel dependencies? - * @todo What if the decorated method parameters are objects? O___O */ abstract class CacheDecorator { @@ -127,32 +129,92 @@ public function __construct(?object $decorated = null) */ protected function initExcludes(): void { - $defaults = ['decoratedClass', 'setTtl', 'setEnabled', 'getConfig', 'initDecorated', + $defaults = ['decoratedClass', 'getConfig', 'initDecorated', 'doesMethodClearTag', 'clearCacheTag', 'getCache', 'putCache', - 'isMethodCacheable', 'generateCacheKey', 'log', 'cacheMiss', - 'forwardCallTo', 'forwardDecoratedCallTo', 'throwBadMethodCallException', ]; + 'isMethodCacheable', 'generateCacheKey', 'normalizeArgument', 'log', 'cacheMiss', + 'forwardCallTo', 'forwardDecoratedCallTo', 'throwBadMethodCallException', + 'ttl', 'enable', 'disable', 'prefix', 'withTags', 'tagCleaners', 'exclude', ]; $this->excludes = array_merge($defaults, $this->excludes); } /** - * Set Cache TTL + * Set the cache TTL for this instance. * * @param int|DateInterval|DateTimeInterface|null $ttl Cache time-to-live in seconds, or null to skip cache. */ - public function setTtl(int|DateInterval|DateTimeInterface|null $ttl): void + public function ttl(int|DateInterval|DateTimeInterface|null $ttl): static { $this->ttl = $ttl; + + return $this; + } + + /** + * Turn caching on for this instance. + */ + public function enable(): static + { + $this->enabled = true; + + return $this; + } + + /** + * Turn caching off for this instance (forwards straight to the decorated object). + */ + public function disable(): static + { + $this->enabled = false; + + return $this; } /** - * Enable or disable caching + * Set the cache key prefix at runtime. + */ + public function prefix(?string $prefix): static + { + $this->prefix_key = $prefix; + + return $this; + } + + /** + * Set the cache tags applied to this decorator's entries. Requires a + * tag-capable cache store. * - * @param bool $bool True == enable + * @param list $tags */ - public function setEnabled(bool $bool): void + public function withTags(array $tags): static { - $this->enabled = $bool; + $this->tags = $tags; + + return $this; + } + + /** + * Set the methods that flush the cache tags after running. Requires a + * tag-capable cache store. + * + * @param list $methods + */ + public function tagCleaners(array $methods): static + { + $this->tag_cleaners = $methods; + + return $this; + } + + /** + * Append one or more method names to the excludes list so they are never + * cached (forwarded straight to the decorated object). + */ + public function exclude(string ...$methods): static + { + $this->excludes = array_values([...$this->excludes, ...$methods]); + + return $this; } /** @@ -409,7 +471,7 @@ protected function generateCacheKey(string $method, array $arguments): string $params = ''; foreach ($temp_params as $k => $v) { - $params .= ".{$k}={$v}"; + $params .= ".{$k}=".$this->normalizeArgument($v); } $key = "{$this->prefix_key}.{$method}{$params}"; @@ -419,6 +481,44 @@ protected function generateCacheKey(string $method, array $arguments): string return $key; } + /** + * Normalize a single (already dotted) argument value into a stable string + * token for the cache key. Override this in a subclass to customize how a + * given argument type contributes to the key. + * + * Resolution order: + * 1. Scalars / null / bool → cast as-is (keeps existing keys byte-identical). + * 2. UrlRoutable (Eloquent models, etc.) → getRouteKey() — natural, stable identity. + * 3. BackedEnum → its ->value; Stringable / __toString → string cast. + * 4. Anything else (plain objects, closures-as-data) → a bounded, stable + * hash (json_encode when encodable, otherwise md5(serialize())). + * + * @param mixed $value A leaf argument value to fold into the cache key + * @return string Stable string token representing the value + */ + protected function normalizeArgument(mixed $value): string + { + if ($value === null || is_scalar($value)) { + return (string) $value; + } + + if ($value instanceof UrlRoutable) { + return (string) $value->getRouteKey(); + } + + if ($value instanceof BackedEnum) { + return (string) $value->value; + } + + if ($value instanceof Stringable || (is_object($value) && method_exists($value, '__toString'))) { + return (string) $value; + } + + $json = json_encode($value); + + return $json !== false ? $json : md5(serialize($value)); + } + /** * Simple wrapper around the Log facade to get logging when necessary * diff --git a/src/tests/CachedStubRepositoryTest.php b/src/tests/CachedStubRepositoryTest.php index cc412b0..1c8410f 100644 --- a/src/tests/CachedStubRepositoryTest.php +++ b/src/tests/CachedStubRepositoryTest.php @@ -40,8 +40,8 @@ protected function setUp(): void Cache::flush(); $this->repository = new CachedStubRepository(new StubRepository); - $this->repository->setEnabled(true); - $this->repository->setTtl(300); + $this->repository->enable(); + $this->repository->ttl(300); } #[Test] @@ -175,7 +175,7 @@ public function test_multi_dimensional_arrays_as_parameter() #[Test] public function test_set_enabled_false_bypasses_cache() { - $this->repository->setEnabled(false); + $this->repository->disable(); $first = $this->repository->all(); $this->assertEquals([1, 2, 3, 4, 5], $first); @@ -194,7 +194,7 @@ public function test_set_ttl_to_null_skips_cache() Cache::shouldReceive('get')->never(); Cache::shouldReceive('put')->never(); - $this->repository->setTtl(null); + $this->repository->ttl(null); $this->repository->find(3); } diff --git a/src/tests/CachedStubServiceTest.php b/src/tests/CachedStubServiceTest.php index 1e3c4dd..063105d 100644 --- a/src/tests/CachedStubServiceTest.php +++ b/src/tests/CachedStubServiceTest.php @@ -16,6 +16,7 @@ use Trm42\CacheDecorator\Tests\Stubs\CachedStubServiceWithDependency; use Trm42\CacheDecorator\Tests\Stubs\StubFluentService; use Trm42\CacheDecorator\Tests\Stubs\StubMagicService; +use Trm42\CacheDecorator\Tests\Stubs\StubModel; use Trm42\CacheDecorator\Tests\Stubs\StubService; /** @@ -48,8 +49,8 @@ protected function setUp(): void $this->inner = new StubService; $this->service = new CachedStubService($this->inner); - $this->service->setEnabled(true); - $this->service->setTtl(300); + $this->service->enable(); + $this->service->ttl(300); } #[Test] @@ -96,7 +97,7 @@ public function test_set_ttl_to_null_bypasses_cache() Cache::shouldReceive('get')->never(); Cache::shouldReceive('put')->never(); - $this->service->setTtl(null); + $this->service->ttl(null); $this->service->compute(3); } @@ -105,7 +106,7 @@ public function test_set_ttl_to_null_bypasses_cache() public function test_no_arg_construction_via_decorated_class() { $service = new CachedAutoStubService; - $service->setTtl(300); + $service->ttl(300); $result = $service->findThing(7); @@ -119,7 +120,7 @@ public function test_decorated_class_is_resolved_through_container_with_dependen // argument, so `new $class` would fail. Resolving through the container // auto-wires the dependency. $service = new CachedStubServiceWithDependency; - $service->setTtl(300); + $service->ttl(300); $this->assertEquals('hello from collaborator', $service->delegatedGreeting()); } @@ -172,8 +173,8 @@ public function test_magic_method_is_forwarded_and_cached() { $magicInner = new StubMagicService; $service = new CachedMagicService($magicInner); - $service->setEnabled(true); - $service->setTtl(300); + $service->enable(); + $service->ttl(300); // magicCompute() only exists via StubMagicService::__call(), so the old // method_exists() gate would have thrown BadMethodCallException. @@ -190,8 +191,8 @@ public function test_fluent_method_returns_decorator_not_inner() { $fluentInner = new StubFluentService; $service = new CachedFluentService($fluentInner); - $service->setEnabled(true); - $service->setTtl(300); + $service->enable(); + $service->ttl(300); // withFlag() returns `$this` (the inner); forwardDecoratedCallTo() // rewrites that to the decorator so chaining stays on the cached surface. @@ -200,4 +201,101 @@ public function test_fluent_method_returns_decorator_not_inner() $this->assertSame($service, $returned, 'Fluent call should return the decorator, not the inner object'); $this->assertEquals('on', $returned->result()); } + + #[Test] + public function test_fluent_setters_return_the_decorator() + { + $this->assertSame($this->service, $this->service->ttl(600)); + $this->assertSame($this->service, $this->service->enable()); + $this->assertSame($this->service, $this->service->disable()); + $this->assertSame($this->service, $this->service->prefix('svc')); + $this->assertSame($this->service, $this->service->withTags(['x'])); + $this->assertSame($this->service, $this->service->tagCleaners(['mutate'])); + $this->assertSame($this->service, $this->service->exclude('whatever')); + } + + #[Test] + public function test_disable_bypasses_cache() + { + $this->service->disable(); + + $this->service->compute(21); + $this->service->compute(21); + + $this->assertEquals(2, $this->inner->callCount, 'disable() should bypass the cache'); + } + + #[Test] + public function test_enable_turns_caching_back_on() + { + $this->service->disable()->enable(); + + $this->service->compute(21); + $this->service->compute(21); + + $this->assertEquals(1, $this->inner->callCount, 'enable() should restore caching'); + } + + #[Test] + public function test_fluent_ttl_null_bypasses_cache() + { + Cache::shouldReceive('get')->never(); + Cache::shouldReceive('put')->never(); + + $this->service->ttl(null); + + $this->service->compute(3); + } + + #[Test] + public function test_fluent_exclude_adds_method_to_excludes() + { + $this->service->exclude('compute'); + + $this->service->compute(21); + $this->service->compute(21); + + $this->assertEquals(2, $this->inner->callCount, 'exclude() should keep compute() off the cache path'); + } + + #[Test] + public function test_chained_configuration_resolves_on_decorator_not_inner() + { + // Regression guard for the initExcludes() additions: each fluent method + // must run on the decorator and return it, not be forwarded to the inner. + $result = $this->service->ttl(600)->enable()->prefix('svc')->compute(21); + + $this->assertEquals(42, $result); + } + + #[Test] + public function test_object_argument_caches_by_identity() + { + $model = new StubModel(7); + + $first = $this->service->describeModel($model); + $second = $this->service->describeModel($model); + + $this->assertEquals('model-7', $first); + $this->assertEquals('model-7', $second); + $this->assertEquals(1, $this->inner->callCount, 'Same UrlRoutable identity should hit the cache'); + } + + #[Test] + public function test_object_arguments_with_same_route_key_share_cache() + { + $this->service->describeModel(new StubModel(7)); + $this->service->describeModel(new StubModel(7)); + + $this->assertEquals(1, $this->inner->callCount, 'Distinct instances with the same getRouteKey() should share a cache entry'); + } + + #[Test] + public function test_object_arguments_with_distinct_identity_yield_distinct_keys() + { + $this->service->describeModel(new StubModel(7)); + $this->service->describeModel(new StubModel(8)); + + $this->assertEquals(2, $this->inner->callCount, 'Different identities should produce different cache keys'); + } } diff --git a/src/tests/Stubs/StubModel.php b/src/tests/Stubs/StubModel.php new file mode 100644 index 0000000..80899b1 --- /dev/null +++ b/src/tests/Stubs/StubModel.php @@ -0,0 +1,34 @@ +id; + } + + public function getRouteKeyName() + { + return 'id'; + } + + public function resolveRouteBinding($value, $field = null) + { + return null; + } + + public function resolveChildRouteBinding($childType, $value, $field = null) + { + return null; + } +} diff --git a/src/tests/Stubs/StubService.php b/src/tests/Stubs/StubService.php index 9ee9838..c88cc2c 100644 --- a/src/tests/Stubs/StubService.php +++ b/src/tests/Stubs/StubService.php @@ -57,4 +57,11 @@ public function returnFalse(): bool return false; } + + public function describeModel(StubModel $model): string + { + $this->callCount++; + + return "model-{$model->id}"; + } }