diff --git a/src/Phaseolies/Application.php b/src/Phaseolies/Application.php index b6cfd32e..a07ed08c 100644 --- a/src/Phaseolies/Application.php +++ b/src/Phaseolies/Application.php @@ -4,6 +4,7 @@ use Phaseolies\Auth\ActorManager; use Phaseolies\Support\Router; +use Phaseolies\Providers\GhostableProvider; use Phaseolies\Providers\ServiceProvider; use Phaseolies\Http\DispatchResult; use Phaseolies\Http\Response; @@ -127,6 +128,34 @@ class Application extends Container */ protected $serviceProviders = []; + /** + * The queued ghost providers keyed by class name. + * + * @var array, ServiceProvider> + */ + protected array $ghostProviders = []; + + /** + * Map of service identifiers to ghost provider classes. + * + * @var array> + */ + protected array $ghostServices = []; + + /** + * Tracks ghost providers that have already been loaded. + * + * @var array, true> + */ + protected array $loadedGhostProviders = []; + + /** + * Tracks ghost providers currently being loaded. + * + * @var array, true> + */ + protected array $loadingGhostProviders = []; + /** * Indicates if the providers has been booted * @@ -134,6 +163,20 @@ class Application extends Container */ protected $providersBooted = false; + /** + * Indicates if the application is currently booting eager providers. + * + * @var bool + */ + protected bool $bootingProviders = false; + + /** + * Tracks providers whose boot method has already run. + * + * @var array, true> + */ + protected array $bootedProviderClasses = []; + /** * @var Router */ @@ -304,13 +347,64 @@ protected function registerProviders(array $providers = []): void { foreach ($providers as $provider) { $providerInstance = new $provider($this); + if ($providerInstance instanceof ServiceProvider) { - $providerInstance->register(); - $this->serviceProviders[] = $providerInstance; + if ($this->shouldQueueGhostProvider($providerInstance)) { + $this->queueGhostProvider($providerInstance); + continue; + } + + $this->registerProviderInstance($providerInstance); } } } + /** + * Determine if the provider should be queued as a ghost provider + * + * @param ServiceProvider $providerInstance + * @return bool + */ + protected function shouldQueueGhostProvider(ServiceProvider $providerInstance): bool + { + return !$this->runningInConsole() && $providerInstance instanceof GhostableProvider; + } + + /** + * Register and track an eager provider instance. + * + * @param ServiceProvider $providerInstance + * @return void + */ + protected function registerProviderInstance(ServiceProvider $providerInstance): void + { + $providerInstance->register(); + + $this->serviceProviders[] = $providerInstance; + } + + /** + * Queue a ghost provider until one of its services is requested. + * + * @param ServiceProvider $providerInstance + * @return void + */ + protected function queueGhostProvider(ServiceProvider $providerInstance): void + { + /** @var GhostableProvider $providerInstance */ + $providerClass = $providerInstance::class; + + $this->ghostProviders[$providerClass] = $providerInstance; + + foreach ($providerInstance->ghosts() as $ghost) { + if (!is_string($ghost) || $ghost === '') { + continue; + } + + $this->ghostServices[$ghost] = $providerClass; + } + } + /** * Boots core service providers. * @@ -334,8 +428,14 @@ protected function bootCoreProviders(): self */ protected function bootProviders(): void { - foreach ($this->serviceProviders as $providerInstance) { - $providerInstance->boot(); + $this->bootingProviders = true; + + try { + foreach ($this->serviceProviders as $providerInstance) { + $this->bootProviderInstance($providerInstance); + } + } finally { + $this->bootingProviders = false; } $this->bootstrap(); @@ -730,6 +830,81 @@ public function getProvider(string $provider): ?ServiceProvider return null; } + /** + * Determine if the application has a binding or queued ghost for the given service. + * + * @param string $key + * @return bool + */ + public function has(string $key): bool + { + return parent::has($key) || isset($this->ghostServices[$key]); + } + + /** + * Load a queued ghost provider when one of its services is requested. + * + * @param string $abstract + * @return bool + */ + public function loadGhostProvider(string $abstract): bool + { + $providerClass = $this->ghostServices[$abstract] ?? null; + + if ($providerClass === null) { + return false; + } + + if (isset($this->loadedGhostProviders[$providerClass]) || isset($this->loadingGhostProviders[$providerClass])) { + return true; + } + + $providerInstance = $this->ghostProviders[$providerClass] ?? null; + + if (!$providerInstance instanceof ServiceProvider) { + return false; + } + + $this->loadingGhostProviders[$providerClass] = true; + + foreach (($providerInstance instanceof GhostableProvider ? $providerInstance->ghosts() : []) as $ghost) { + unset($this->ghostServices[$ghost]); + } + + try { + $this->registerProviderInstance($providerInstance); + + if ($this->providersBooted || $this->bootingProviders) { + $this->bootProviderInstance($providerInstance); + } + + $this->loadedGhostProviders[$providerClass] = true; + + return true; + } finally { + unset($this->loadingGhostProviders[$providerClass], $this->ghostProviders[$providerClass]); + } + } + + /** + * Boot a provider instance once. + * + * @param ServiceProvider $providerInstance + * @return void + */ + protected function bootProviderInstance(ServiceProvider $providerInstance): void + { + $providerClass = $providerInstance::class; + + if (isset($this->bootedProviderClasses[$providerClass])) { + return; + } + + $providerInstance->boot(); + + $this->bootedProviderClasses[$providerClass] = true; + } + /** * Determine if the application is in the given environment. * diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index 50c77b02..33e5ac1c 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -163,6 +163,14 @@ public function get(string $abstract, array $parameters = []): mixed throw new \RuntimeException("Circular dependency detected while resolving [{$abstract}]"); } + if ( + !isset(self::$bindings[$abstract]) && + !array_key_exists($abstract, self::$instances) && + method_exists($this, 'loadGhostProvider') + ) { + $this->loadGhostProvider($abstract); + } + $this->resolving[$abstract] = true; try { diff --git a/src/Phaseolies/Providers/CacheServiceProvider.php b/src/Phaseolies/Providers/CacheServiceProvider.php index ce17c845..113343df 100644 --- a/src/Phaseolies/Providers/CacheServiceProvider.php +++ b/src/Phaseolies/Providers/CacheServiceProvider.php @@ -8,10 +8,11 @@ use Symfony\Component\Cache\Adapter\ApcuAdapter; use Psr\SimpleCache\CacheInterface; use Phaseolies\Providers\ServiceProvider; +use Phaseolies\Providers\GhostableProvider; use Phaseolies\Cache\CacheStore; use Phaseolies\Cache\IncrementableCacheInterface; -class CacheServiceProvider extends ServiceProvider +class CacheServiceProvider extends ServiceProvider implements GhostableProvider { /** * @var \Closure[] Custom adapter factories @@ -158,4 +159,19 @@ public function boot() { // } + + /** + * Get the services that should ghost-load this provider. + * + * @return array + */ + public function ghosts(): array + { + return [ + CacheStore::class, + IncrementableCacheInterface::class, + CacheInterface::class, + 'cache', + ]; + } } diff --git a/src/Phaseolies/Providers/GhostableProvider.php b/src/Phaseolies/Providers/GhostableProvider.php new file mode 100644 index 00000000..b878c16d --- /dev/null +++ b/src/Phaseolies/Providers/GhostableProvider.php @@ -0,0 +1,13 @@ + + */ + public function ghosts(): array; +} diff --git a/src/Phaseolies/Providers/LanguageServiceProvider.php b/src/Phaseolies/Providers/LanguageServiceProvider.php index 2640cbcb..4b29f21e 100644 --- a/src/Phaseolies/Providers/LanguageServiceProvider.php +++ b/src/Phaseolies/Providers/LanguageServiceProvider.php @@ -6,8 +6,9 @@ use Phaseolies\Translation\FileLoader; use Phaseolies\Translation\Translator; use Phaseolies\Providers\ServiceProvider; +use Phaseolies\Providers\GhostableProvider; -class LanguageServiceProvider extends ServiceProvider +class LanguageServiceProvider extends ServiceProvider implements GhostableProvider { /** * Register the service provider. @@ -55,4 +56,17 @@ public function boot() { Lang::setFacadeApplication($this->app); } + + /** + * Get the services that should ghost-load this provider. + * + * @return array + */ + public function ghosts(): array + { + return [ + 'translation.loader', + 'translator', + ]; + } } diff --git a/src/Phaseolies/Providers/RateLimiterServiceProvider.php b/src/Phaseolies/Providers/RateLimiterServiceProvider.php index bc5d206c..ef546c4c 100644 --- a/src/Phaseolies/Providers/RateLimiterServiceProvider.php +++ b/src/Phaseolies/Providers/RateLimiterServiceProvider.php @@ -3,10 +3,11 @@ namespace Phaseolies\Providers; use Phaseolies\Providers\ServiceProvider; +use Phaseolies\Providers\GhostableProvider; use Phaseolies\Cache\IncrementableCacheInterface; use Phaseolies\Cache\RateLimiter; -class RateLimiterServiceProvider extends ServiceProvider +class RateLimiterServiceProvider extends ServiceProvider implements GhostableProvider { /** * Register the service provider. @@ -29,4 +30,16 @@ public function boot() { // } + + /** + * Get the services that should ghost-load this provider. + * + * @return array + */ + public function ghosts(): array + { + return [ + RateLimiter::class, + ]; + } } diff --git a/tests/Application/ApplicationTest.php b/tests/Application/ApplicationTest.php index 4e26e9aa..35ed2fc5 100644 --- a/tests/Application/ApplicationTest.php +++ b/tests/Application/ApplicationTest.php @@ -16,6 +16,7 @@ use Phaseolies\Support\Router; use Phaseolies\Support\Session; use Phaseolies\Console\Console; +use Tests\Application\Mock\Providers\GhostableTestProvider; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use Phaseolies\Support\StringService; @@ -62,6 +63,7 @@ final class ApplicationTest extends TestCase protected function setUp(): void { $container = new Container(); + $container->flush(); $container->bind('config', fn() => Config::class); // Create a temporary directory structure @@ -96,6 +98,9 @@ protected function setUp(): void protected function tearDown(): void { + GhostableTestProvider::resetState(); + $this->app->flush(); + Container::forgetInstance(); $this->deleteDirectory($this->tempBasePath); } @@ -216,6 +221,71 @@ public function testCoreProvidersAreLoaded(): void $this->assertContains(\Phaseolies\Providers\LanguageServiceProvider::class, $providers); } + public function testGhostableProvidersAreQueuedOutsideConsole(): void + { + GhostableTestProvider::resetState(); + + $this->setProtectedProperty($this->app, 'isRunningInConsole', false); + + $this->callProtectedMethod($this->app, 'registerProviders', [ + [GhostableTestProvider::class], + ]); + + $this->assertTrue($this->app->has('ghost.service')); + $this->assertSame([], $this->app->getProviders()); + $this->assertSame(0, GhostableTestProvider::$registerCount); + } + + public function testGhostServiceResolutionLoadsQueuedProviderAndBootsIt(): void + { + GhostableTestProvider::resetState(); + + $this->setProtectedProperty($this->app, 'isRunningInConsole', false); + $this->setProtectedProperty($this->app, 'providersBooted', true); + + $this->callProtectedMethod($this->app, 'registerProviders', [ + [GhostableTestProvider::class], + ]); + + $this->assertSame('ghost-value', $this->app->make('ghost.service')); + $this->assertSame('booted', $this->app->make('ghost.booted')); + $this->assertSame(1, GhostableTestProvider::$registerCount); + $this->assertSame(1, GhostableTestProvider::$bootCount); + $this->assertInstanceOf( + GhostableTestProvider::class, + $this->app->getProvider(GhostableTestProvider::class) + ); + } + + public function testGhostServiceCanBeResolvedDuringProviderRegistration(): void + { + GhostableTestProvider::resetState(); + GhostableTestProvider::$resolveDuringRegister = true; + + $this->setProtectedProperty($this->app, 'isRunningInConsole', false); + + $this->callProtectedMethod($this->app, 'registerProviders', [ + [GhostableTestProvider::class], + ]); + + $this->assertSame('ghost-value', $this->app->make('ghost.service')); + $this->assertSame(1, GhostableTestProvider::$registerCount); + } + + public function testGhostableProvidersRemainEagerInConsole(): void + { + GhostableTestProvider::resetState(); + + $this->setProtectedProperty($this->app, 'isRunningInConsole', true); + + $this->callProtectedMethod($this->app, 'registerProviders', [ + [GhostableTestProvider::class], + ]); + + $this->assertSame(1, GhostableTestProvider::$registerCount); + $this->assertCount(1, $this->app->getProviders()); + } + public function testSingletonBindings(): void { $this->callProtectedMethod($this->app, 'bindSingletonClasses'); diff --git a/tests/Application/Mock/Providers/GhostableTestProvider.php b/tests/Application/Mock/Providers/GhostableTestProvider.php new file mode 100644 index 00000000..b7f1cfd4 --- /dev/null +++ b/tests/Application/Mock/Providers/GhostableTestProvider.php @@ -0,0 +1,45 @@ +app->singleton('ghost.service', fn() => 'ghost-value'); + + if (self::$resolveDuringRegister) { + $this->app->make('ghost.service'); + } + } + + public function boot(): void + { + self::$bootCount++; + + $this->app->singleton('ghost.booted', fn() => 'booted'); + } + + public function ghosts(): array + { + return [ + 'ghost.service', + ]; + } +}