diff --git a/src/Phaseolies/DI/Concerns/EnforcesImmutability.php b/src/Phaseolies/DI/Concerns/EnforcesImmutability.php index 118e7109..6d572842 100644 --- a/src/Phaseolies/DI/Concerns/EnforcesImmutability.php +++ b/src/Phaseolies/DI/Concerns/EnforcesImmutability.php @@ -30,6 +30,10 @@ trait EnforcesImmutability */ public function freeze(): void { + if ($this->__frozen) { + return; + } + $reflector = new \ReflectionClass(static::class); foreach ($reflector->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) { diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index 9484f57e..b76a967e 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -37,6 +37,13 @@ class Container implements ArrayAccess */ private array $resolving = []; + /** + * Whether each class seen by freezeResolved() is a frozen service. + * + * @var array + */ + private array $immutableClasses = []; + /** * The container instance. * @@ -261,7 +268,7 @@ public function get(string $abstract, array $parameters = []): mixed try { if (isset($this->instances[$abstract]) && $this->instances[$abstract] !== null) { - return $this->instances[$abstract]; + return $this->freezeResolved($this->instances[$abstract]); } if (isset($this->bindings[$abstract])) { @@ -286,7 +293,7 @@ public function get(string $abstract, array $parameters = []): mixed // Fallback only: no exact instance and no explicit binding for $abstract. foreach ($this->instances as $instance) { if ($instance instanceof $abstract) { - return $instance; + return $this->freezeResolved($instance); } } @@ -314,7 +321,7 @@ private function resolveBinding(string $abstract, array $binding, array $paramet $concrete = $binding['concrete']; if (is_callable($concrete)) { - return $concrete($this, $parameters); + return $this->freezeResolved($concrete($this, $parameters)); } if (is_string($concrete) && class_exists($concrete)) { @@ -376,6 +383,31 @@ public function build(string $concrete, array $parameters = []): object return $instance; } + /** + * Freeze an object handed out by a closure binding or instance() + * + * @param mixed $resolved + * @return mixed + */ + private function freezeResolved(mixed $resolved): mixed + { + if (!is_object($resolved)) { + return $resolved; + } + + $class = $resolved::class; + + $this->immutableClasses[$class] ??= !empty((new \ReflectionClass($resolved))->getAttributes(Immutable::class)) + && method_exists($resolved, 'freeze') + && method_exists($resolved, 'isFrozen'); + + if ($this->immutableClasses[$class] && !$resolved->isFrozen()) { + $resolved->freeze(); + } + + return $resolved; + } + /** * Freeze the instance if the class has the #[Immutable] attribute and uses the EnforcesImmutability trait. * diff --git a/tests/Application/ContainerTest.php b/tests/Application/ContainerTest.php index 031e29e1..a2563af4 100644 --- a/tests/Application/ContainerTest.php +++ b/tests/Application/ContainerTest.php @@ -2896,6 +2896,118 @@ public function testImmutableSingletonBothReferencesAreFrozen() $this->assertSame($first, $second); } + // ========================================================================= + // SERVICES CONFIGURED IN A LAUNCHER (closure bindings and instance()) + // ========================================================================= + + public function testSingletonClosureResultIsFrozenOnFirstResolve() + { + $payment = new MockPaymentService(); + $payment->gateway = 'paypal'; + + $this->container->singleton(MockPaymentService::class, fn () => $payment); + + $this->assertFalse($payment->isFrozen()); + + $resolved = $this->container->get(MockPaymentService::class); + + $this->assertSame($payment, $resolved); + $this->assertTrue($resolved->isFrozen()); + $this->assertSame('paypal', $resolved->gateway); + } + + public function testSingletonClosureServiceBlocksMutationAfterResolve() + { + $payment = new MockPaymentService(); + $this->container->singleton(MockPaymentService::class, fn () => $payment); + + $resolved = $this->container->get(MockPaymentService::class); + + $this->expectException(ImmutableViolationException::class); + $resolved->taxRate = 0.0; + } + + public function testLauncherReferenceIsFrozenOnceTheContainerResolvesIt() + { + $payment = new MockPaymentService(); + $this->container->singleton(MockPaymentService::class, fn () => $payment); + $this->container->get(MockPaymentService::class); + + // The launcher still holds the same object, and it is frozen too. + $this->expectException(ImmutableViolationException::class); + $payment->gateway = 'late change'; + } + + public function testInstanceIsFrozenOnFirstResolve() + { + $payment = new MockPaymentService(); + $payment->retries = 9; + + $this->container->instance(MockPaymentService::class, $payment); + + $resolved = $this->container->get(MockPaymentService::class); + + $this->assertTrue($resolved->isFrozen()); + $this->assertSame(9, $resolved->retries); + } + + public function testResolvingTwiceKeepsTheConfiguredValues() + { + $payment = new MockPaymentService(); + $payment->gateway = 'paypal'; + $payment->methods = ['wallet']; + + $this->container->singleton(MockPaymentService::class, fn () => $payment); + + $this->container->get(MockPaymentService::class); + $again = $this->container->get(MockPaymentService::class); + + $this->assertSame('paypal', $again->gateway); + $this->assertSame(['wallet'], $again->methods); + } + + public function testMakeWithClosureBindingFreezesEachResult() + { + $this->container->bind(MockPaymentService::class, fn () => new MockPaymentService()); + + $first = $this->container->make(MockPaymentService::class); + $second = $this->container->make(MockPaymentService::class); + + $this->assertNotSame($first, $second); + $this->assertTrue($first->isFrozen()); + $this->assertTrue($second->isFrozen()); + } + + public function testClosureResultWithoutImmutableAttributeIsNotFrozen() + { + $this->container->singleton('plain', fn () => new \stdClass()); + + $resolved = $this->container->get('plain'); + $resolved->anything = 'allowed'; + + $this->assertSame('allowed', $resolved->anything); + } + + public function testClosureReturningNonObjectIsLeftAlone() + { + $this->container->bind('answer', fn () => 42); + + $this->assertSame(42, $this->container->get('answer')); + } + + public function testBuildWithValuesInsteadOfAClosureStillWorks() + { + $this->container->singleton( + MockPaymentService::class, + fn ($app) => $app->build(MockPaymentService::class) + ); + + $resolved = $this->container->get(MockPaymentService::class); + + $this->assertTrue($resolved->isFrozen()); + $this->assertSame('stripe', $resolved->gateway); + } + // ========================================================================= // TRANSIENT BEHAVIOUR WITH IMMUTABLE // =========================================================================