diff --git a/src/Illuminate/Console/Concerns/CreatesMatchingTest.php b/src/Illuminate/Console/Concerns/CreatesMatchingTest.php index a360c281a98a..862cce38a141 100644 --- a/src/Illuminate/Console/Concerns/CreatesMatchingTest.php +++ b/src/Illuminate/Console/Concerns/CreatesMatchingTest.php @@ -2,11 +2,35 @@ namespace Illuminate\Console\Concerns; +use Illuminate\Support\Hooks\Hook; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputOption; trait CreatesMatchingTest { + /** + * Register "initialize" hook. + * + * @return \Illuminate\Support\Hooks\Hook + */ + public function registerCreatesMatchingTestInitializeHook(): Hook + { + return Hook::make('initialize', fn () => $this->addTestOptions()); + } + + /** + * Register "generate" hook. + * + * @return \Illuminate\Support\Hooks\Hook + */ + public function registerCreatesMatchingTestGenerateHook(): Hook + { + return Hook::make('generate', function ($name, $path) { + // We want to run test creation after generation, so we'll return a callback to execute at the end + return fn () => $this->handleTestCreation($path); + }); + } + /** * Add the standard command options for generating matching tests. * diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index 5c12e05ed094..f81cc45bc427 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -2,13 +2,15 @@ namespace Illuminate\Console; -use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Hookable; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputArgument; abstract class GeneratorCommand extends Command { + use Hookable; + /** * The filesystem instance. * @@ -109,11 +111,9 @@ public function __construct(Filesystem $files) { parent::__construct(); - if (in_array(CreatesMatchingTest::class, class_uses_recursive($this))) { - $this->addTestOptions(); - } - $this->files = $files; + + $this->runHooks('initialize'); } /** @@ -156,18 +156,16 @@ public function handle() return false; } - // Next, we will generate the path to the location where this class' file should get + // Finally, we will generate the path to the location where this class' file should get // written. Then, we will build the class and make the proper replacements on the // stub files so that it gets the correctly formatted namespace and class name. - $this->makeDirectory($path); - - $this->files->put($path, $this->sortImports($this->buildClass($name))); + $this->runHooks('generate', [$name, $path], function () use ($name, $path) { + $this->makeDirectory($path); - $this->info($this->type.' created successfully.'); + $this->files->put($path, $this->sortImports($this->buildClass($name))); - if (in_array(CreatesMatchingTest::class, class_uses_recursive($this))) { - $this->handleTestCreation($path); - } + $this->info($this->type.' created successfully.'); + }); } /** diff --git a/src/Illuminate/Contracts/Support/Hook.php b/src/Illuminate/Contracts/Support/Hook.php new file mode 100644 index 000000000000..f9b214926702 --- /dev/null +++ b/src/Illuminate/Contracts/Support/Hook.php @@ -0,0 +1,57 @@ + Model::withoutEvents($callback); + return Hook::make('run', function () { + if (! $dispatcher = Model::getEventDispatcher()) { + return null; + } + + Model::setEventDispatcher(new NullDispatcher($dispatcher)); + + return fn () => Model::setEventDispatcher($dispatcher); + }); } } diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 48d1d3f1b2fa..c134fd3af876 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -18,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Arr; use Illuminate\Support\Collection as BaseCollection; +use Illuminate\Support\Hookable; +use Illuminate\Support\Hooks\ConventionalHook; use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; use JsonSerializable; @@ -32,7 +34,8 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt Concerns\HasTimestamps, Concerns\HidesAttributes, Concerns\GuardsAttributes, - ForwardsCalls; + ForwardsCalls, + Hookable; /** * The connection name for the model. @@ -252,6 +255,26 @@ protected static function boot() static::bootTraits(); } + /** + * Register a conventions-based hook for boot[trait name] methods. + * + * @return \Illuminate\Support\Hooks\ConventionalHook + */ + public static function registerBootHook(): ConventionalHook + { + return new ConventionalHook('boot'); + } + + /** + * Register a conventions-based hook for initialize[trait name] methods. + * + * @return \Illuminate\Support\Hooks\ConventionalHook + */ + public static function registerInitializeHook(): ConventionalHook + { + return new ConventionalHook('initialize'); + } + /** * Boot all of the bootable traits on the model. * @@ -259,29 +282,7 @@ protected static function boot() */ protected static function bootTraits() { - $class = static::class; - - $booted = []; - - static::$traitInitializers[$class] = []; - - foreach (class_uses_recursive($class) as $trait) { - $method = 'boot'.class_basename($trait); - - if (method_exists($class, $method) && ! in_array($method, $booted)) { - forward_static_call([$class, $method]); - - $booted[] = $method; - } - - if (method_exists($class, $method = 'initialize'.class_basename($trait))) { - static::$traitInitializers[$class][] = $method; - - static::$traitInitializers[$class] = array_unique( - static::$traitInitializers[$class] - ); - } - } + static::runStaticHooks('boot'); } /** @@ -291,9 +292,7 @@ protected static function bootTraits() */ protected function initializeTraits() { - foreach (static::$traitInitializers[static::class] as $method) { - $this->{$method}(); - } + $this->runHooks('initialize'); } /** diff --git a/src/Illuminate/Database/Seeder.php b/src/Illuminate/Database/Seeder.php index 1a7a12e1914d..bcdac810ccb9 100755 --- a/src/Illuminate/Database/Seeder.php +++ b/src/Illuminate/Database/Seeder.php @@ -4,12 +4,14 @@ use Illuminate\Console\Command; use Illuminate\Container\Container; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Support\Arr; +use Illuminate\Support\Hookable; use InvalidArgumentException; abstract class Seeder { + use Hookable; + /** * The container instance. * @@ -171,16 +173,10 @@ public function __invoke(array $parameters = []) throw new InvalidArgumentException('Method [run] missing from '.get_class($this)); } - $callback = fn () => isset($this->container) - ? $this->container->call([$this, 'run'], $parameters) - : $this->run(...$parameters); - - $uses = array_flip(class_uses_recursive(static::class)); - - if (isset($uses[WithoutModelEvents::class])) { - $callback = $this->withoutModelEvents($callback); - } - - return $callback(); + return $this->runHooks('run', $this, function () use ($parameters) { + return isset($this->container) + ? $this->container->call([$this, 'run'], $parameters) + : $this->run(...$parameters); + }); } } diff --git a/src/Illuminate/Foundation/Testing/DatabaseMigrations.php b/src/Illuminate/Foundation/Testing/DatabaseMigrations.php index 10a3a7300af6..75eaa37d6141 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseMigrations.php +++ b/src/Illuminate/Foundation/Testing/DatabaseMigrations.php @@ -4,11 +4,22 @@ use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Testing\Traits\CanConfigureMigrationCommands; +use Illuminate\Support\Hooks\Hook; trait DatabaseMigrations { use CanConfigureMigrationCommands; + /** + * Register test case hook. + * + * @return \Illuminate\Support\Hooks\Hook + */ + public function registerDatabaseMigrationsHook(): Hook + { + return new Hook('setUp', fn () => $this->runDatabaseMigrations(), 55); + } + /** * Define hooks to migrate the database before and after each test. * diff --git a/src/Illuminate/Foundation/Testing/DatabaseTransactions.php b/src/Illuminate/Foundation/Testing/DatabaseTransactions.php index e162e188a4ed..97e695162f8f 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseTransactions.php +++ b/src/Illuminate/Foundation/Testing/DatabaseTransactions.php @@ -2,8 +2,20 @@ namespace Illuminate\Foundation\Testing; +use Illuminate\Support\Hooks\Hook; + trait DatabaseTransactions { + /** + * Register test case hook. + * + * @return \Illuminate\Support\Hooks\Hook + */ + public function registerDatabaseTransactionsHook(): Hook + { + return new Hook('setUp', fn () => $this->beginDatabaseTransaction(), 60); + } + /** * Handle database transactions on the specified connections. * diff --git a/src/Illuminate/Foundation/Testing/RefreshDatabase.php b/src/Illuminate/Foundation/Testing/RefreshDatabase.php index 55ff86d965d6..8335cd357420 100644 --- a/src/Illuminate/Foundation/Testing/RefreshDatabase.php +++ b/src/Illuminate/Foundation/Testing/RefreshDatabase.php @@ -4,11 +4,22 @@ use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Testing\Traits\CanConfigureMigrationCommands; +use Illuminate\Support\Hooks\Hook; trait RefreshDatabase { use CanConfigureMigrationCommands; + /** + * Register test case hook. + * + * @return \Illuminate\Support\Hooks\Hook + */ + public function registerRefreshDatabaseHook(): Hook + { + return new Hook('setUp', fn () => $this->refreshDatabase(), 50); + } + /** * Define hooks to migrate the database before and after each test. * diff --git a/src/Illuminate/Foundation/Testing/TestCase.php b/src/Illuminate/Foundation/Testing/TestCase.php index 35af3d49bc0e..cdf1a52645d4 100644 --- a/src/Illuminate/Foundation/Testing/TestCase.php +++ b/src/Illuminate/Foundation/Testing/TestCase.php @@ -9,6 +9,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\ParallelTesting; +use Illuminate\Support\Hookable; use Illuminate\Support\Str; use Mockery; use Mockery\Exception\InvalidCountException; @@ -27,7 +28,8 @@ abstract class TestCase extends BaseTestCase Concerns\InteractsWithSession, Concerns\InteractsWithTime, Concerns\InteractsWithViews, - Concerns\MocksApplicationServices; + Concerns\MocksApplicationServices, + Hookable; /** * The Illuminate application instance. @@ -88,7 +90,7 @@ protected function setUp(): void ParallelTesting::callSetUpTestCaseCallbacks($this); } - $this->setUpTraits(); + $this->runHooks('setUp'); foreach ($this->afterApplicationCreatedCallbacks as $callback) { $callback(); @@ -109,42 +111,6 @@ protected function refreshApplication() $this->app = $this->createApplication(); } - /** - * Boot the testing helper traits. - * - * @return array - */ - protected function setUpTraits() - { - $uses = array_flip(class_uses_recursive(static::class)); - - if (isset($uses[RefreshDatabase::class])) { - $this->refreshDatabase(); - } - - if (isset($uses[DatabaseMigrations::class])) { - $this->runDatabaseMigrations(); - } - - if (isset($uses[DatabaseTransactions::class])) { - $this->beginDatabaseTransaction(); - } - - if (isset($uses[WithoutMiddleware::class])) { - $this->disableMiddlewareForAllTests(); - } - - if (isset($uses[WithoutEvents::class])) { - $this->disableEventsForAllTests(); - } - - if (isset($uses[WithFaker::class])) { - $this->setUpFaker(); - } - - return $uses; - } - /** * Clean up the testing environment before the next test. * diff --git a/src/Illuminate/Foundation/Testing/WithFaker.php b/src/Illuminate/Foundation/Testing/WithFaker.php index cd276fbd4eb0..9799813914f7 100644 --- a/src/Illuminate/Foundation/Testing/WithFaker.php +++ b/src/Illuminate/Foundation/Testing/WithFaker.php @@ -4,6 +4,7 @@ use Faker\Factory; use Faker\Generator; +use Illuminate\Support\Hooks\Hook; trait WithFaker { @@ -14,6 +15,16 @@ trait WithFaker */ protected $faker; + /** + * Register test case hook. + * + * @return \Illuminate\Support\Hooks\Hook + */ + public function registerWithFakerHook(): Hook + { + return new Hook('setUp', fn () => $this->setUpFaker(), 75); + } + /** * Setup up the Faker instance. * diff --git a/src/Illuminate/Foundation/Testing/WithoutEvents.php b/src/Illuminate/Foundation/Testing/WithoutEvents.php index fa5df3ce8f5b..bc9652416f7a 100644 --- a/src/Illuminate/Foundation/Testing/WithoutEvents.php +++ b/src/Illuminate/Foundation/Testing/WithoutEvents.php @@ -3,9 +3,20 @@ namespace Illuminate\Foundation\Testing; use Exception; +use Illuminate\Support\Hooks\Hook; trait WithoutEvents { + /** + * Register test case hook. + * + * @return \Illuminate\Support\Hooks\Hook + */ + public function registerWithoutEventsHook(): Hook + { + return new Hook('setUp', fn () => $this->disableEventsForAllTests(), 70); + } + /** * Prevent all event handles from being executed. * diff --git a/src/Illuminate/Foundation/Testing/WithoutMiddleware.php b/src/Illuminate/Foundation/Testing/WithoutMiddleware.php index 269b532d3150..ebb5a9331dcd 100644 --- a/src/Illuminate/Foundation/Testing/WithoutMiddleware.php +++ b/src/Illuminate/Foundation/Testing/WithoutMiddleware.php @@ -3,9 +3,20 @@ namespace Illuminate\Foundation\Testing; use Exception; +use Illuminate\Support\Hooks\Hook; trait WithoutMiddleware { + /** + * Register test case hook. + * + * @return \Illuminate\Support\Hooks\Hook + */ + public function registerWithoutMiddlewareHook(): Hook + { + return new Hook('setUp', fn () => $this->disableMiddlewareForAllTests(), 65); + } + /** * Prevent all middleware from being executed for this test class. * diff --git a/src/Illuminate/Support/Hookable.php b/src/Illuminate/Support/Hookable.php new file mode 100644 index 000000000000..db97641ff7b9 --- /dev/null +++ b/src/Illuminate/Support/Hookable.php @@ -0,0 +1,35 @@ +run($name, static::class, $arguments, $callback); + } + + /** + * Run hooks non-statically. + * + * @param string $name + * @param array $arguments + * @param \Closure|null $callback + * @return mixed + */ + protected function runHooks($name, $arguments = [], Closure $callback = null) + { + return HookCollection::for(static::class)->run($name, $this, $arguments, $callback); + } +} diff --git a/src/Illuminate/Support/Hooks/ConventionalHook.php b/src/Illuminate/Support/Hooks/ConventionalHook.php new file mode 100644 index 000000000000..707950ba6c7d --- /dev/null +++ b/src/Illuminate/Support/Hooks/ConventionalHook.php @@ -0,0 +1,68 @@ +prefix.class_basename($trait); + + if (! in_array($method, $executed) && method_exists($instance, $method)) { + if (is_object($instance)) { + $instance->$method(...$arguments); + } else { + forward_static_call_array([$instance, $method], $arguments); + } + + $executed[] = $method; + } + } + } + + /** + * @inheritdoc + */ + public function cleanup($instance, array $arguments = []) + { + // No cleanup is necessary + } + + /** + * @inheritdoc + */ + public function getName() + { + return $this->prefix; + } + + /** + * @inheritDoc + */ + public function getPriority() + { + return $this->priority; + } +} diff --git a/src/Illuminate/Support/Hooks/Hook.php b/src/Illuminate/Support/Hooks/Hook.php new file mode 100644 index 000000000000..6ad3f48daa7d --- /dev/null +++ b/src/Illuminate/Support/Hooks/Hook.php @@ -0,0 +1,124 @@ +cleanup = null; + + $result = $this->runCallback($this->callback, $instance, $arguments); + + if ($result instanceof Closure) { + $this->cleanup = $result; + } + } + + /** + * @inheritdoc + */ + public function cleanup($instance, array $arguments = []) + { + if (is_null($this->cleanup)) { + return; + } + + $this->runCallback($this->cleanup, $instance, $arguments); + } + + /** + * @inheritdoc + */ + public function getName() + { + return $this->name; + } + + /** + * @inheritDoc + */ + public function getPriority() + { + return $this->priority; + } + + /** + * Run a callback that may or may not be static. + * + * @param Closure $callback + * @param object|string $instance + * @param array $arguments + * @return mixed + */ + protected function runCallback($callback, $instance, $arguments = []) + { + return is_object($instance) + ? $callback->call($instance, $arguments) + : $callback(...$arguments); + } +} diff --git a/src/Illuminate/Support/Hooks/HookCollection.php b/src/Illuminate/Support/Hooks/HookCollection.php new file mode 100644 index 000000000000..c17bf55975aa --- /dev/null +++ b/src/Illuminate/Support/Hooks/HookCollection.php @@ -0,0 +1,147 @@ +getMethods()) + ->map(fn ($method) => static::hookForMethod($method)) + ->filter(); + } + + /** + * Load a hook for a given method based on its return type. + * + * @param \ReflectionMethod $method + * @return \Illuminate\Support\Hooks\PendingHook|null + */ + protected static function hookForMethod(ReflectionMethod $method): ?PendingHook + { + if (static::methodReturnsHook($method)) { + return new PendingHook(function ($instance = null) use ($method) { + return $method->invoke($instance); + }, $method->isStatic()); + } + + return null; + } + + /** + * Determine if a class method returns a Hook instance. + * + * @param \ReflectionMethod $method + * @return bool + */ + protected static function methodReturnsHook(ReflectionMethod $method): bool + { + return $method->getReturnType() instanceof ReflectionNamedType + && is_a($method->getReturnType()->getName(), Hook::class, true); + } + + /** + * Run all the hooks that match the given name and instance. + * + * @param string $name + * @param object|string $instance + * @param array $arguments + * @param \Closure|null $callback + * @return mixed + */ + public function run($name, $instance, $arguments = [], Closure $callback = null) + { + $arguments = Arr::wrap($arguments); + + $hooks = $this->onlyStatic(! is_object($instance)) + ->resolve($instance) + ->filter(fn (Hook $hook) => $hook->getName() === $name) + ->sortBy(fn (Hook $hook) => $hook->getPriority()); + + $hooks->each(fn (Hook $hook) => $hook->run($instance, $arguments)); + + try { + return $callback ? $callback() : null; + } finally { + $hooks->reverse()->each(fn (Hook $hook) => $hook->cleanup($instance, $arguments)); + } + } + + /** + * Filter collection to only static hooks. + * + * @param bool $onlyStatic + * @return $this + */ + public function onlyStatic($onlyStatic = true): self + { + if ($onlyStatic) { + return $this->where('isStatic'); + } + + return $this; + } + + /** + * Map collection to resolved Hook instances. + * + * @param object|string $instance + * @return $this + */ + public function resolve($instance): self + { + return $this->map(function ($hook) use ($instance) { + return $hook instanceof PendingHook + ? $hook->resolve($instance) + : $hook; + }); + } +} diff --git a/src/Illuminate/Support/Hooks/PendingHook.php b/src/Illuminate/Support/Hooks/PendingHook.php new file mode 100644 index 000000000000..2d4c19c85a41 --- /dev/null +++ b/src/Illuminate/Support/Hooks/PendingHook.php @@ -0,0 +1,89 @@ +isStatic) { + return $this->getHook($instance); + } + + throw new RuntimeException('Trying to resolve a non-static hook statically.'); + } + + /** + * Resolve or return the already resolved Hook. + * + * @param object|string|null $instance + * @return \Illuminate\Support\Hooks\Hook + */ + protected function getHook($instance = null): Hook + { + if (is_null($this->hook)) { + try { + $this->hook = $this->isStatic + ? call_user_func($this->callback) + : $this->callback->call($instance); + } catch (BadMethodCallException $exception) { + $this->hook = new class implements Hook + { + public function run($instance, array $arguments = []) + { + throw new RuntimeException('Unexpected hook call from mock object'); + } + + public function cleanup($instance, array $arguments = []) + { + throw new RuntimeException('Unexpected hook call from mock object'); + } + + public function getName() + { + return Str::random(); + } + + public function getPriority() + { + return PHP_INT_MAX; + } + }; + } + } + + return $this->hook; + } +}