diff --git a/src/Becoming.php b/src/Becoming.php index 27b89b17..09883bfa 100644 --- a/src/Becoming.php +++ b/src/Becoming.php @@ -26,6 +26,7 @@ final class Becoming implements BecomingInterface { private Being $being; private LoggerInterface $logger; + private BecomingArgumentsInterface $becomingArguments; public function __construct( InjectorInterface $injector, @@ -37,6 +38,7 @@ public function __construct( $becomingArguments ??= new BecomingArguments($injector, new SemanticValidator($semanticNamespace)); $logger ??= new Logger(new SemanticLogger(), $becomingArguments); $this->logger = $logger; + $this->becomingArguments = $becomingArguments; $this->being = new Being($logger, $becomingArguments, new BecomingType()); } @@ -57,6 +59,10 @@ public function __invoke(object $input): object $current = $input; $isFirst = true; + // Activate the semantic validator's per-chain cache; the finally below + // guarantees it is discarded whether the chain succeeds or throws. + $this->becomingArguments->beginChain(); + try { // Being reveals its becoming, then becomes it while ($nextForm = $this->being->willBe($current)) { @@ -90,6 +96,8 @@ public function __invoke(object $input): object } throw $e; + } finally { + $this->becomingArguments->endChain(); } // Success close is outside the try so a logging failure here is not diff --git a/src/BecomingArguments.php b/src/BecomingArguments.php index a9576a7c..043b5710 100644 --- a/src/BecomingArguments.php +++ b/src/BecomingArguments.php @@ -82,6 +82,18 @@ public function be(object $current, string $becoming): array return $args; } + #[Override] + public function beginChain(): void + { + $this->semanticValidator->beginChain(); + } + + #[Override] + public function endChain(): void + { + $this->semanticValidator->endChain(); + } + /** * Resolves #[Inject] parameters from DI container * diff --git a/src/BecomingArgumentsInterface.php b/src/BecomingArgumentsInterface.php index 0ac2d39b..0f60c977 100644 --- a/src/BecomingArgumentsInterface.php +++ b/src/BecomingArgumentsInterface.php @@ -25,4 +25,14 @@ interface BecomingArgumentsInterface * @phpstan-return array */ public function be(object $current, string $becoming): array; + + /** + * Begin a metamorphosis chain (activates the semantic validator's per-chain cache). + */ + public function beginChain(): void; + + /** + * End a metamorphosis chain (discards the per-chain cache; must run even on failure). + */ + public function endChain(): void; } diff --git a/src/SemanticVariable/NullValidator.php b/src/SemanticVariable/NullValidator.php index 68dabb75..5da2a56e 100644 --- a/src/SemanticVariable/NullValidator.php +++ b/src/SemanticVariable/NullValidator.php @@ -73,4 +73,22 @@ public function validateObject(object $object): Errors { return new NullErrors(); } + + /** + * No-op: the null validator has no cache to activate. + */ + #[Override] + public function beginChain(): void + { + // No-op + } + + /** + * No-op: the null validator has no cache to discard. + */ + #[Override] + public function endChain(): void + { + // No-op + } } diff --git a/src/SemanticVariable/SemanticValidator.php b/src/SemanticVariable/SemanticValidator.php index cdfaed62..9e5e4f8c 100644 --- a/src/SemanticVariable/SemanticValidator.php +++ b/src/SemanticVariable/SemanticValidator.php @@ -14,14 +14,24 @@ use ReflectionMethod; use ReflectionParameter; +use function array_filter; use function array_key_exists; +use function array_values; use function class_exists; +use function count; use function get_object_vars; +use function gettype; +use function implode; use function in_array; use function is_array; +use function is_object; +use function reset; +use function sort; +use function spl_object_id; use function str_replace; use function trigger_error; use function ucwords; +use function var_export; use const E_USER_NOTICE; @@ -43,6 +53,19 @@ final class SemanticValidator implements SemanticValidatorInterface private readonly array $classMap; private readonly SemanticValidationMethodResolver $validationMethodResolver; + /** @var array Full class names already confirmed missing — avoids re-checking class_exists() and re-emitting the notice on every call. */ + private array $missingSemanticClasses = []; + + /** + * Per-chain value-validation cache. null = inactive (outside a chain): every + * call re-validates and nothing accumulates. An array (set by beginChain()) + * remembers successfully validated (name, attributes, value) triples for the + * lifetime of one Becoming::__invoke() chain. + * + * @var array|null + */ + private array|null $chainCache = null; + /** @param array|SemanticValidationMethodResolver|null $classMapOrValidationMethodResolver */ public function __construct( #[Named('semantic_namespace')] @@ -255,6 +278,12 @@ public function validateWithAttributes(string $variableName, array $parameterAtt return new NullErrors(); } + $cacheKey = $this->chainCacheKey($variableName, $parameterAttributes, $args, $validationMethods); + if ($cacheKey !== null && isset($this->chainCache[$cacheKey])) { + // Same value carried unchanged through this chain — already validated. + return new NullErrors(); + } + $exceptions = []; foreach ($validationMethods as $method) { @@ -266,9 +295,85 @@ public function validateWithAttributes(string $variableName, array $parameterAtt } } + if ($cacheKey !== null && empty($exceptions)) { + $this->chainCache[$cacheKey] = true; + } + return empty($exceptions) ? new NullErrors() : new Errors($exceptions); } + /** + * Activate a fresh per-chain cache. See {@see SemanticValidatorInterface::beginChain()}. + */ + #[Override] + public function beginChain(): void + { + $this->chainCache = []; + } + + /** + * Discard the per-chain cache. See {@see SemanticValidatorInterface::endChain()}. + */ + #[Override] + public function endChain(): void + { + $this->chainCache = null; + } + + /** + * Cache key for a single-value validation, or null when caching must be bypassed. + * + * Bypassed when: no active chain; not a single-value call; the value is an + * array (normalizing it costs more than re-validating); or any resolved + * #[Validate] method takes an #[Inject] parameter (it may consult mutable + * injected state and legitimately return a different answer for the same + * value, so its result must never be cached). + * + * The key combines the variable name, the SORTED attribute set (so a later + * #[Teen] int $age never reuses an earlier plain int $age result — they + * select different #[Validate] methods), and a value key. Scalars/null are + * type-tagged to avoid 1/"1"/true collisions; objects use spl_object_id(), + * which is a safe stand-in for value identity ONLY because Be Framework's + * public-readonly convention means an object's identity implies its state + * is unchanged since construction. + * + * @param ParameterAttributes $parameterAttributes + * @param ValidationArguments $args + * @param ReflectionMethods $validationMethods + * @phpstan-param array $parameterAttributes + * @phpstan-param array $args + * @phpstan-param array $validationMethods + */ + private function chainCacheKey(string $variableName, array $parameterAttributes, array $args, array $validationMethods): string|null + { + if ($this->chainCache === null || count($args) !== 1) { + return null; + } + + /** @var mixed $value */ + $value = reset($args); + if (is_array($value)) { + return null; + } + + foreach ($validationMethods as $method) { + foreach ($method->getParameters() as $parameter) { + if ($this->validationMethodResolver->hasInjectAttribute($parameter)) { + return null; + } + } + } + + $valueKey = is_object($value) + ? 'obj:' . spl_object_id($value) + : gettype($value) . ':' . var_export($value, true); + + $attributes = array_values(array_filter($parameterAttributes, 'is_string')); + sort($attributes); + + return $variableName . '|' . implode(',', $attributes) . '|' . $valueKey; + } + /** * Resolve semantic class from variable name */ @@ -277,7 +382,12 @@ private function resolveSemanticClass(string $variableName): object|null $className = $this->convertToClassName($variableName); $fullClassName = $this->classMap[$className] ?? "{$this->semanticNamespace}\\$className"; + if (isset($this->missingSemanticClasses[$fullClassName])) { + return null; + } + if (! class_exists($fullClassName)) { + $this->missingSemanticClasses[$fullClassName] = true; trigger_error("Semantic variable '{$className}' not registered in ontology namespace {$this->semanticNamespace}", E_USER_NOTICE); return null; diff --git a/src/SemanticVariable/SemanticValidatorInterface.php b/src/SemanticVariable/SemanticValidatorInterface.php index c752e89e..42ea381b 100644 --- a/src/SemanticVariable/SemanticValidatorInterface.php +++ b/src/SemanticVariable/SemanticValidatorInterface.php @@ -39,4 +39,22 @@ public function validateArgs(ReflectionMethod $method, array $args): Errors; * @return Errors Validation errors (empty if validation passes) */ public function validateArg(ReflectionParameter $parameter, mixed $value): Errors; + + /** + * Begin a metamorphosis chain: activate the per-chain value-validation cache. + * + * Between beginChain() and endChain(), a successfully validated + * (parameterName, attributes, value) triple is remembered so an unchanged + * value carried through several hops of one chain is validated once. Outside + * this window the cache is inactive and every call re-validates. + */ + public function beginChain(): void; + + /** + * End a metamorphosis chain: discard the per-chain cache. + * + * Must be called (even on failure) so cache entries never outlive their + * chain — the unbounded-growth failure mode documented for SemanticLogger. + */ + public function endChain(): void; } diff --git a/tests/BecomingTest.php b/tests/BecomingTest.php index ceedb26f..34a62b7f 100644 --- a/tests/BecomingTest.php +++ b/tests/BecomingTest.php @@ -18,7 +18,9 @@ use Be\Framework\SemanticVariable\Errors; use Be\Framework\SemanticVariable\SemanticValidator; use InvalidArgumentException; +use Koriym\SemanticLogger\Exception\NoLogSessionException; use Koriym\SemanticLogger\SemanticLogger; +use MyVendor\MyApp\SemanticVariables\Counted; use PHPUnit\Framework\TestCase; use Ray\Di\AbstractModule; use Ray\Di\Di\Inject; @@ -291,6 +293,27 @@ public function testChainCloseLogRecordsRuntimeOrigin(): void $this->assertSame(BecomingCloseContext::ORIGIN_RUNTIME, $context['origin']); } + public function testFlushReturnsAccumulatedSessionAndResetsState(): void + { + // BeModule binds SemanticLoggerInterface as a singleton so one instance + // can accumulate several chains before the caller reads it out (see + // docs/semantic-log-architecture.md#log-lifecycle-flush-ownership). + // flush() must return everything accumulated so far AND reset the + // logger's internal state so a later read finds no session. + $semanticLogger = new SemanticLogger(); + $becoming = $this->becomingWithLogger($semanticLogger); + + $becoming(new BecomingTestInput('first')); + $becoming(new BecomingTestInput('second')); + + $session = $semanticLogger->flush()->toArray(); + assert(is_array($session['open'])); + $this->assertCount(2, $session['open'], 'flush() must return both accumulated chains'); + + $this->expectException(NoLogSessionException::class); + $semanticLogger->toArray(); + } + private function becomingWithLogger(SemanticLogger $semanticLogger): Becoming { $injector = new Injector(new BecomingTestModule()); @@ -349,6 +372,50 @@ public function testInfrastructureExceptionPropagatesImmediately(): void $input = new BecomingTestInfrastructureErrorInput('test'); ($this->becoming)($input); } + + public function testUnchangedValueValidatedOncePerChainThenReValidatedNextChain(): void + { + // Issue #81: a value carried unchanged through a chain's hops is + // validated once, but each new Becoming::__invoke() starts fresh. + Counted::$count = 0; + + $result = ($this->becoming)(new BecomingTestCacheStart(5)); + $this->assertSame(5, $result->counted); + $this->assertSame(1, Counted::$count, 'Unchanged value validated once within one chain'); + + ($this->becoming)(new BecomingTestCacheStart(5)); + $this->assertSame(2, Counted::$count, 'A new chain re-validates (cache is per-chain)'); + } +} + +// Cache-per-chain fixtures (issue #81): `counted` carried unchanged through hops. +#[Be(BecomingTestCacheMid::class)] +final class BecomingTestCacheStart +{ + public function __construct( + #[Input] + public readonly int $counted, + ) { + } +} + +#[Be(BecomingTestCacheEnd::class)] +final class BecomingTestCacheMid +{ + public function __construct( + #[Input] + public readonly int $counted, + ) { + } +} + +final class BecomingTestCacheEnd +{ + public function __construct( + #[Input] + public readonly int $counted, + ) { + } } // Test fixtures for coverage testing diff --git a/tests/FakeApp/SemanticVariables/Counted.php b/tests/FakeApp/SemanticVariables/Counted.php new file mode 100644 index 00000000..2d421d71 --- /dev/null +++ b/tests/FakeApp/SemanticVariables/Counted.php @@ -0,0 +1,22 @@ + $tags */ + #[Validate] + public function validateTags(array $tags): void + { + self::$count++; + } +} diff --git a/tests/FakeApp/SemanticVariables/Tally.php b/tests/FakeApp/SemanticVariables/Tally.php new file mode 100644 index 00000000..f4208abc --- /dev/null +++ b/tests/FakeApp/SemanticVariables/Tally.php @@ -0,0 +1,27 @@ +validator = new SemanticValidator('MyVendor\\MyApp\\SemanticVariables'); + Counted::$count = 0; + Tally::$count = 0; + Tags::$count = 0; + } + + public function testUnchangedValueIsValidatedOncePerChain(): void + { + $this->validator->beginChain(); + $this->validator->validateWithAttributes('counted', [], 5); + $this->validator->validateWithAttributes('counted', [], 5); + $this->validator->endChain(); + + $this->assertSame(1, Counted::$count, 'Same value in one chain must validate once'); + } + + public function testAttributeSetIsPartOfCacheKey(): void + { + // Trap #2: an earlier plain `int $age` check must not let a later + // `#[Teen] int $age` with the same value skip validation. age=25 passes + // validateAge but fails validateTeen (>19), so a wrong cache hit would + // hide the Teen error. + $this->validator->beginChain(); + + $plain = $this->validator->validateWithAttributes('age', [], 25); + $this->assertFalse($plain->hasErrors(), 'Plain age=25 is valid'); + + $teen = $this->validator->validateWithAttributes('age', ['Teen'], 25); + $this->validator->endChain(); + + $this->assertTrue($teen->hasErrors(), 'Teen validation must run despite same value/name'); + } + + public function testInjectConsumingValidatorIsNeverCached(): void + { + // Trap #1: a #[Validate] method with an #[Inject] parameter may depend on + // mutable injected state and must re-run for the same value. + $this->validator->beginChain(); + $this->validator->validateWithAttributes('tally', [], 7); + $this->validator->validateWithAttributes('tally', [], 7); + $this->validator->endChain(); + + $this->assertSame(2, Tally::$count, 'Inject-consuming validator must not be cached'); + } + + public function testDirectCallsOutsideChainNeverAccumulate(): void + { + // Trap #3: calling the validator directly (no active chain) must leave the + // cache inactive so entries never accumulate or serve stale results + // across unrelated calls. + $cache = new ReflectionProperty(SemanticValidator::class, 'chainCache'); + + $this->assertNull($cache->getValue($this->validator), 'Cache starts inactive'); + + $this->validator->validate('counted', 5); + $this->validator->validate('counted', 5); + $this->validator->validateWithAttributes('age', [], 25); + + $this->assertNull($cache->getValue($this->validator), 'Direct calls must not activate the cache'); + $this->assertSame(2, Counted::$count, 'Without an active chain every call re-validates'); + } + + public function testEndChainDiscardsCache(): void + { + $cache = new ReflectionProperty(SemanticValidator::class, 'chainCache'); + + $this->validator->beginChain(); + $this->validator->validateWithAttributes('counted', [], 5); + $this->assertIsArray($cache->getValue($this->validator)); + + $this->validator->endChain(); + $this->assertNull($cache->getValue($this->validator), 'endChain() must discard the cache'); + } + + public function testArrayValuesAreNeverCached(): void + { + // Array values are excluded from the cache (normalizing costs more than + // re-validating), so each call must re-run even in an active chain. + $this->validator->beginChain(); + $this->validator->validateWithAttributes('tags', [], ['a', 'b']); + $this->validator->validateWithAttributes('tags', [], ['a', 'b']); + $this->validator->endChain(); + + $this->assertSame(2, Tags::$count, 'Array values must never be cached'); + } + + public function testMultiArgCallsAreNeverCached(): void + { + // The cache only serves single-value validations; multi-arg (cross-field) + // calls must always re-validate so a changed sibling arg is never masked. + $this->validator->beginChain(); + $this->validator->validateWithAttributes('counted', [], 5, 99); + $this->validator->validateWithAttributes('counted', [], 5, 99); + $this->validator->endChain(); + + $this->assertSame(2, Counted::$count, 'Multi-arg calls must never be cached'); + } +}