diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b9e4d0f..c15cfa575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **Other Features** - Added `Spiral\Scaffolder\Command\InfoCommand` console command for getting information about available scaffolder commands. + - [spiral/core] Added the ability to bind the interface as a proxy via `Spiral\Core\Config\Proxy` or `Spiral\Core\Config\DeprecationProxy`. ## 3.11.1 - 2023-12-29 diff --git a/src/Core/src/Config/DeprecationProxy.php b/src/Core/src/Config/DeprecationProxy.php new file mode 100644 index 000000000..fa63fd8db --- /dev/null +++ b/src/Core/src/Config/DeprecationProxy.php @@ -0,0 +1,45 @@ +message ?? \sprintf( + 'Using `%s` outside of the `%s` scope is deprecated and will be impossible in version %s.', + $this->interface, + $this->scope, + $this->version + ); + + @trigger_error($message, \E_USER_DEPRECATED); + + return parent::getInterface(); + } +} diff --git a/src/Core/src/Config/Proxy.php b/src/Core/src/Config/Proxy.php new file mode 100644 index 000000000..940b1f217 --- /dev/null +++ b/src/Core/src/Config/Proxy.php @@ -0,0 +1,33 @@ +interface); + } + + /** + * @return class-string + */ + public function getInterface(): string + { + return $this->interface; + } +} diff --git a/src/Core/src/Internal/Factory.php b/src/Core/src/Internal/Factory.php index 06e409b82..e786ce003 100644 --- a/src/Core/src/Internal/Factory.php +++ b/src/Core/src/Internal/Factory.php @@ -8,12 +8,9 @@ use Psr\Container\ContainerInterface; use ReflectionFunctionAbstract as ContextFunction; use ReflectionParameter; -use Spiral\Core\Attribute\Finalize; -use Spiral\Core\Attribute\Scope as ScopeAttribute; -use Spiral\Core\Attribute\Singleton; +use Spiral\Core\Attribute; use Spiral\Core\BinderInterface; -use Spiral\Core\Config\Injectable; -use Spiral\Core\Config\DeferredFactory; +use Spiral\Core\Config; use Spiral\Core\Container\InjectorInterface; use Spiral\Core\Container\SingletonInterface; use Spiral\Core\Exception\Container\AutowireException; @@ -91,18 +88,20 @@ public function make(string $alias, array $parameters = [], Stringable|string|nu unset($this->state->bindings[$alias]); return match ($binding::class) { - \Spiral\Core\Config\Alias::class => $this->resolveAlias($binding, $alias, $context, $parameters), - \Spiral\Core\Config\Autowire::class => $this->resolveAutowire($binding, $alias, $context, $parameters), - DeferredFactory::class, - \Spiral\Core\Config\Factory::class => $this->resolveFactory($binding, $alias, $context, $parameters), - \Spiral\Core\Config\Shared::class => $this->resolveShared($binding, $alias, $context, $parameters), - Injectable::class => $this->resolveInjector( + Config\Alias::class => $this->resolveAlias($binding, $alias, $context, $parameters), + Config\Proxy::class, + Config\DeprecationProxy::class => $this->resolveProxy($binding, $alias, $context), + Config\Autowire::class => $this->resolveAutowire($binding, $alias, $context, $parameters), + Config\DeferredFactory::class, + Config\Factory::class => $this->resolveFactory($binding, $alias, $context, $parameters), + Config\Shared::class => $this->resolveShared($binding, $alias, $context, $parameters), + Config\Injectable::class => $this->resolveInjector( $binding, new Ctx(alias: $alias, class: $alias, context: $context), $parameters, ), - \Spiral\Core\Config\Scalar::class => $binding->value, - \Spiral\Core\Config\WeakReference::class => $this + Config\Scalar::class => $binding->value, + Config\WeakReference::class => $this ->resolveWeakReference($binding, $alias, $context, $parameters), default => $binding, }; @@ -117,7 +116,7 @@ public function make(string $alias, array $parameters = [], Stringable|string|nu * @psalm-suppress UnusedParam * todo wat should we do with $arguments? */ - private function resolveInjector(Injectable $binding, Ctx $ctx, array $arguments) + private function resolveInjector(Config\Injectable $binding, Ctx $ctx, array $arguments) { $context = $ctx->context; try { @@ -174,7 +173,7 @@ private function resolveInjector(Injectable $binding, Ctx $ctx, array $arguments } private function resolveAlias( - \Spiral\Core\Config\Alias $binding, + Config\Alias $binding, string $alias, Stringable|string|null $context, array $arguments, @@ -194,8 +193,19 @@ private function resolveAlias( return $result; } + private function resolveProxy(Config\Proxy $binding, string $alias, Stringable|string|null $context): mixed + { + $result = Proxy::create(new \ReflectionClass($binding->getInterface()), $context, new Attribute\Proxy()); + + if ($binding->singleton) { + $this->state->singletons[$alias] = $result; + } + + return $result; + } + private function resolveShared( - \Spiral\Core\Config\Shared $binding, + Config\Shared $binding, string $alias, Stringable|string|null $context, array $arguments, @@ -210,7 +220,7 @@ private function resolveShared( } private function resolveAutowire( - \Spiral\Core\Config\Autowire $binding, + Config\Autowire $binding, string $alias, Stringable|string|null $context, array $arguments, @@ -222,14 +232,14 @@ private function resolveAutowire( } private function resolveFactory( - \Spiral\Core\Config\Factory|DeferredFactory $binding, + Config\Factory|Config\DeferredFactory $binding, string $alias, Stringable|string|null $context, array $arguments, ): mixed { $ctx = new Ctx(alias: $alias, class: $alias, context: $context, singleton: $binding->singleton); try { - $instance = $binding::class === \Spiral\Core\Config\Factory::class && $binding->getParametersCount() === 0 + $instance = $binding::class === Config\Factory::class && $binding->getParametersCount() === 0 ? ($binding->factory)() : $this->invoker->invoke($binding->factory, $arguments); } catch (NotCallableException $e) { @@ -244,7 +254,7 @@ private function resolveFactory( } private function resolveWeakReference( - \Spiral\Core\Config\WeakReference $binding, + Config\WeakReference $binding, string $alias, Stringable|string|null $context, array $arguments, @@ -370,7 +380,7 @@ private function validateNewInstance( ): object { // Check scope name $ctx->reflection = new \ReflectionClass($instance); - $scopeName = ($ctx->reflection->getAttributes(ScopeAttribute::class)[0] ?? null)?->newInstance()->name; + $scopeName = ($ctx->reflection->getAttributes(Attribute\Scope::class)[0] ?? null)?->newInstance()->name; if ($scopeName !== null && $scopeName !== $this->scope->getScopeName()) { throw new BadScopeException($scopeName, $instance::class); } @@ -405,7 +415,7 @@ private function createInstance( } // Check scope name - $scope = ($reflection->getAttributes(ScopeAttribute::class)[0] ?? null)?->newInstance()->name; + $scope = ($reflection->getAttributes(Attribute\Scope::class)[0] ?? null)?->newInstance()->name; if ($scope !== null && $scope !== $this->scope->getScopeName()) { throw new BadScopeException($scope, $class); } @@ -503,16 +513,16 @@ private function isSingleton(Ctx $ctx): bool return true; } - return $ctx->reflection->getAttributes(Singleton::class) !== []; + return $ctx->reflection->getAttributes(Attribute\Singleton::class) !== []; } private function getFinalizer(Ctx $ctx, object $instance): ?callable { /** * @psalm-suppress UnnecessaryVarAnnotation - * @var Finalize|null $attribute + * @var Attribute\Finalize|null $attribute */ - $attribute = ($ctx->reflection->getAttributes(Finalize::class)[0] ?? null)?->newInstance(); + $attribute = ($ctx->reflection->getAttributes(Attribute\Finalize::class)[0] ?? null)?->newInstance(); if ($attribute === null) { return null; } diff --git a/src/Core/tests/Internal/Proxy/ProxyTest.php b/src/Core/tests/Internal/Proxy/ProxyTest.php index e1842b0f6..7e6ae64ed 100644 --- a/src/Core/tests/Internal/Proxy/ProxyTest.php +++ b/src/Core/tests/Internal/Proxy/ProxyTest.php @@ -4,9 +4,14 @@ namespace Spiral\Tests\Core\Internal\Proxy; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; use PHPUnit\Framework\TestCase; use Spiral\Core\Attribute\Proxy; +use Spiral\Core\Config; use Spiral\Core\Container; +use Spiral\Core\Exception\Container\ContainerException; +use Spiral\Core\Scope; use Spiral\Tests\Core\Internal\Proxy\Stub\EmptyInterface; use Spiral\Tests\Core\Internal\Proxy\Stub\MockInterface; use Spiral\Tests\Core\Internal\Proxy\Stub\MockInterfaceImpl; @@ -20,9 +25,7 @@ public static function interfacesProvider(): iterable // yield [EmptyInterface::class, 'empty']; } - /** - * @dataProvider interfacesProvider - */ + #[DataProvider('interfacesProvider')] public function testSimpleCases(string $interface, string $var): void { $root = new Container(); @@ -54,9 +57,7 @@ public function testMagicCallsOnNonMagicProxy(): void }); } - /** - * @dataProvider interfacesProvider - */ + #[DataProvider('interfacesProvider')] public function testExtraArguments(string $interface, string $var): void { $root = new Container(); @@ -76,9 +77,7 @@ public function testExtraArguments(string $interface, string $var): void }); } - /** - * @dataProvider interfacesProvider - */ + #[DataProvider('interfacesProvider')] public function testVariadic(string $interface, string $var): void { $root = new Container(); @@ -99,9 +98,7 @@ public function testVariadic(string $interface, string $var): void }); } - /** - * @dataProvider interfacesProvider - */ + #[DataProvider('interfacesProvider')] public function testReference(string $interface, string $var): void { $interface === EmptyInterface::class && self::markTestSkipped( @@ -121,9 +118,7 @@ public function testReference(string $interface, string $var): void }); } - /** - * @dataProvider interfacesProvider - */ + #[DataProvider('interfacesProvider')] public function testReturnReference(string $interface, string $var): void { $interface === EmptyInterface::class && self::markTestSkipped( @@ -146,9 +141,7 @@ public function testReturnReference(string $interface, string $var): void }); } - /** - * @dataProvider interfacesProvider - */ + #[DataProvider('interfacesProvider')] public function testReferenceVariadic(string $interface, string $var): void { $interface === EmptyInterface::class && self::markTestSkipped( @@ -188,4 +181,158 @@ public function testProxyToUnionType(): void self::assertInstanceOf(MockInterfaceImpl::class, $mock); } + + #[DataProvider('interfacesProvider')] + public function testProxyConfig(string $interface): void + { + $root = new Container(); + $root->getBinder('foo')->bindSingleton($interface, Stub\MockInterfaceImpl::class); + $root->bindSingleton($interface, new Config\Proxy($interface, true)); + + $proxy = $root->get($interface); + $this->assertInstanceOf($interface, $proxy); + $this->assertNotInstanceOf(MockInterfaceImpl::class, $proxy); + + $root->runScope(new Scope('foo'), static function (Container $container) use ($interface, $proxy) { + $proxy->bar(name: 'foo'); // Possible to run + self::assertSame('foo', $proxy->baz('foo', 42)); + self::assertSame(123, $proxy->qux(age: 123)); + self::assertSame(69, $proxy->space(test age: 69)); + + $real = $container->get($interface); + self::assertInstanceOf(MockInterfaceImpl::class, $real); + + $real->bar(name: 'foo'); // Possible to run + self::assertSame('foo', $real->baz('foo', 42)); + self::assertSame(123, $real->qux(age: 123)); + self::assertSame(69, $real->space(test age: 69)); + }); + } + + #[DataProvider('interfacesProvider')] + public function testProxyConfigOutOfProxyException(string $interface): void + { + $root = new Container(); + $root->getBinder('foo')->bindSingleton($interface, Stub\MockInterfaceImpl::class); + $root->bindSingleton($interface, new Config\Proxy($interface, true)); + + $this->assertInstanceOf($interface, $root->get($interface)); + $proxy = $root->get($interface); + + $this->expectException(ContainerException::class); + $this->expectExceptionMessage('Proxy is out of scope.'); + $proxy->bar(name: 'foo'); // Impossible to run + } + + #[DataProvider('interfacesProvider')] + #[WithoutErrorHandler] + public function testDeprecationProxyConfig(string $interface): void + { + \set_error_handler(static function (int $errno, string $error) use ($interface): void { + self::assertSame( + \sprintf('Using `%s` outside of the `foo` scope is deprecated and will be ' . + 'impossible in version 4.0.', $interface), + $error + ); + }); + + $root = new Container(); + $root->getBinder('foo')->bindSingleton($interface, Stub\MockInterfaceImpl::class); + $root->bindSingleton($interface, new Config\DeprecationProxy($interface, true, 'foo', '4.0')); + + $proxy = $root->get($interface); + $this->assertInstanceOf($interface, $proxy); + + $root->runScope(new Scope('foo'), static function () use ($proxy) { + $proxy->bar(name: 'foo'); // Possible to run + self::assertSame('foo', $proxy->baz('foo', 42)); + self::assertSame(123, $proxy->qux(age: 123)); + self::assertSame(69, $proxy->space(test age: 69)); + }); + + \restore_error_handler(); + } + + #[DataProvider('interfacesProvider')] + #[WithoutErrorHandler] + public function testDeprecationProxyConfigCustomMessage(string $interface): void + { + \set_error_handler(static function (int $errno, string $error) use ($interface): void { + self::assertSame(\sprintf('Using `%s` impossible', $interface), $error); + }); + + $root = new Container(); + $root->getBinder('foo')->bindSingleton($interface, Stub\MockInterfaceImpl::class); + $root->bindSingleton($interface, new Config\DeprecationProxy( + interface: $interface, + message: \sprintf('Using `%s` impossible', $interface), + )); + + $proxy = $root->get($interface); + $this->assertInstanceOf($interface, $proxy); + + $root->runScope(new Scope('foo'), static function () use ($proxy) { + $proxy->bar(name: 'foo'); // Possible to run + self::assertSame('foo', $proxy->baz('foo', 42)); + self::assertSame(123, $proxy->qux(age: 123)); + self::assertSame(69, $proxy->space(test age: 69)); + }); + + \restore_error_handler(); + } + + #[DataProvider('interfacesProvider')] + #[WithoutErrorHandler] + public function testDeprecationProxyConfigDontThrowIfNotConstructed(string $interface): void + { + \set_error_handler(static function (int $errno, string $error) use ($interface): void { + self::fail('Unexpected error: ' . $error); + }); + + $root = new Container(); + $root->getBinder('foo')->bindSingleton($interface, Stub\MockInterfaceImpl::class); + $root->bindSingleton($interface, new Config\DeprecationProxy($interface, true, 'foo', '4.0')); + + $root->runScope(new Scope('foo'), static function (Container $container) use ($interface) { + $proxy = $container->get($interface); + + $proxy->bar(name: 'foo'); // Possible to run + self::assertSame('foo', $proxy->baz('foo', 42)); + self::assertSame(123, $proxy->qux(age: 123)); + self::assertSame(69, $proxy->space(test age: 69)); + }); + + \restore_error_handler(); + } + + #[DataProvider('invalidDeprecationProxyArgsDataProvider')] + public function testDeprecationProxyConfigArgsRequiredException(string|null $scope, string|null $version): void + { + self::expectException(\InvalidArgumentException::class); + self::expectExceptionMessage('Scope and version or custom message must be provided.'); + + new Config\DeprecationProxy(interface: EmptyInterface::class, scope: $scope, version: $version); + } + + public function testProxyConfigToString(): void + { + $proxy = new Config\Proxy(EmptyInterface::class); + + $this->assertSame(\sprintf('Proxy to `%s`', EmptyInterface::class), (string) $proxy); + } + + public function testProxyConfigNotInterfaceException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Interface `%s` does not exist.', \stdClass::class)); + + new Config\Proxy(\stdClass::class); + } + + public static function invalidDeprecationProxyArgsDataProvider(): \Traversable + { + yield [null, '4.0']; + yield ['foo', null]; + yield [null, null]; + } }