From a0a5c0693db61c0c12e9669bb5cce86229a79163 Mon Sep 17 00:00:00 2001 From: Peter Elmered Date: Mon, 25 Mar 2024 15:18:20 +0100 Subject: [PATCH 01/26] First pass for domain autoloader --- config/ddd.php | 35 ++++ src/LaravelDDDServiceProvider.php | 6 + src/Support/DomainAutoloader.php | 154 ++++++++++++++++++ src/Support/Path.php | 9 + .../AutoloadServiceProviderTest.php | 8 + 5 files changed, 212 insertions(+) create mode 100644 src/Support/DomainAutoloader.php create mode 100644 tests/Autoloader/AutoloadServiceProviderTest.php diff --git a/config/ddd.php b/config/ddd.php index e7dd5bf..a763011 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -44,8 +44,43 @@ 'view_models' => 'ViewModels', 'value_objects' => 'ValueObjects', 'actions' => 'Actions', + 'factories' => 'Database\Factories', ], + + 'autoload' => [ + /* + | Autoload service providers from the domain namespace. + | By default, it loads any file that ends with 'ServiceProvider.php' inside your domain. + | For example: Domain/Invoicing/Providers/InvoicingServiceProvider.php or Domain/Invoicing/InvoicingServiceProvider.php + */ + // To customize the pattern, you can use a glob pattern like '*/Providers/*.php' + 'service_providers' => '*/*ServiceProvider.php', + + /* + | Autoload commands from the domain namespace. + | By default, it loads any file inside the /Commands folder that ends '.php', extends Illuminate\Console\Command and is not abstract. + | For example: Domain/Invoicing/Commands/CreateInvoiceCommand.php + */ + // To customize the pattern, you can use a glob pattern like '*/Commands/*.php' + 'commands' => '*/Commands/*.php', + + /* + | Autoload policies from the domain namespace. + | By default, it loads any file inside the /Policies folder that ends 'Policy.php' and is not abstract. + | For example: Domain/Invoicing/Policies/InvoicePolicy.php + */ + 'policies' => 'Policies\\{model}Policy', + + /* + | Autoload factories from the domain namespace. + | By default, it loads any file inside the /Database/Factories folder that ends 'Factory.php' and is not abstract. + | For example: Domain/Invoicing/Database/Factories/InvoiceFactory.php + */ + 'factories' => 'Database\\Factories\\{model}Factory', + ], + + /* |-------------------------------------------------------------------------- | Base Model diff --git a/src/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php index 5827049..dbebc0c 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -11,6 +11,7 @@ use Lunarstorm\LaravelDDD\Commands\MakeModel; use Lunarstorm\LaravelDDD\Commands\MakeValueObject; use Lunarstorm\LaravelDDD\Commands\MakeViewModel; +use Lunarstorm\LaravelDDD\Support\DomainAutoloader; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -45,4 +46,9 @@ public function packageBooted() $this->package->basePath('/../stubs') => resource_path("stubs/{$this->package->shortName()}"), ], "{$this->package->shortName()}-stubs"); } + + public function packageRegistered() + { + (new DomainAutoloader())->autoload(); + } } diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php new file mode 100644 index 0000000..1b00472 --- /dev/null +++ b/src/Support/DomainAutoloader.php @@ -0,0 +1,154 @@ +config = config('ddd'); + } + + public function autoload(): void + { + if(isset($this->config['autoload.service_providers'])) { + $this->registerDomainServiceProviders($this->config['autoload.service_providers']); + } + if(isset($this->config['autoload.commands'])) { + $this->registerDomainCommands($this->config['autoload.commands']); + } + if(isset($this->config['autoload.policies'])) { + $this->registerPolicies($this->config['autoload.policies']); + } + if(isset($this->config['autoload.factories'])) { + $this->registerFactories($this->config['autoload.factories']); + } + } + + protected function registerDomainServiceProviders(bool|string $domainPath = null): void + { + $domainPath = is_string($domainPath) ? $domainPath : '*/*ServiceProvider.php'; + + $serviceProviders = Cache::rememberForever('ddd-domain-service-providers', static function () use ($domainPath){ + return Arr::map( + glob(base_path(DomainResolver::getConfiguredDomainPath().'/'.$domainPath)), + (static function ($serviceProvider) { + return Path::filePathToNamespace( + $serviceProvider, + DomainResolver::getConfiguredDomainPath(), + DomainResolver::getConfiguredDomainNamespace() + ); + })); + }); + + $app = app(); + foreach ($serviceProviders as $serviceProvider) { + $app->register($serviceProvider); + } + } + + protected function registerDomainCommands(bool|string $domainPath = null): void + { + $domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php'; + $commands = Cache::rememberForever('ddd-domain-commands', static function () use ($domainPath){ + $commands = Arr::map( + glob(base_path(DomainResolver::getConfiguredDomainPath().'/'.$domainPath)), + static function ($command) { + return Path::filePathToNamespace( + $command, + DomainResolver::getConfiguredDomainPath(), + DomainResolver::getConfiguredDomainNamespace() + ); + }); + + // Filter out invalid commands (Abstract classes and classes not extending Illuminate\Console\Command) + return Arr::where($commands, static function($command) { + if (is_subclass_of($command, Command::class) && + ! (new ReflectionClass($command))->isAbstract()) { + ConsoleApplication::starting(static function ($artisan) use ($command): void { + $artisan->resolve($command); + }); + } + }); + }); + ConsoleApplication::starting(static function ($artisan) use ($commands): void { + foreach ($commands as $command) { + $artisan->resolve($command); + } + }); + } + + protected function registerPolicies(bool|string $domainPath = null): void + { + $domainPath = is_string($domainPath) ? $domainPath : 'Policies\\{model}Policy'; + + Gate::guessPolicyNamesUsing(static function (string $modelClass) use ($domainPath): ?string { + + [$domain, $model] = static::extractDomainAndModelFromModelNamespace($modelClass); + + if (is_null($domain)) { + return null; + } + + $policy = DomainResolver::getConfiguredDomainNamespace().'\\'.$domain.'\\'.str_replace('{model}', $model, $domainPath); + + return $policy; + }); + } + + protected function registerFactories(bool|string $domainPath = null): void + { + $domainPath = is_string($domainPath) ? $domainPath : 'Database\\Factories\\{model}Factory'; + + Factory::guessFactoryNamesUsing( function (string $modelClass) use ($domainPath){ + + [$domain, $model] = $this->extractDomainAndModelFromModelNamespace($modelClass); + + if (is_null($domain)) { + return null; + } + + // Look for domain model factory in \\Database\\Factories\Factory.php + $classPath = 'Domain\\'.$domain.'\\'.str_replace('{model}', $model, $domainPath); + if (class_exists($classPath)) { + return $classPath; + } + + // Look for domain factory in /database/factories//Factory.php + $classPath = 'Database\\Factories\\'.$domain.'\\'.$model.'Factory'; + if (class_exists($classPath)) { + return $classPath; + } + + // Default Laravel factory location + return 'Database\Factories\\'.class_basename($modelClass).'Factory'; + }); + } + + protected function extractDomainAndModelFromModelNamespace(string $modelName): array + { + // Matches \{domain}\\{model} and extracts domain and model + // For example: Domain\Invoicing\Models\Invoice gives ['domain' => 'Invoicing', 'model' => 'Invoice'] + $regex = '/'.DomainResolver::getConfiguredDomainNamespace().'\\\\(?.+)\\\\'.$this->config['namespaces.models'].'\\\\(?.+)/'; + + if (preg_match($regex, $modelName, $matches, PREG_OFFSET_CAPTURE, 0)) { + return [ + 'domain' => $matches['domain'][0], + 'model' => $matches['model'][0] + ]; + } + + return []; + } +} diff --git a/src/Support/Path.php b/src/Support/Path.php index c516dbd..50e9e6c 100644 --- a/src/Support/Path.php +++ b/src/Support/Path.php @@ -17,4 +17,13 @@ public static function join(...$parts) return implode(DIRECTORY_SEPARATOR, $parts); } + + public static function filePathToNamespace(string $path, string $namespacePath, string $namespace): string + { + return str_replace( + [base_path().$namespacePath, '/', '.php'], + [$namespace, '\\', ''], + $path + ); + } } diff --git a/tests/Autoloader/AutoloadServiceProviderTest.php b/tests/Autoloader/AutoloadServiceProviderTest.php new file mode 100644 index 0000000..f205325 --- /dev/null +++ b/tests/Autoloader/AutoloadServiceProviderTest.php @@ -0,0 +1,8 @@ + Date: Mon, 25 Mar 2024 16:12:32 +0100 Subject: [PATCH 02/26] Fix config keys in autoloader --- src/Support/DomainAutoloader.php | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index 1b00472..5a67bd5 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -18,21 +18,23 @@ class DomainAutoloader public function __construct() { $this->config = config('ddd'); + $this->cacheDirectory = $this->config['cache_directory'] ?? 'bootstrap/cache/ddd'; } public function autoload(): void { - if(isset($this->config['autoload.service_providers'])) { - $this->registerDomainServiceProviders($this->config['autoload.service_providers']); + //dd('Autoloading domains', $this->config); + if(isset($this->config['autoload']['service_providers'])) { + $this->registerDomainServiceProviders($this->config['autoload']['service_providers']); } - if(isset($this->config['autoload.commands'])) { - $this->registerDomainCommands($this->config['autoload.commands']); + if(isset($this->config['autoload']['commands'])) { + $this->registerDomainCommands($this->config['autoload']['commands']); } - if(isset($this->config['autoload.policies'])) { - $this->registerPolicies($this->config['autoload.policies']); + if(isset($this->config['autoload']['policies'])) { + $this->registerPolicies($this->config['autoload']['policies']); } - if(isset($this->config['autoload.factories'])) { - $this->registerFactories($this->config['autoload.factories']); + if(isset($this->config['autoload']['factories'])) { + $this->registerFactories($this->config['autoload']['factories']); } } From edf44d696051bb423360f59b34564dbd59918aeb Mon Sep 17 00:00:00 2001 From: Peter Elmered Date: Mon, 25 Mar 2024 16:14:14 +0100 Subject: [PATCH 03/26] Fix cache + update changed method names after merge --- config/ddd.php | 1 + src/Support/DomainAutoloader.php | 39 ++++++++++++++++++++++++-------- src/Support/Path.php | 2 +- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/config/ddd.php b/config/ddd.php index b818743..0899794 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -63,6 +63,7 @@ 'factories' => 'Database\Factories', ], + 'cache_folder' => 'bootstrap/cache/ddd/', 'autoload' => [ /* diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index 5a67bd5..bab8d6a 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -42,14 +42,15 @@ protected function registerDomainServiceProviders(bool|string $domainPath = null { $domainPath = is_string($domainPath) ? $domainPath : '*/*ServiceProvider.php'; - $serviceProviders = Cache::rememberForever('ddd-domain-service-providers', static function () use ($domainPath){ + $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath){ return Arr::map( - glob(base_path(DomainResolver::getConfiguredDomainPath().'/'.$domainPath)), + glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), (static function ($serviceProvider) { + return Path::filePathToNamespace( $serviceProvider, - DomainResolver::getConfiguredDomainPath(), - DomainResolver::getConfiguredDomainNamespace() + DomainResolver::domainPath(), + DomainResolver::domainRootNamespace() ); })); }); @@ -63,14 +64,14 @@ protected function registerDomainServiceProviders(bool|string $domainPath = null protected function registerDomainCommands(bool|string $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php'; - $commands = Cache::rememberForever('ddd-domain-commands', static function () use ($domainPath){ + $commands = $this->remember('ddd-domain-commands', static function () use ($domainPath){ $commands = Arr::map( - glob(base_path(DomainResolver::getConfiguredDomainPath().'/'.$domainPath)), + glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), static function ($command) { return Path::filePathToNamespace( $command, - DomainResolver::getConfiguredDomainPath(), - DomainResolver::getConfiguredDomainNamespace() + DomainResolver::domainPath(), + DomainResolver::domainRootNamespace() ); }); @@ -103,7 +104,7 @@ protected function registerPolicies(bool|string $domainPath = null): void return null; } - $policy = DomainResolver::getConfiguredDomainNamespace().'\\'.$domain.'\\'.str_replace('{model}', $model, $domainPath); + $policy = DomainResolver::domainRootNamespace().'\\'.$domain.'\\'.str_replace('{model}', $model, $domainPath); return $policy; }); @@ -142,7 +143,7 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a { // Matches \{domain}\\{model} and extracts domain and model // For example: Domain\Invoicing\Models\Invoice gives ['domain' => 'Invoicing', 'model' => 'Invoice'] - $regex = '/'.DomainResolver::getConfiguredDomainNamespace().'\\\\(?.+)\\\\'.$this->config['namespaces.models'].'\\\\(?.+)/'; + $regex = '/'.DomainResolver::domainRootNamespace().'\\\\(?.+)\\\\'.$this->config['namespaces.models'].'\\\\(?.+)/'; if (preg_match($regex, $modelName, $matches, PREG_OFFSET_CAPTURE, 0)) { return [ @@ -153,4 +154,22 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a return []; } + + protected function remember($fileName, $callback) + { + $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); + + $data = file_exists($cacheFilePath) ? include $cacheFilePath : null; + + if (is_null($data)) { + $data = $callback(); + + file_put_contents( + $cacheFilePath, + ' Date: Mon, 25 Mar 2024 16:32:37 +0100 Subject: [PATCH 04/26] Clear cache when you run cache:clear command --- config/ddd.php | 5 +++- src/LaravelDDDServiceProvider.php | 3 +++ src/Support/CacheClearSubscriber.php | 40 ++++++++++++++++++++++++++++ src/Support/DomainAutoloader.php | 2 ++ 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/Support/CacheClearSubscriber.php diff --git a/config/ddd.php b/config/ddd.php index 0899794..ec67ad7 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -63,7 +63,10 @@ 'factories' => 'Database\Factories', ], - 'cache_folder' => 'bootstrap/cache/ddd/', + /* + * The folder where the domain cache files will be stored. + */ + 'cache_directory' => 'bootstrap/cache', 'autoload' => [ /* diff --git a/src/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php index fe8b301..311b6e2 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -2,6 +2,7 @@ namespace Lunarstorm\LaravelDDD; +use Illuminate\Support\Facades\Event; use Lunarstorm\LaravelDDD\Commands\InstallCommand; use Lunarstorm\LaravelDDD\Commands\MakeAction; use Lunarstorm\LaravelDDD\Commands\MakeBaseModel; @@ -11,6 +12,7 @@ use Lunarstorm\LaravelDDD\Commands\MakeModel; use Lunarstorm\LaravelDDD\Commands\MakeValueObject; use Lunarstorm\LaravelDDD\Commands\MakeViewModel; +use Lunarstorm\LaravelDDD\Support\CacheClearSubscriber; use Lunarstorm\LaravelDDD\Support\DomainAutoloader; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -71,5 +73,6 @@ public function packageBooted() public function packageRegistered() { (new DomainAutoloader())->autoload(); + Event::subscribe(CacheClearSubscriber::class); } } diff --git a/src/Support/CacheClearSubscriber.php b/src/Support/CacheClearSubscriber.php new file mode 100644 index 0000000..2217821 --- /dev/null +++ b/src/Support/CacheClearSubscriber.php @@ -0,0 +1,40 @@ +getMessage(), 'No such file or directory')) { + dump($exception->getMessage()); + throw $exception; + } + } + } + } + + /** + * Register the listeners for the subscriber. + * + * @param Dispatcher $events + * + * @return array + */ + public function subscribe(Dispatcher $events): void + { + $events->listen('cache:clearing', [$this, 'handle']); + } +} diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index bab8d6a..5cbff17 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -155,8 +155,10 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a return []; } + protected function remember($fileName, $callback) { + // The cache is not available during booting, so we need to roll our own file based cache $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); $data = file_exists($cacheFilePath) ? include $cacheFilePath : null; From d2d5130827466bb83a61931a497511b557f7ac7d Mon Sep 17 00:00:00 2001 From: Peter Elmered Date: Tue, 26 Mar 2024 04:35:11 +0100 Subject: [PATCH 05/26] Fix factory config key (should be singular) --- config/ddd.php | 2 +- src/Support/DomainAutoloader.php | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/config/ddd.php b/config/ddd.php index ec67ad7..3e1867d 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -60,7 +60,7 @@ 'resource' => 'Resources', 'rule' => 'Rules', 'scope' => 'Scopes', - 'factories' => 'Database\Factories', + 'factory' => 'Database\Factories', ], /* diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index 5cbff17..1ceead3 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -23,7 +23,6 @@ public function __construct() public function autoload(): void { - //dd('Autoloading domains', $this->config); if(isset($this->config['autoload']['service_providers'])) { $this->registerDomainServiceProviders($this->config['autoload']['service_providers']); } @@ -155,7 +154,6 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a return []; } - protected function remember($fileName, $callback) { // The cache is not available during booting, so we need to roll our own file based cache From 36a993a5701706c64d96166c7730ec0125410a78 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Wed, 27 Mar 2024 10:51:02 -0400 Subject: [PATCH 06/26] Formatting (pint) --- config/ddd.php | 1 - src/LaravelDDDServiceProvider.php | 9 ---- src/Support/CacheClearSubscriber.php | 2 +- src/Support/DomainAutoloader.php | 47 ++++++++++--------- .../AutoloadServiceProviderTest.php | 7 --- 5 files changed, 25 insertions(+), 41 deletions(-) diff --git a/config/ddd.php b/config/ddd.php index 3e1867d..251ac1b 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -100,7 +100,6 @@ 'factories' => 'Database\\Factories\\{model}Factory', ], - /* |-------------------------------------------------------------------------- | Base Model diff --git a/src/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php index 311b6e2..eb166ed 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -3,15 +3,6 @@ namespace Lunarstorm\LaravelDDD; use Illuminate\Support\Facades\Event; -use Lunarstorm\LaravelDDD\Commands\InstallCommand; -use Lunarstorm\LaravelDDD\Commands\MakeAction; -use Lunarstorm\LaravelDDD\Commands\MakeBaseModel; -use Lunarstorm\LaravelDDD\Commands\MakeBaseViewModel; -use Lunarstorm\LaravelDDD\Commands\MakeDTO; -use Lunarstorm\LaravelDDD\Commands\MakeFactory; -use Lunarstorm\LaravelDDD\Commands\MakeModel; -use Lunarstorm\LaravelDDD\Commands\MakeValueObject; -use Lunarstorm\LaravelDDD\Commands\MakeViewModel; use Lunarstorm\LaravelDDD\Support\CacheClearSubscriber; use Lunarstorm\LaravelDDD\Support\DomainAutoloader; use Spatie\LaravelPackageTools\Package; diff --git a/src/Support/CacheClearSubscriber.php b/src/Support/CacheClearSubscriber.php index 2217821..d71ac19 100644 --- a/src/Support/CacheClearSubscriber.php +++ b/src/Support/CacheClearSubscriber.php @@ -1,4 +1,5 @@ config['autoload']['service_providers'])) { + if (isset($this->config['autoload']['service_providers'])) { $this->registerDomainServiceProviders($this->config['autoload']['service_providers']); } - if(isset($this->config['autoload']['commands'])) { + if (isset($this->config['autoload']['commands'])) { $this->registerDomainCommands($this->config['autoload']['commands']); } - if(isset($this->config['autoload']['policies'])) { + if (isset($this->config['autoload']['policies'])) { $this->registerPolicies($this->config['autoload']['policies']); } - if(isset($this->config['autoload']['factories'])) { + if (isset($this->config['autoload']['factories'])) { $this->registerFactories($this->config['autoload']['factories']); } } - protected function registerDomainServiceProviders(bool|string $domainPath = null): void + protected function registerDomainServiceProviders(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : '*/*ServiceProvider.php'; - $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath){ + $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath) { return Arr::map( glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), (static function ($serviceProvider) { - return Path::filePathToNamespace( - $serviceProvider, - DomainResolver::domainPath(), - DomainResolver::domainRootNamespace() - ); - })); + return Path::filePathToNamespace( + $serviceProvider, + DomainResolver::domainPath(), + DomainResolver::domainRootNamespace() + ); + })); }); $app = app(); @@ -60,10 +61,10 @@ protected function registerDomainServiceProviders(bool|string $domainPath = null } } - protected function registerDomainCommands(bool|string $domainPath = null): void + protected function registerDomainCommands(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php'; - $commands = $this->remember('ddd-domain-commands', static function () use ($domainPath){ + $commands = $this->remember('ddd-domain-commands', static function () use ($domainPath) { $commands = Arr::map( glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), static function ($command) { @@ -72,10 +73,10 @@ static function ($command) { DomainResolver::domainPath(), DomainResolver::domainRootNamespace() ); - }); + }); // Filter out invalid commands (Abstract classes and classes not extending Illuminate\Console\Command) - return Arr::where($commands, static function($command) { + return Arr::where($commands, static function ($command) { if (is_subclass_of($command, Command::class) && ! (new ReflectionClass($command))->isAbstract()) { ConsoleApplication::starting(static function ($artisan) use ($command): void { @@ -86,12 +87,12 @@ static function ($command) { }); ConsoleApplication::starting(static function ($artisan) use ($commands): void { foreach ($commands as $command) { - $artisan->resolve($command); + $artisan->resolve($command); } }); } - protected function registerPolicies(bool|string $domainPath = null): void + protected function registerPolicies(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : 'Policies\\{model}Policy'; @@ -109,11 +110,11 @@ protected function registerPolicies(bool|string $domainPath = null): void }); } - protected function registerFactories(bool|string $domainPath = null): void + protected function registerFactories(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : 'Database\\Factories\\{model}Factory'; - Factory::guessFactoryNamesUsing( function (string $modelClass) use ($domainPath){ + Factory::guessFactoryNamesUsing(function (string $modelClass) use ($domainPath) { [$domain, $model] = $this->extractDomainAndModelFromModelNamespace($modelClass); @@ -147,7 +148,7 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a if (preg_match($regex, $modelName, $matches, PREG_OFFSET_CAPTURE, 0)) { return [ 'domain' => $matches['domain'][0], - 'model' => $matches['model'][0] + 'model' => $matches['model'][0], ]; } @@ -157,7 +158,7 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a protected function remember($fileName, $callback) { // The cache is not available during booting, so we need to roll our own file based cache - $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); + $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); $data = file_exists($cacheFilePath) ? include $cacheFilePath : null; @@ -166,7 +167,7 @@ protected function remember($fileName, $callback) file_put_contents( $cacheFilePath, - ' Date: Wed, 27 Mar 2024 11:02:07 -0400 Subject: [PATCH 07/26] Update return type in docblock to make phpstan happy. --- src/Support/CacheClearSubscriber.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Support/CacheClearSubscriber.php b/src/Support/CacheClearSubscriber.php index d71ac19..3ba0999 100644 --- a/src/Support/CacheClearSubscriber.php +++ b/src/Support/CacheClearSubscriber.php @@ -31,7 +31,7 @@ public function handle(): void * Register the listeners for the subscriber. * * - * @return array + * @return void */ public function subscribe(Dispatcher $events): void { From d808673794d4072144b0700cf38f5a95ebffc77c Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Wed, 27 Mar 2024 11:10:20 -0400 Subject: [PATCH 08/26] Relocate event subscriber into Listeners, following laravel's convention. --- src/LaravelDDDServiceProvider.php | 2 +- src/{Support => Listeners}/CacheClearSubscriber.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/{Support => Listeners}/CacheClearSubscriber.php (76%) diff --git a/src/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php index eb166ed..c7cb0bf 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -3,7 +3,7 @@ namespace Lunarstorm\LaravelDDD; use Illuminate\Support\Facades\Event; -use Lunarstorm\LaravelDDD\Support\CacheClearSubscriber; +use Lunarstorm\LaravelDDD\Listeners\CacheClearSubscriber; use Lunarstorm\LaravelDDD\Support\DomainAutoloader; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; diff --git a/src/Support/CacheClearSubscriber.php b/src/Listeners/CacheClearSubscriber.php similarity index 76% rename from src/Support/CacheClearSubscriber.php rename to src/Listeners/CacheClearSubscriber.php index 3ba0999..5f643e8 100644 --- a/src/Support/CacheClearSubscriber.php +++ b/src/Listeners/CacheClearSubscriber.php @@ -1,6 +1,6 @@ getMessage(), 'No such file or directory')) { + if (!str_contains($exception->getMessage(), 'No such file or directory')) { dump($exception->getMessage()); throw $exception; } From e94355499cad6b0db8f8d38c7e3fe803f6d7e448 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Wed, 27 Mar 2024 11:10:51 -0400 Subject: [PATCH 09/26] Minor cleanup. --- config/ddd.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/ddd.php b/config/ddd.php index 251ac1b..d7c6e61 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -31,11 +31,11 @@ | objects relative to the domain namespace of which the object | belongs to. | - | e.g., Domain/Invoicing/Models/* - | Domain/Invoicing/Data/* - | Domain/Invoicing/ViewModels/* - | Domain/Invoicing/ValueObjects/* - | Domain/Invoicing/Actions/* + | e.g., Domain\Invoicing\Models\* + | Domain\Invoicing\Data\* + | Domain\Invoicing\ViewModels\* + | Domain\Invoicing\ValueObjects\* + | Domain\Invoicing\Actions\* | */ 'namespaces' => [ @@ -50,6 +50,7 @@ 'enum' => 'Enums', 'event' => 'Events', 'exception' => 'Exceptions', + 'factory' => 'Database\Factories', 'job' => 'Jobs', 'listener' => 'Listeners', 'mail' => 'Mail', @@ -60,7 +61,6 @@ 'resource' => 'Resources', 'rule' => 'Rules', 'scope' => 'Scopes', - 'factory' => 'Database\Factories', ], /* From 4168b299087e087ed66ab38e0a0dab333b17e411 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Wed, 27 Mar 2024 21:20:56 -0400 Subject: [PATCH 10/26] WIP --- composer.json | 3 +- src/Factories/DomainFactory.php | 23 ++-- src/Support/Domain.php | 12 +- src/Support/DomainAutoloader.php | 113 +++++++++++------- src/Support/DomainResolver.php | 10 +- src/ValueObjects/DomainObject.php | 73 +++++++++++ src/ValueObjects/DomainObjectNamespace.php | 6 +- tests/Autoload/FactoryTest.php | 31 +++++ tests/Autoload/PolicyTest.php | 27 +++++ .../ServiceProviderTest.php} | 0 tests/Datasets/Domains.php | 2 +- .../DomainFactoryTest.php} | 0 tests/TestCase.php | 13 +- tests/ValueObject/DomainObjectTest.php | 36 ++++++ .../Database/Factories/ReportFactory.php | 13 ++ .../Internal/Reporting/Models/Report.php | 11 ++ .../Factories/VanillaModelFactory.php | 15 +++ .../Domain/Invoicing}/Models/Invoice.php | 2 +- .../Domain/Invoicing/Models/VanillaModel.php | 11 ++ .../Invoicing/Policies/InvoicePolicy.php | 47 ++++++++ tests/resources/app/Models/Post.php | 13 ++ tests/resources/app/Models/User.php | 30 +++++ tests/resources/app/Policies/PostPolicy.php | 17 +++ 23 files changed, 438 insertions(+), 70 deletions(-) create mode 100644 tests/Autoload/FactoryTest.php create mode 100644 tests/Autoload/PolicyTest.php rename tests/{Autoloader/AutoloadServiceProviderTest.php => Autoload/ServiceProviderTest.php} (100%) rename tests/{Model/FactoryTest.php => Factory/DomainFactoryTest.php} (100%) create mode 100644 tests/ValueObject/DomainObjectTest.php create mode 100644 tests/resources/Domain/Internal/Reporting/Database/Factories/ReportFactory.php create mode 100644 tests/resources/Domain/Internal/Reporting/Models/Report.php create mode 100644 tests/resources/Domain/Invoicing/Database/Factories/VanillaModelFactory.php rename tests/{Fixtures => resources/Domain/Invoicing}/Models/Invoice.php (62%) create mode 100644 tests/resources/Domain/Invoicing/Models/VanillaModel.php create mode 100644 tests/resources/Domain/Invoicing/Policies/InvoicePolicy.php create mode 100644 tests/resources/app/Models/Post.php create mode 100644 tests/resources/app/Models/User.php create mode 100644 tests/resources/app/Policies/PostPolicy.php diff --git a/composer.json b/composer.json index 51cfca0..7e8b478 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,8 @@ "App\\": "vendor/orchestra/testbench-core/laravel/app/", "Database\\Factories\\": "vendor/orchestra/testbench-core/laravel/database/factories/", "Database\\Seeders\\": "vendor/orchestra/testbench-core/laravel/database/seeders/", - "Domain\\": "vendor/orchestra/testbench-core/laravel/src/Domain/" + "Domain\\": "vendor/orchestra/testbench-core/laravel/src/Domain/", + "TestDomain\\": "tests/Fixtures/Domain" } }, "scripts": { diff --git a/src/Factories/DomainFactory.php b/src/Factories/DomainFactory.php index 6518d0d..93daddd 100644 --- a/src/Factories/DomainFactory.php +++ b/src/Factories/DomainFactory.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Str; use Lunarstorm\LaravelDDD\Support\DomainResolver; +use Lunarstorm\LaravelDDD\ValueObjects\DomainObject; abstract class DomainFactory extends Factory { @@ -27,26 +28,20 @@ protected static function domainNamespace() public static function resolveFactoryName(string $modelName) { $resolver = function (string $modelName) { - $domainNamespace = static::domainNamespace(); - $modelNamespace = config('ddd.namespaces.model'); + $model = DomainObject::fromClass($modelName, 'model'); - // Expected domain model FQN: - // {DomainNamespace}\{Domain}\{ModelNamespace}\{Model} - - if (! Str::startsWith($modelName, $domainNamespace)) { + if (!$model) { // Not a domain model return null; } - $domain = str($modelName) - ->after($domainNamespace) - ->beforeLast($modelNamespace) - ->trim('\\') - ->toString(); - - $modelBaseName = class_basename($modelName); + // First try resolving as a factory class in the domain layer + if (class_exists($factoryClass = DomainResolver::getDomainObjectNamespace($model->domain, 'factory', "{$model->name}Factory"))) { + return $factoryClass; + }; - return static::$namespace."{$domain}\\{$modelBaseName}Factory"; + // Otherwise, fallback to the the standard location under /database/factories + return static::$namespace . "{$model->domain}\\{$model->name}Factory"; }; return $resolver($modelName); diff --git a/src/Support/Domain.php b/src/Support/Domain.php index 17bf5e1..45a563b 100644 --- a/src/Support/Domain.php +++ b/src/Support/Domain.php @@ -107,9 +107,11 @@ public function object(string $type, string $name): DomainObject return new DomainObject( name: $name, + domain: $this->domain, namespace: $namespace, - fqn: $namespace.'\\'.$name, - path: $this->path($namespace.'\\'.$name), + fqn: $namespace . '\\' . $name, + path: $this->path($namespace . '\\' . $name), + type: $type ); } @@ -124,11 +126,13 @@ public function factory(string $name): DomainObject return new DomainObject( name: $name, + domain: $this->domain, namespace: $this->namespace->factories, - fqn: $this->namespace->factories.'\\'.$name, + fqn: $this->namespace->factories . '\\' . $name, path: str("database/factories/{$this->domainWithSubdomain}/{$name}.php") ->replace(['\\', '/'], DIRECTORY_SEPARATOR) - ->toString() + ->toString(), + type: 'factory' ); } diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index 66bfb36..fe8d7e2 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -4,11 +4,18 @@ use Illuminate\Console\Application as ConsoleApplication; use Illuminate\Console\Command; +use Illuminate\Container\Container; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Foundation\Application; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Str; +use Lunarstorm\LaravelDDD\Factories\DomainFactory; +use Lunarstorm\LaravelDDD\ValueObjects\DomainObject; use ReflectionClass; +use Throwable; class DomainAutoloader { @@ -19,22 +26,30 @@ class DomainAutoloader public function __construct() { $this->config = config('ddd'); - $this->cacheDirectory = $this->config['cache_directory'] ?? 'bootstrap/cache/ddd'; + $this->cacheDirectory = $this->configValue('cache_directory') ?? 'bootstrap/cache/ddd'; + } + + protected function configValue($path) + { + return data_get($this->config, $path); } public function autoload(): void { - if (isset($this->config['autoload']['service_providers'])) { - $this->registerDomainServiceProviders($this->config['autoload']['service_providers']); + if ($value = $this->configValue('autoload.service_providers')) { + $this->registerDomainServiceProviders($value); } - if (isset($this->config['autoload']['commands'])) { - $this->registerDomainCommands($this->config['autoload']['commands']); + + if ($value = $this->configValue('autoload.commands')) { + $this->registerDomainCommands($value); } - if (isset($this->config['autoload']['policies'])) { - $this->registerPolicies($this->config['autoload']['policies']); + + if ($value = $this->configValue('autoload.policies')) { + $this->registerPolicies($value); } - if (isset($this->config['autoload']['factories'])) { - $this->registerFactories($this->config['autoload']['factories']); + + if ($value = $this->configValue('autoload.factories')) { + $this->registerFactories($value); } } @@ -44,7 +59,7 @@ protected function registerDomainServiceProviders(bool|string|null $domainPath = $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath) { return Arr::map( - glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), + glob(base_path(DomainResolver::domainPath() . '/' . $domainPath)), (static function ($serviceProvider) { return Path::filePathToNamespace( @@ -52,7 +67,8 @@ protected function registerDomainServiceProviders(bool|string|null $domainPath = DomainResolver::domainPath(), DomainResolver::domainRootNamespace() ); - })); + }) + ); }); $app = app(); @@ -66,19 +82,22 @@ protected function registerDomainCommands(bool|string|null $domainPath = null): $domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php'; $commands = $this->remember('ddd-domain-commands', static function () use ($domainPath) { $commands = Arr::map( - glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), + glob(base_path(DomainResolver::domainPath() . '/' . $domainPath)), static function ($command) { return Path::filePathToNamespace( $command, DomainResolver::domainPath(), DomainResolver::domainRootNamespace() ); - }); + } + ); // Filter out invalid commands (Abstract classes and classes not extending Illuminate\Console\Command) return Arr::where($commands, static function ($command) { - if (is_subclass_of($command, Command::class) && - ! (new ReflectionClass($command))->isAbstract()) { + if ( + is_subclass_of($command, Command::class) && + !(new ReflectionClass($command))->isAbstract() + ) { ConsoleApplication::starting(static function ($artisan) use ($command): void { $artisan->resolve($command); }); @@ -96,17 +115,24 @@ protected function registerPolicies(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : 'Policies\\{model}Policy'; - Gate::guessPolicyNamesUsing(static function (string $modelClass) use ($domainPath): ?string { + Gate::guessPolicyNamesUsing(static function (string $class) use ($domainPath): array|string|null { + if ($model = DomainObject::fromClass($class, 'model')) { + return (new Domain($model->domain)) + ->object('policy', "{$model->name}Policy") + ->fqn; + } - [$domain, $model] = static::extractDomainAndModelFromModelNamespace($modelClass); + $classDirname = str_replace('/', '\\', dirname(str_replace('\\', '/', $class))); - if (is_null($domain)) { - return null; - } + $classDirnameSegments = explode('\\', $classDirname); - $policy = DomainResolver::domainRootNamespace().'\\'.$domain.'\\'.str_replace('{model}', $model, $domainPath); + return Arr::wrap(Collection::times(count($classDirnameSegments), function ($index) use ($class, $classDirnameSegments) { + $classDirname = implode('\\', array_slice($classDirnameSegments, 0, $index)); - return $policy; + return $classDirname . '\\Policies\\' . class_basename($class) . 'Policy'; + })->reverse()->values()->first(function ($class) { + return class_exists($class); + }) ?: [$classDirname . '\\Policies\\' . class_basename($class) . 'Policy']); }); } @@ -114,28 +140,18 @@ protected function registerFactories(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : 'Database\\Factories\\{model}Factory'; - Factory::guessFactoryNamesUsing(function (string $modelClass) use ($domainPath) { - - [$domain, $model] = $this->extractDomainAndModelFromModelNamespace($modelClass); - - if (is_null($domain)) { - return null; + Factory::guessFactoryNamesUsing(function (string $modelName) use ($domainPath) { + if (DomainResolver::isDomainClass($modelName)) { + return DomainFactory::factoryForModel($modelName); } - // Look for domain model factory in \\Database\\Factories\Factory.php - $classPath = 'Domain\\'.$domain.'\\'.str_replace('{model}', $model, $domainPath); - if (class_exists($classPath)) { - return $classPath; - } + $appNamespace = static::appNamespace(); - // Look for domain factory in /database/factories//Factory.php - $classPath = 'Database\\Factories\\'.$domain.'\\'.$model.'Factory'; - if (class_exists($classPath)) { - return $classPath; - } + $modelName = Str::startsWith($modelName, $appNamespace . 'Models\\') + ? Str::after($modelName, $appNamespace . 'Models\\') + : Str::after($modelName, $appNamespace); - // Default Laravel factory location - return 'Database\Factories\\'.class_basename($modelClass).'Factory'; + return 'Database\\Factories\\' . $modelName . 'Factory'; }); } @@ -143,7 +159,7 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a { // Matches \{domain}\\{model} and extracts domain and model // For example: Domain\Invoicing\Models\Invoice gives ['domain' => 'Invoicing', 'model' => 'Invoice'] - $regex = '/'.DomainResolver::domainRootNamespace().'\\\\(?.+)\\\\'.$this->config['namespaces.models'].'\\\\(?.+)/'; + $regex = '/' . DomainResolver::domainRootNamespace() . '\\\\(?.+)\\\\' . $this->configValue('namespaces.models') . '\\\\(?.+)/'; if (preg_match($regex, $modelName, $matches, PREG_OFFSET_CAPTURE, 0)) { return [ @@ -158,7 +174,7 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a protected function remember($fileName, $callback) { // The cache is not available during booting, so we need to roll our own file based cache - $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); + $cacheFilePath = base_path($this->cacheDirectory . '/' . $fileName . '.php'); $data = file_exists($cacheFilePath) ? include $cacheFilePath : null; @@ -167,10 +183,21 @@ protected function remember($fileName, $callback) file_put_contents( $cacheFilePath, - 'make(Application::class) + ->getNamespace(); + } catch (Throwable) { + return 'App\\'; + } + } } diff --git a/src/Support/DomainResolver.php b/src/Support/DomainResolver.php index 8ececb3..8c96646 100644 --- a/src/Support/DomainResolver.php +++ b/src/Support/DomainResolver.php @@ -45,9 +45,15 @@ public static function getRelativeObjectNamespace(string $type): string return config("ddd.namespaces.{$type}", str($type)->plural()->studly()->toString()); } - public static function getDomainObjectNamespace(string $domain, string $type): string + public static function getDomainObjectNamespace(string $domain, string $type, ?string $object = null): string { - return implode('\\', [static::domainRootNamespace(), $domain, static::getRelativeObjectNamespace($type)]); + $namespace = implode('\\', [static::domainRootNamespace(), $domain, static::getRelativeObjectNamespace($type)]); + + if($object){ + $namespace .= "\\{$object}"; + } + + return $namespace; } /** diff --git a/src/ValueObjects/DomainObject.php b/src/ValueObjects/DomainObject.php index 8d77121..4a3958c 100644 --- a/src/ValueObjects/DomainObject.php +++ b/src/ValueObjects/DomainObject.php @@ -2,13 +2,86 @@ namespace Lunarstorm\LaravelDDD\ValueObjects; +use Illuminate\Support\Str; +use Lunarstorm\LaravelDDD\Support\Domain; +use Lunarstorm\LaravelDDD\Support\DomainResolver; +use Lunarstorm\LaravelDDD\Support\Path; + class DomainObject { public function __construct( public readonly string $name, + public readonly string $domain, public readonly string $namespace, public readonly string $fqn, public readonly string $path, + public readonly ?string $type = null, ) { } + + public static function fromClass(string $fullyQualifiedClass, ?string $objectType = null): ?self + { + if (!DomainResolver::isDomainClass($fullyQualifiedClass)) { + return null; + } + + // First extract the object base name + $objectName = class_basename($fullyQualifiedClass); + + $objectNamespace = ''; + + $possibleObjectNamespaces = config("ddd.namespaces.{$objectType}") + ? [$objectType => config("ddd.namespaces.{$objectType}")] + : config('ddd.namespaces', []); + + foreach ($possibleObjectNamespaces as $type => $namespace) { + $rootObjectNamespace = preg_quote($namespace); + + $pattern = "/({$rootObjectNamespace})(.*)$/"; + + $result = preg_match($pattern, $fullyQualifiedClass, $matches); + + if (!$result) { + continue; + } + + $objectNamespace = str(data_get($matches, 0))->beforeLast('\\')->toString(); + + $objectType = $type; + } + + // If there wasn't a recognized namespace, we'll assume it's a + // domain object in an ad-hoc namespace. + if (!$objectNamespace) { + // e.g., Domain\Invoicing\AdHoc\Nested\Thing + $objectNamespace = str($fullyQualifiedClass) + ->after(Str::finish(DomainResolver::domainRootNamespace(), '\\')) + ->after('\\') + ->before("\\{$objectName}") + ->toString(); + } + + // Extract the domain portion + $domainName = str($fullyQualifiedClass) + ->after(Str::finish(DomainResolver::domainRootNamespace(), '\\')) + ->before("\\{$objectNamespace}") + ->toString(); + + // Reconstruct the path + $path = Path::join( + DomainResolver::domainPath(), + $domainName, + $objectNamespace, + "{$objectName}.php", + ); + + return new self( + name: $objectName, + domain: $domainName, + namespace: $objectNamespace, + fqn: $fullyQualifiedClass, + path: $path, + type: $objectType, + ); + } } diff --git a/src/ValueObjects/DomainObjectNamespace.php b/src/ValueObjects/DomainObjectNamespace.php index 0681f8d..2a92bc5 100644 --- a/src/ValueObjects/DomainObjectNamespace.php +++ b/src/ValueObjects/DomainObjectNamespace.php @@ -8,7 +8,7 @@ class DomainObjectNamespace { public function __construct( - public readonly string $key, + public readonly string $type, public readonly string $namespace, ) { } @@ -23,8 +23,8 @@ public static function make(string $key, string $domain, ?string $subdomain = nu $domainNamespace = implode('\\', [$root, $domainWithSubdomain]); - $namespace = "{$domainNamespace}\\".config("ddd.namespaces.{$key}", Str::studly($key)); + $namespace = "{$domainNamespace}\\" . config("ddd.namespaces.{$key}", Str::studly($key)); - return new self(key: $key, namespace: $namespace); + return new self(type: $key, namespace: $namespace); } } diff --git a/tests/Autoload/FactoryTest.php b/tests/Autoload/FactoryTest.php new file mode 100644 index 0000000..968ee37 --- /dev/null +++ b/tests/Autoload/FactoryTest.php @@ -0,0 +1,31 @@ +setupTestApplication(); + + Config::set('ddd.domain_namespace', 'Domain'); + + (new DomainAutoloader())->autoload(); +}); + +it('can autoload domain factory', function ($modelClass, $expectedFactoryClass) { + expect($modelClass::factory())->toBeInstanceOf($expectedFactoryClass); +})->with([ + ['Domain\Invoicing\Models\VanillaModel', 'Domain\Invoicing\Database\Factories\VanillaModelFactory'], + ['Domain\Internal\Reporting\Models\Report', 'Domain\Internal\Reporting\Database\Factories\ReportFactory'] +]); + +it('does not affect non-domain model factories', function () { + Artisan::call('make:model RegularModel -f'); + + $modelClass = 'App\Models\RegularModel'; + + expect(class_exists($modelClass))->toBeTrue(); + + expect($modelClass::factory()) + ->toBeInstanceOf('Database\Factories\RegularModelFactory'); +}); diff --git a/tests/Autoload/PolicyTest.php b/tests/Autoload/PolicyTest.php new file mode 100644 index 0000000..9f1a621 --- /dev/null +++ b/tests/Autoload/PolicyTest.php @@ -0,0 +1,27 @@ +setupTestApplication(); + + Config::set('ddd.domain_namespace', 'Domain'); + + (new DomainAutoloader())->autoload(); +}); + +it('can autoload domain policy', function ($class, $expectedPolicy) { + expect(class_exists($class))->toBeTrue(); + expect(Gate::getPolicyFor($class))->toBeInstanceOf($expectedPolicy); +})->with([ + ['Domain\Invoicing\Models\Invoice', 'Domain\Invoicing\Policies\InvoicePolicy'], +]); + +it('can autoload non-domain policy', function ($class, $expectedPolicy) { + expect(class_exists($class))->toBeTrue(); + expect(Gate::getPolicyFor($class))->toBeInstanceOf($expectedPolicy); +})->with([ + ['App\Models\Post', 'App\Policies\PostPolicy'], +]); diff --git a/tests/Autoloader/AutoloadServiceProviderTest.php b/tests/Autoload/ServiceProviderTest.php similarity index 100% rename from tests/Autoloader/AutoloadServiceProviderTest.php rename to tests/Autoload/ServiceProviderTest.php diff --git a/tests/Datasets/Domains.php b/tests/Datasets/Domains.php index 56842c1..37dd91e 100644 --- a/tests/Datasets/Domains.php +++ b/tests/Datasets/Domains.php @@ -3,8 +3,8 @@ dataset('domainPaths', [ ['src/Domain', 'Domain'], ['src/Domains', 'Domains'], + ['src/Domains', 'Domain'], ['Custom/PathTo/Domain', 'Domain'], - ['Custom/PathTo/Domains', 'Domains'], ]); dataset('domainSubdomain', [ diff --git a/tests/Model/FactoryTest.php b/tests/Factory/DomainFactoryTest.php similarity index 100% rename from tests/Model/FactoryTest.php rename to tests/Factory/DomainFactoryTest.php diff --git a/tests/TestCase.php b/tests/TestCase.php index 9788b7f..abff1fd 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,6 +6,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\File; use Lunarstorm\LaravelDDD\LaravelDDDServiceProvider; +use Lunarstorm\LaravelDDD\Listeners\CacheClearSubscriber; use Orchestra\Testbench\TestCase as Orchestra; use Symfony\Component\Process\Process; @@ -17,6 +18,8 @@ protected function setUp(): void $this->cleanFilesAndFolders(); + (new CacheClearSubscriber())->handle(); + $composerFile = base_path('composer.json'); $data = json_decode(file_get_contents($composerFile), true); @@ -34,7 +37,7 @@ protected function setUp(): void $this->composerReload(); Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Lunarstorm\\LaravelDDD\\Database\\Factories\\'.class_basename($modelName).'Factory' + fn (string $modelName) => 'Lunarstorm\\LaravelDDD\\Database\\Factories\\' . class_basename($modelName) . 'Factory' ); $this->beforeApplicationDestroyed(function () { @@ -76,4 +79,12 @@ protected function cleanFilesAndFolders() File::deleteDirectory(base_path('src/Domain')); File::deleteDirectory(base_path('src/Domains')); } + + public function setupTestApplication() + { + File::copyDirectory(__DIR__ . '/resources/app', app_path()); + File::copyDirectory(__DIR__ . '/resources/Domain', base_path('src/Domain')); + + File::ensureDirectoryExists(app_path('Models')); + } } diff --git a/tests/ValueObject/DomainObjectTest.php b/tests/ValueObject/DomainObjectTest.php new file mode 100644 index 0000000..f2a73ec --- /dev/null +++ b/tests/ValueObject/DomainObjectTest.php @@ -0,0 +1,36 @@ +name->toEqual($objectName) + ->domain->toEqual($domain) + ->namespace->toEqual($relativeNamespace) + ->path->toEqual($expectedPath); +})->with([ + ['Domain\Invoicing\Models\Invoice', 'Invoicing', 'Models', 'Invoice'], + ['Domain\Invoicing\Models\Payment\InvoicePayment', 'Invoicing', 'Models\Payment', 'InvoicePayment'], + ['Domain\Internal\Invoicing\Models\Invoice', 'Internal\Invoicing', 'Models', 'Invoice'], + ['Domain\Internal\Invoicing\Models\Payment\InvoicePayment', 'Internal\Invoicing', 'Models\Payment', 'InvoicePayment'], + ['Domain\Invoicing\AdHoc\Thing', 'Invoicing', 'AdHoc', 'Thing'], + ['Domain\Invoicing\AdHoc\Nested\Thing', 'Invoicing', 'AdHoc\Nested', 'Thing'], + + // Ad-hoc objects inside subdomains are not supported for now + // ['Domain\Internal\Invoicing\AdHoc\Thing', 'Internal\Invoicing', 'AdHoc', 'Thing'], + // ['Domain\Internal\Invoicing\AdHoc\Nested\Thing', 'Internal\Invoicing', 'AdHoc\Nested', 'Thing'], +]); + +it('cannot create a domain object from unresolvable classes', function (string $class) { + expect(DomainObject::fromClass($class))->toBeNull(); +})->with([ + ['Illuminate\Support\Str'], + ['NotDomain\Invoicing\Models\InvoicePayment'], + ['Invoice'], +]); diff --git a/tests/resources/Domain/Internal/Reporting/Database/Factories/ReportFactory.php b/tests/resources/Domain/Internal/Reporting/Database/Factories/ReportFactory.php new file mode 100644 index 0000000..72c81ee --- /dev/null +++ b/tests/resources/Domain/Internal/Reporting/Database/Factories/ReportFactory.php @@ -0,0 +1,13 @@ + 'datetime', + ]; +} diff --git a/tests/resources/app/Policies/PostPolicy.php b/tests/resources/app/Policies/PostPolicy.php new file mode 100644 index 0000000..689c12e --- /dev/null +++ b/tests/resources/app/Policies/PostPolicy.php @@ -0,0 +1,17 @@ +id === $post->user_id; + } +} From 94112cb42b1fada98b72913aaa9b6202a16354b0 Mon Sep 17 00:00:00 2001 From: JasperTey Date: Thu, 28 Mar 2024 01:21:33 +0000 Subject: [PATCH 11/26] Fix styling --- src/Factories/DomainFactory.php | 6 ++--- src/Listeners/CacheClearSubscriber.php | 7 ++--- src/Support/Domain.php | 6 ++--- src/Support/DomainAutoloader.php | 26 +++++++++---------- src/Support/DomainResolver.php | 2 +- src/ValueObjects/DomainObject.php | 6 ++--- src/ValueObjects/DomainObjectNamespace.php | 2 +- tests/Autoload/FactoryTest.php | 2 +- tests/TestCase.php | 6 ++--- tests/ValueObject/DomainObjectTest.php | 2 +- .../Invoicing/Policies/InvoicePolicy.php | 4 +-- 11 files changed, 33 insertions(+), 36 deletions(-) diff --git a/src/Factories/DomainFactory.php b/src/Factories/DomainFactory.php index 93daddd..47f7fe1 100644 --- a/src/Factories/DomainFactory.php +++ b/src/Factories/DomainFactory.php @@ -30,7 +30,7 @@ public static function resolveFactoryName(string $modelName) $resolver = function (string $modelName) { $model = DomainObject::fromClass($modelName, 'model'); - if (!$model) { + if (! $model) { // Not a domain model return null; } @@ -38,10 +38,10 @@ public static function resolveFactoryName(string $modelName) // First try resolving as a factory class in the domain layer if (class_exists($factoryClass = DomainResolver::getDomainObjectNamespace($model->domain, 'factory', "{$model->name}Factory"))) { return $factoryClass; - }; + } // Otherwise, fallback to the the standard location under /database/factories - return static::$namespace . "{$model->domain}\\{$model->name}Factory"; + return static::$namespace."{$model->domain}\\{$model->name}Factory"; }; return $resolver($modelName); diff --git a/src/Listeners/CacheClearSubscriber.php b/src/Listeners/CacheClearSubscriber.php index 5f643e8..b5909ad 100644 --- a/src/Listeners/CacheClearSubscriber.php +++ b/src/Listeners/CacheClearSubscriber.php @@ -13,13 +13,13 @@ public function __construct() public function handle(): void { - $files = glob(base_path(config('ddd.cache_directory') . '/ddd-*.php')); + $files = glob(base_path(config('ddd.cache_directory').'/ddd-*.php')); foreach ($files as $file) { try { unlink($file); } catch (ErrorException $exception) { - if (!str_contains($exception->getMessage(), 'No such file or directory')) { + if (! str_contains($exception->getMessage(), 'No such file or directory')) { dump($exception->getMessage()); throw $exception; } @@ -29,9 +29,6 @@ public function handle(): void /** * Register the listeners for the subscriber. - * - * - * @return void */ public function subscribe(Dispatcher $events): void { diff --git a/src/Support/Domain.php b/src/Support/Domain.php index 45a563b..fac0b26 100644 --- a/src/Support/Domain.php +++ b/src/Support/Domain.php @@ -109,8 +109,8 @@ public function object(string $type, string $name): DomainObject name: $name, domain: $this->domain, namespace: $namespace, - fqn: $namespace . '\\' . $name, - path: $this->path($namespace . '\\' . $name), + fqn: $namespace.'\\'.$name, + path: $this->path($namespace.'\\'.$name), type: $type ); } @@ -128,7 +128,7 @@ public function factory(string $name): DomainObject name: $name, domain: $this->domain, namespace: $this->namespace->factories, - fqn: $this->namespace->factories . '\\' . $name, + fqn: $this->namespace->factories.'\\'.$name, path: str("database/factories/{$this->domainWithSubdomain}/{$name}.php") ->replace(['\\', '/'], DIRECTORY_SEPARATOR) ->toString(), diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index fe8d7e2..f0ec82b 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -59,7 +59,7 @@ protected function registerDomainServiceProviders(bool|string|null $domainPath = $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath) { return Arr::map( - glob(base_path(DomainResolver::domainPath() . '/' . $domainPath)), + glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), (static function ($serviceProvider) { return Path::filePathToNamespace( @@ -82,7 +82,7 @@ protected function registerDomainCommands(bool|string|null $domainPath = null): $domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php'; $commands = $this->remember('ddd-domain-commands', static function () use ($domainPath) { $commands = Arr::map( - glob(base_path(DomainResolver::domainPath() . '/' . $domainPath)), + glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), static function ($command) { return Path::filePathToNamespace( $command, @@ -96,7 +96,7 @@ static function ($command) { return Arr::where($commands, static function ($command) { if ( is_subclass_of($command, Command::class) && - !(new ReflectionClass($command))->isAbstract() + ! (new ReflectionClass($command))->isAbstract() ) { ConsoleApplication::starting(static function ($artisan) use ($command): void { $artisan->resolve($command); @@ -115,7 +115,7 @@ protected function registerPolicies(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : 'Policies\\{model}Policy'; - Gate::guessPolicyNamesUsing(static function (string $class) use ($domainPath): array|string|null { + Gate::guessPolicyNamesUsing(static function (string $class): array|string|null { if ($model = DomainObject::fromClass($class, 'model')) { return (new Domain($model->domain)) ->object('policy', "{$model->name}Policy") @@ -129,10 +129,10 @@ protected function registerPolicies(bool|string|null $domainPath = null): void return Arr::wrap(Collection::times(count($classDirnameSegments), function ($index) use ($class, $classDirnameSegments) { $classDirname = implode('\\', array_slice($classDirnameSegments, 0, $index)); - return $classDirname . '\\Policies\\' . class_basename($class) . 'Policy'; + return $classDirname.'\\Policies\\'.class_basename($class).'Policy'; })->reverse()->values()->first(function ($class) { return class_exists($class); - }) ?: [$classDirname . '\\Policies\\' . class_basename($class) . 'Policy']); + }) ?: [$classDirname.'\\Policies\\'.class_basename($class).'Policy']); }); } @@ -140,18 +140,18 @@ protected function registerFactories(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : 'Database\\Factories\\{model}Factory'; - Factory::guessFactoryNamesUsing(function (string $modelName) use ($domainPath) { + Factory::guessFactoryNamesUsing(function (string $modelName) { if (DomainResolver::isDomainClass($modelName)) { return DomainFactory::factoryForModel($modelName); } $appNamespace = static::appNamespace(); - $modelName = Str::startsWith($modelName, $appNamespace . 'Models\\') - ? Str::after($modelName, $appNamespace . 'Models\\') + $modelName = Str::startsWith($modelName, $appNamespace.'Models\\') + ? Str::after($modelName, $appNamespace.'Models\\') : Str::after($modelName, $appNamespace); - return 'Database\\Factories\\' . $modelName . 'Factory'; + return 'Database\\Factories\\'.$modelName.'Factory'; }); } @@ -159,7 +159,7 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a { // Matches \{domain}\\{model} and extracts domain and model // For example: Domain\Invoicing\Models\Invoice gives ['domain' => 'Invoicing', 'model' => 'Invoice'] - $regex = '/' . DomainResolver::domainRootNamespace() . '\\\\(?.+)\\\\' . $this->configValue('namespaces.models') . '\\\\(?.+)/'; + $regex = '/'.DomainResolver::domainRootNamespace().'\\\\(?.+)\\\\'.$this->configValue('namespaces.models').'\\\\(?.+)/'; if (preg_match($regex, $modelName, $matches, PREG_OFFSET_CAPTURE, 0)) { return [ @@ -174,7 +174,7 @@ protected function extractDomainAndModelFromModelNamespace(string $modelName): a protected function remember($fileName, $callback) { // The cache is not available during booting, so we need to roll our own file based cache - $cacheFilePath = base_path($this->cacheDirectory . '/' . $fileName . '.php'); + $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); $data = file_exists($cacheFilePath) ? include $cacheFilePath : null; @@ -183,7 +183,7 @@ protected function remember($fileName, $callback) file_put_contents( $cacheFilePath, - 'after(Str::finish(DomainResolver::domainRootNamespace(), '\\')) diff --git a/src/ValueObjects/DomainObjectNamespace.php b/src/ValueObjects/DomainObjectNamespace.php index 2a92bc5..23c5240 100644 --- a/src/ValueObjects/DomainObjectNamespace.php +++ b/src/ValueObjects/DomainObjectNamespace.php @@ -23,7 +23,7 @@ public static function make(string $key, string $domain, ?string $subdomain = nu $domainNamespace = implode('\\', [$root, $domainWithSubdomain]); - $namespace = "{$domainNamespace}\\" . config("ddd.namespaces.{$key}", Str::studly($key)); + $namespace = "{$domainNamespace}\\".config("ddd.namespaces.{$key}", Str::studly($key)); return new self(type: $key, namespace: $namespace); } diff --git a/tests/Autoload/FactoryTest.php b/tests/Autoload/FactoryTest.php index 968ee37..482222e 100644 --- a/tests/Autoload/FactoryTest.php +++ b/tests/Autoload/FactoryTest.php @@ -16,7 +16,7 @@ expect($modelClass::factory())->toBeInstanceOf($expectedFactoryClass); })->with([ ['Domain\Invoicing\Models\VanillaModel', 'Domain\Invoicing\Database\Factories\VanillaModelFactory'], - ['Domain\Internal\Reporting\Models\Report', 'Domain\Internal\Reporting\Database\Factories\ReportFactory'] + ['Domain\Internal\Reporting\Models\Report', 'Domain\Internal\Reporting\Database\Factories\ReportFactory'], ]); it('does not affect non-domain model factories', function () { diff --git a/tests/TestCase.php b/tests/TestCase.php index abff1fd..5a6a4a8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -37,7 +37,7 @@ protected function setUp(): void $this->composerReload(); Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Lunarstorm\\LaravelDDD\\Database\\Factories\\' . class_basename($modelName) . 'Factory' + fn (string $modelName) => 'Lunarstorm\\LaravelDDD\\Database\\Factories\\'.class_basename($modelName).'Factory' ); $this->beforeApplicationDestroyed(function () { @@ -82,8 +82,8 @@ protected function cleanFilesAndFolders() public function setupTestApplication() { - File::copyDirectory(__DIR__ . '/resources/app', app_path()); - File::copyDirectory(__DIR__ . '/resources/Domain', base_path('src/Domain')); + File::copyDirectory(__DIR__.'/resources/app', app_path()); + File::copyDirectory(__DIR__.'/resources/Domain', base_path('src/Domain')); File::ensureDirectoryExists(app_path('Models')); } diff --git a/tests/ValueObject/DomainObjectTest.php b/tests/ValueObject/DomainObjectTest.php index f2a73ec..d096477 100644 --- a/tests/ValueObject/DomainObjectTest.php +++ b/tests/ValueObject/DomainObjectTest.php @@ -7,7 +7,7 @@ it('can create a domain object from resolvable class names', function (string $class, $domain, $relativeNamespace, $objectName) { $domainObject = DomainObject::fromClass($class); - $expectedPath = Path::join(DomainResolver::domainPath(), $domain, $relativeNamespace, $objectName . '.php'); + $expectedPath = Path::join(DomainResolver::domainPath(), $domain, $relativeNamespace, $objectName.'.php'); expect($domainObject) ->name->toEqual($objectName) diff --git a/tests/resources/Domain/Invoicing/Policies/InvoicePolicy.php b/tests/resources/Domain/Invoicing/Policies/InvoicePolicy.php index 9a55efd..a4c55f7 100644 --- a/tests/resources/Domain/Invoicing/Policies/InvoicePolicy.php +++ b/tests/resources/Domain/Invoicing/Policies/InvoicePolicy.php @@ -2,9 +2,9 @@ namespace Domain\Invoicing\Policies; -use Illuminate\Auth\Access\HandlesAuthorization; -use Domain\Invoicing\Models\Invoice; use App\Models\User; +use Domain\Invoicing\Models\Invoice; +use Illuminate\Auth\Access\HandlesAuthorization; class InvoicePolicy { From c79cb8bbdb8a07853e6d6a32b8b210ae78c549f6 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Thu, 28 Mar 2024 01:18:38 -0400 Subject: [PATCH 12/26] Initial autoload test coverage. --- composer.json | 1 + src/Listeners/CacheClearSubscriber.php | 15 +-- src/Support/DomainAutoloader.php | 125 +++++++++--------- tests/Autoload/CommandTest.php | 36 +++++ tests/Autoload/PolicyTest.php | 4 +- tests/Autoload/ProviderTest.php | 32 +++++ tests/Autoload/ServiceProviderTest.php | 1 - tests/TestCase.php | 6 +- .../Invoicing/Commands/InvoiceDeliver.php | 24 ++++ .../Domain/Invoicing/Models/Invoice.php | 11 ++ .../Providers/InvoiceServiceProvider.php | 27 ++++ .../resources/app/Commands/InvoiceSecret.php | 18 +++ 12 files changed, 223 insertions(+), 77 deletions(-) create mode 100644 tests/Autoload/CommandTest.php create mode 100644 tests/Autoload/ProviderTest.php delete mode 100644 tests/Autoload/ServiceProviderTest.php create mode 100644 tests/resources/Domain/Invoicing/Commands/InvoiceDeliver.php create mode 100644 tests/resources/Domain/Invoicing/Providers/InvoiceServiceProvider.php create mode 100644 tests/resources/app/Commands/InvoiceSecret.php diff --git a/composer.json b/composer.json index 7e8b478..ef083df 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "php": "^8.1|^8.2|^8.3", "illuminate/contracts": "^10.25|^11.0", "laravel/prompts": "^0.1.16", + "lorisleiva/lody": "^0.5.0", "spatie/laravel-package-tools": "^1.13.0" }, "require-dev": { diff --git a/src/Listeners/CacheClearSubscriber.php b/src/Listeners/CacheClearSubscriber.php index b5909ad..9267bde 100644 --- a/src/Listeners/CacheClearSubscriber.php +++ b/src/Listeners/CacheClearSubscriber.php @@ -2,8 +2,8 @@ namespace Lunarstorm\LaravelDDD\Listeners; -use ErrorException; use Illuminate\Events\Dispatcher; +use Lunarstorm\LaravelDDD\Support\DomainAutoloader; class CacheClearSubscriber { @@ -13,18 +13,7 @@ public function __construct() public function handle(): void { - $files = glob(base_path(config('ddd.cache_directory').'/ddd-*.php')); - - foreach ($files as $file) { - try { - unlink($file); - } catch (ErrorException $exception) { - if (! str_contains($exception->getMessage(), 'No such file or directory')) { - dump($exception->getMessage()); - throw $exception; - } - } - } + DomainAutoloader::clearCache(); } /** diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index f0ec82b..1d1cb31 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -10,11 +10,14 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; +use Lorisleiva\Lody\Lody; use Lunarstorm\LaravelDDD\Factories\DomainFactory; use Lunarstorm\LaravelDDD\ValueObjects\DomainObject; -use ReflectionClass; +use Symfony\Component\Finder\Finder; use Throwable; class DomainAutoloader @@ -53,65 +56,78 @@ public function autoload(): void } } - protected function registerDomainServiceProviders(bool|string|null $domainPath = null): void + public function registerDomainServiceProviders(bool|string|null $domainPath = null): void { - $domainPath = is_string($domainPath) ? $domainPath : '*/*ServiceProvider.php'; + // $domainPath = is_string($domainPath) ? $domainPath : '*/*ServiceProvider.php'; + + // $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath) { + // return Arr::map( + // glob(base_path(DomainResolver::domainPath() . '/' . $domainPath)), + // (static function ($serviceProvider) { + + // return Path::filePathToNamespace( + // $serviceProvider, + // DomainResolver::domainPath(), + // DomainResolver::domainRootNamespace() + // ); + // }) + // ); + // }); + + $domainPath = app()->basePath(DomainResolver::domainPath()); + + if (! is_dir($domainPath)) { + return; + } $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath) { - return Arr::map( - glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), - (static function ($serviceProvider) { - - return Path::filePathToNamespace( - $serviceProvider, - DomainResolver::domainPath(), - DomainResolver::domainRootNamespace() - ); - }) - ); + $finder = Finder::create()->files()->in($domainPath); + + return Lody::classesFromFinder($finder) + ->isNotAbstract() + ->isInstanceOf(ServiceProvider::class) + ->toArray(); }); $app = app(); + foreach ($serviceProviders as $serviceProvider) { $app->register($serviceProvider); } } - protected function registerDomainCommands(bool|string|null $domainPath = null): void + public function registerDomainCommands(bool|string|null $domainPath = null): void { - $domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php'; + // $domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php'; + + $domainPath = app()->basePath(DomainResolver::domainPath()); + + if (! is_dir($domainPath)) { + return; + } + $commands = $this->remember('ddd-domain-commands', static function () use ($domainPath) { - $commands = Arr::map( - glob(base_path(DomainResolver::domainPath().'/'.$domainPath)), - static function ($command) { - return Path::filePathToNamespace( - $command, - DomainResolver::domainPath(), - DomainResolver::domainRootNamespace() - ); - } - ); + $finder = Finder::create()->files()->in($domainPath); - // Filter out invalid commands (Abstract classes and classes not extending Illuminate\Console\Command) - return Arr::where($commands, static function ($command) { - if ( - is_subclass_of($command, Command::class) && - ! (new ReflectionClass($command))->isAbstract() - ) { - ConsoleApplication::starting(static function ($artisan) use ($command): void { - $artisan->resolve($command); - }); - } - }); + return Lody::classesFromFinder($finder) + ->isNotAbstract() + ->isInstanceOf(Command::class) + ->toArray(); }); - ConsoleApplication::starting(static function ($artisan) use ($commands): void { - foreach ($commands as $command) { - $artisan->resolve($command); - } + + foreach ($commands as $class) { + $this->registerCommand($class); + } + } + + public function registerCommand($class) + { + ConsoleApplication::starting(function ($artisan) use ($class) { + $artisan->resolve($class); }); } - protected function registerPolicies(bool|string|null $domainPath = null): void + public function registerPolicies(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : 'Policies\\{model}Policy'; @@ -136,7 +152,7 @@ protected function registerPolicies(bool|string|null $domainPath = null): void }); } - protected function registerFactories(bool|string|null $domainPath = null): void + public function registerFactories(bool|string|null $domainPath = null): void { $domainPath = is_string($domainPath) ? $domainPath : 'Database\\Factories\\{model}Factory'; @@ -155,22 +171,6 @@ protected function registerFactories(bool|string|null $domainPath = null): void }); } - protected function extractDomainAndModelFromModelNamespace(string $modelName): array - { - // Matches \{domain}\\{model} and extracts domain and model - // For example: Domain\Invoicing\Models\Invoice gives ['domain' => 'Invoicing', 'model' => 'Invoice'] - $regex = '/'.DomainResolver::domainRootNamespace().'\\\\(?.+)\\\\'.$this->configValue('namespaces.models').'\\\\(?.+)/'; - - if (preg_match($regex, $modelName, $matches, PREG_OFFSET_CAPTURE, 0)) { - return [ - 'domain' => $matches['domain'][0], - 'model' => $matches['model'][0], - ]; - } - - return []; - } - protected function remember($fileName, $callback) { // The cache is not available during booting, so we need to roll our own file based cache @@ -190,6 +190,13 @@ protected function remember($fileName, $callback) return $data; } + public static function clearCache() + { + $files = glob(base_path(config('ddd.cache_directory').'/ddd-*.php')); + + File::delete($files); + } + protected static function appNamespace() { try { diff --git a/tests/Autoload/CommandTest.php b/tests/Autoload/CommandTest.php new file mode 100644 index 0000000..fb0aab1 --- /dev/null +++ b/tests/Autoload/CommandTest.php @@ -0,0 +1,36 @@ +setupTestApplication(); + + DomainAutoloader::clearCache(); +}); + +describe('without autoload', function () { + it('does not register the command', function () { + expect(class_exists('Domain\Invoicing\Commands\InvoiceDeliver'))->toBeTrue(); + expect(fn () => Artisan::call('invoice:deliver'))->toThrow(CommandNotFoundException::class); + }); +}); + +describe('with autoload', function () { + beforeEach(function () { + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); + + it('registers the command', function () { + expect(class_exists('Domain\Invoicing\Commands\InvoiceDeliver'))->toBeTrue(); + Artisan::call('invoice:deliver'); + expect(Artisan::output())->toContain('Invoice delivered!'); + }); +}); diff --git a/tests/Autoload/PolicyTest.php b/tests/Autoload/PolicyTest.php index 9f1a621..895d17a 100644 --- a/tests/Autoload/PolicyTest.php +++ b/tests/Autoload/PolicyTest.php @@ -9,7 +9,9 @@ Config::set('ddd.domain_namespace', 'Domain'); - (new DomainAutoloader())->autoload(); + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); }); it('can autoload domain policy', function ($class, $expectedPolicy) { diff --git a/tests/Autoload/ProviderTest.php b/tests/Autoload/ProviderTest.php new file mode 100644 index 0000000..e292e4e --- /dev/null +++ b/tests/Autoload/ProviderTest.php @@ -0,0 +1,32 @@ +setupTestApplication(); + + DomainAutoloader::clearCache(); +}); + +describe('without autoload', function () { + it('does not register the provider', function () { + expect(fn () => app('invoicing'))->toThrow(Exception::class); + }); +}); + +describe('with autoload', function () { + beforeEach(function () { + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); + + it('registers the provider', function () { + expect(app('invoicing'))->toEqual('invoicing-singleton'); + $this->artisan('invoice:deliver')->expectsOutputToContain('invoice-secret'); + }); +}); diff --git a/tests/Autoload/ServiceProviderTest.php b/tests/Autoload/ServiceProviderTest.php deleted file mode 100644 index b3d9bbc..0000000 --- a/tests/Autoload/ServiceProviderTest.php +++ /dev/null @@ -1 +0,0 @@ -cleanFilesAndFolders(); - (new CacheClearSubscriber())->handle(); - $composerFile = base_path('composer.json'); $data = json_decode(file_get_contents($composerFile), true); @@ -78,6 +76,8 @@ protected function cleanFilesAndFolders() File::deleteDirectory(base_path('Custom')); File::deleteDirectory(base_path('src/Domain')); File::deleteDirectory(base_path('src/Domains')); + + DomainAutoloader::clearCache(); } public function setupTestApplication() diff --git a/tests/resources/Domain/Invoicing/Commands/InvoiceDeliver.php b/tests/resources/Domain/Invoicing/Commands/InvoiceDeliver.php new file mode 100644 index 0000000..9abd7c2 --- /dev/null +++ b/tests/resources/Domain/Invoicing/Commands/InvoiceDeliver.php @@ -0,0 +1,24 @@ +info('Invoice delivered!'); + + if ($secret = Invoice::getSecret()) { + $this->line($secret); + + return; + } + } +} diff --git a/tests/resources/Domain/Invoicing/Models/Invoice.php b/tests/resources/Domain/Invoicing/Models/Invoice.php index 66a6068..99d08e1 100644 --- a/tests/resources/Domain/Invoicing/Models/Invoice.php +++ b/tests/resources/Domain/Invoicing/Models/Invoice.php @@ -6,4 +6,15 @@ class Invoice extends DomainModel { + protected static $secret = null; + + public static function setSecret($secret): void + { + self::$secret = $secret; + } + + public static function getSecret(): ?string + { + return self::$secret; + } } diff --git a/tests/resources/Domain/Invoicing/Providers/InvoiceServiceProvider.php b/tests/resources/Domain/Invoicing/Providers/InvoiceServiceProvider.php new file mode 100644 index 0000000..5e257e5 --- /dev/null +++ b/tests/resources/Domain/Invoicing/Providers/InvoiceServiceProvider.php @@ -0,0 +1,27 @@ +app->singleton('invoicing', function (Application $app) { + return 'invoicing-singleton'; + }); + } + + /** + * Bootstrap any application services. + * + * @return void + */ + public function boot() + { + Invoice::setSecret('invoice-secret'); + } +} diff --git a/tests/resources/app/Commands/InvoiceSecret.php b/tests/resources/app/Commands/InvoiceSecret.php new file mode 100644 index 0000000..5798f48 --- /dev/null +++ b/tests/resources/app/Commands/InvoiceSecret.php @@ -0,0 +1,18 @@ +line(Invoice::getSecret() ?? 'Invoice secret not set.'); + } +} From 0f2fc85d4c7df576aebadf88de92af5857a89e4c Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Thu, 28 Mar 2024 09:09:06 -0400 Subject: [PATCH 13/26] A few cleanups. --- config/ddd.php | 18 +++--- src/Support/DomainAutoloader.php | 96 +++++++++++++------------------- tests/Autoload/FactoryTest.php | 2 +- tests/Autoload/PolicyTest.php | 2 +- 4 files changed, 50 insertions(+), 68 deletions(-) diff --git a/config/ddd.php b/config/ddd.php index d7c6e61..83adb2f 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -71,33 +71,31 @@ 'autoload' => [ /* | Autoload service providers from the domain namespace. - | By default, it loads any file that ends with 'ServiceProvider.php' inside your domain. + | By default, it loads any non-abstract class inside the domain layer extending Illuminate\Support\ServiceProvider. | For example: Domain/Invoicing/Providers/InvoicingServiceProvider.php or Domain/Invoicing/InvoicingServiceProvider.php */ - // To customize the pattern, you can use a glob pattern like '*/Providers/*.php' - 'service_providers' => '*/*ServiceProvider.php', + 'service_providers' => true, /* | Autoload commands from the domain namespace. - | By default, it loads any file inside the /Commands folder that ends '.php', extends Illuminate\Console\Command and is not abstract. + | By default, it loads any non-abstract class inside the domain layer extending Illuminate\Console\Command. | For example: Domain/Invoicing/Commands/CreateInvoiceCommand.php */ - // To customize the pattern, you can use a glob pattern like '*/Commands/*.php' - 'commands' => '*/Commands/*.php', + 'commands' => true, /* | Autoload policies from the domain namespace. - | By default, it loads any file inside the /Policies folder that ends 'Policy.php' and is not abstract. + | By default, it uses the configured `ddd.namespaces.policy` namespace to guess the policy name. | For example: Domain/Invoicing/Policies/InvoicePolicy.php */ - 'policies' => 'Policies\\{model}Policy', + 'policies' => true, /* | Autoload factories from the domain namespace. - | By default, it loads any file inside the /Database/Factories folder that ends 'Factory.php' and is not abstract. + | By default, it uses the configured `ddd.namespaces.factory` namespace to guess the factory name. | For example: Domain/Invoicing/Database/Factories/InvoiceFactory.php */ - 'factories' => 'Database\\Factories\\{model}Factory', + 'factories' => true, ], /* diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index 1d1cb31..625df50 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -9,7 +9,6 @@ use Illuminate\Foundation\Application; use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -40,48 +39,39 @@ protected function configValue($path) public function autoload(): void { if ($value = $this->configValue('autoload.service_providers')) { - $this->registerDomainServiceProviders($value); + $this->registerProviders($value); } if ($value = $this->configValue('autoload.commands')) { - $this->registerDomainCommands($value); + $this->registerCommands($value); } - if ($value = $this->configValue('autoload.policies')) { - $this->registerPolicies($value); + if ($this->configValue('autoload.policies') === true) { + $this->registerPolicies(); } - if ($value = $this->configValue('autoload.factories')) { - $this->registerFactories($value); + if ($this->configValue('autoload.factories') === true) { + $this->registerFactories(); } } - public function registerDomainServiceProviders(bool|string|null $domainPath = null): void + protected function normalizePaths($path): array { - // $domainPath = is_string($domainPath) ? $domainPath : '*/*ServiceProvider.php'; - - // $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath) { - // return Arr::map( - // glob(base_path(DomainResolver::domainPath() . '/' . $domainPath)), - // (static function ($serviceProvider) { - - // return Path::filePathToNamespace( - // $serviceProvider, - // DomainResolver::domainPath(), - // DomainResolver::domainRootNamespace() - // ); - // }) - // ); - // }); - - $domainPath = app()->basePath(DomainResolver::domainPath()); - - if (! is_dir($domainPath)) { - return; - } + return collect($path) + ->filter(fn ($path) => is_dir($path)) + ->toArray(); + } + + protected function registerProviders(bool|string|array|null $path = null): void + { + $paths = $this->normalizePaths($path === true ? app()->basePath(DomainResolver::domainPath()) : $path); + + $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($paths) { + if(empty($paths)) { + return []; + } - $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($domainPath) { - $finder = Finder::create()->files()->in($domainPath); + $finder = Finder::create()->files()->in($paths); return Lody::classesFromFinder($finder) ->isNotAbstract() @@ -96,18 +86,16 @@ public function registerDomainServiceProviders(bool|string|null $domainPath = nu } } - public function registerDomainCommands(bool|string|null $domainPath = null): void + protected function registerCommands(bool|string|array|null $path = null): void { - // $domainPath = is_string($domainPath) ? $domainPath : '*/Commands/*.php'; + $paths = $this->normalizePaths($path === true ? app()->basePath(DomainResolver::domainPath()) : $path); - $domainPath = app()->basePath(DomainResolver::domainPath()); - - if (! is_dir($domainPath)) { - return; - } + $commands = $this->remember('ddd-domain-commands', static function () use ($paths) { + if(empty($paths)) { + return []; + } - $commands = $this->remember('ddd-domain-commands', static function () use ($domainPath) { - $finder = Finder::create()->files()->in($domainPath); + $finder = Finder::create()->files()->in($paths); return Lody::classesFromFinder($finder) ->isNotAbstract() @@ -120,18 +108,16 @@ public function registerDomainCommands(bool|string|null $domainPath = null): voi } } - public function registerCommand($class) + protected function registerCommand($class) { ConsoleApplication::starting(function ($artisan) use ($class) { $artisan->resolve($class); }); } - public function registerPolicies(bool|string|null $domainPath = null): void + protected function registerPolicies(): void { - $domainPath = is_string($domainPath) ? $domainPath : 'Policies\\{model}Policy'; - - Gate::guessPolicyNamesUsing(static function (string $class): array|string|null { + Gate::guessPolicyNamesUsing(static function (string $class): array|string { if ($model = DomainObject::fromClass($class, 'model')) { return (new Domain($model->domain)) ->object('policy', "{$model->name}Policy") @@ -145,17 +131,15 @@ public function registerPolicies(bool|string|null $domainPath = null): void return Arr::wrap(Collection::times(count($classDirnameSegments), function ($index) use ($class, $classDirnameSegments) { $classDirname = implode('\\', array_slice($classDirnameSegments, 0, $index)); - return $classDirname.'\\Policies\\'.class_basename($class).'Policy'; + return $classDirname . '\\Policies\\' . class_basename($class) . 'Policy'; })->reverse()->values()->first(function ($class) { return class_exists($class); - }) ?: [$classDirname.'\\Policies\\'.class_basename($class).'Policy']); + }) ?: [$classDirname . '\\Policies\\' . class_basename($class) . 'Policy']); }); } - public function registerFactories(bool|string|null $domainPath = null): void + protected function registerFactories(): void { - $domainPath = is_string($domainPath) ? $domainPath : 'Database\\Factories\\{model}Factory'; - Factory::guessFactoryNamesUsing(function (string $modelName) { if (DomainResolver::isDomainClass($modelName)) { return DomainFactory::factoryForModel($modelName); @@ -163,18 +147,18 @@ public function registerFactories(bool|string|null $domainPath = null): void $appNamespace = static::appNamespace(); - $modelName = Str::startsWith($modelName, $appNamespace.'Models\\') - ? Str::after($modelName, $appNamespace.'Models\\') + $modelName = Str::startsWith($modelName, $appNamespace . 'Models\\') + ? Str::after($modelName, $appNamespace . 'Models\\') : Str::after($modelName, $appNamespace); - return 'Database\\Factories\\'.$modelName.'Factory'; + return 'Database\\Factories\\' . $modelName . 'Factory'; }); } protected function remember($fileName, $callback) { // The cache is not available during booting, so we need to roll our own file based cache - $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); + $cacheFilePath = base_path($this->cacheDirectory . '/' . $fileName . '.php'); $data = file_exists($cacheFilePath) ? include $cacheFilePath : null; @@ -183,7 +167,7 @@ protected function remember($fileName, $callback) file_put_contents( $cacheFilePath, - 'toBeTrue(); expect(Gate::getPolicyFor($class))->toBeInstanceOf($expectedPolicy); })->with([ From bde21b0fac3bcb2a737de568995402229b0fc46a Mon Sep 17 00:00:00 2001 From: JasperTey Date: Thu, 28 Mar 2024 13:09:26 +0000 Subject: [PATCH 14/26] Fix styling --- src/Support/DomainAutoloader.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index 625df50..fe867cd 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -67,7 +67,7 @@ protected function registerProviders(bool|string|array|null $path = null): void $paths = $this->normalizePaths($path === true ? app()->basePath(DomainResolver::domainPath()) : $path); $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($paths) { - if(empty($paths)) { + if (empty($paths)) { return []; } @@ -91,7 +91,7 @@ protected function registerCommands(bool|string|array|null $path = null): void $paths = $this->normalizePaths($path === true ? app()->basePath(DomainResolver::domainPath()) : $path); $commands = $this->remember('ddd-domain-commands', static function () use ($paths) { - if(empty($paths)) { + if (empty($paths)) { return []; } @@ -131,10 +131,10 @@ protected function registerPolicies(): void return Arr::wrap(Collection::times(count($classDirnameSegments), function ($index) use ($class, $classDirnameSegments) { $classDirname = implode('\\', array_slice($classDirnameSegments, 0, $index)); - return $classDirname . '\\Policies\\' . class_basename($class) . 'Policy'; + return $classDirname.'\\Policies\\'.class_basename($class).'Policy'; })->reverse()->values()->first(function ($class) { return class_exists($class); - }) ?: [$classDirname . '\\Policies\\' . class_basename($class) . 'Policy']); + }) ?: [$classDirname.'\\Policies\\'.class_basename($class).'Policy']); }); } @@ -147,18 +147,18 @@ protected function registerFactories(): void $appNamespace = static::appNamespace(); - $modelName = Str::startsWith($modelName, $appNamespace . 'Models\\') - ? Str::after($modelName, $appNamespace . 'Models\\') + $modelName = Str::startsWith($modelName, $appNamespace.'Models\\') + ? Str::after($modelName, $appNamespace.'Models\\') : Str::after($modelName, $appNamespace); - return 'Database\\Factories\\' . $modelName . 'Factory'; + return 'Database\\Factories\\'.$modelName.'Factory'; }); } protected function remember($fileName, $callback) { // The cache is not available during booting, so we need to roll our own file based cache - $cacheFilePath = base_path($this->cacheDirectory . '/' . $fileName . '.php'); + $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); $data = file_exists($cacheFilePath) ? include $cacheFilePath : null; @@ -167,7 +167,7 @@ protected function remember($fileName, $callback) file_put_contents( $cacheFilePath, - ' Date: Thu, 28 Mar 2024 09:14:53 -0400 Subject: [PATCH 15/26] Simplify autoload.service_providers to autoload.providers --- config/ddd.php | 2 +- src/Support/DomainAutoloader.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/ddd.php b/config/ddd.php index 83adb2f..9b99c85 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -74,7 +74,7 @@ | By default, it loads any non-abstract class inside the domain layer extending Illuminate\Support\ServiceProvider. | For example: Domain/Invoicing/Providers/InvoicingServiceProvider.php or Domain/Invoicing/InvoicingServiceProvider.php */ - 'service_providers' => true, + 'providers' => true, /* | Autoload commands from the domain namespace. diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index fe867cd..300c42e 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -38,7 +38,7 @@ protected function configValue($path) public function autoload(): void { - if ($value = $this->configValue('autoload.service_providers')) { + if ($value = $this->configValue('autoload.providers')) { $this->registerProviders($value); } From fea680f22ff50a4b5790de3ed9adfcda8459d3a4 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Thu, 28 Mar 2024 09:37:02 -0400 Subject: [PATCH 16/26] Unused import. --- src/ValueObjects/DomainObject.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ValueObjects/DomainObject.php b/src/ValueObjects/DomainObject.php index 63308a2..7f8bddc 100644 --- a/src/ValueObjects/DomainObject.php +++ b/src/ValueObjects/DomainObject.php @@ -3,7 +3,6 @@ namespace Lunarstorm\LaravelDDD\ValueObjects; use Illuminate\Support\Str; -use Lunarstorm\LaravelDDD\Support\Domain; use Lunarstorm\LaravelDDD\Support\DomainResolver; use Lunarstorm\LaravelDDD\Support\Path; From bd1a5fa4b64a5223a3f71b01d7abb675a68d55f3 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Thu, 28 Mar 2024 09:46:03 -0400 Subject: [PATCH 17/26] Bump Pest version. --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ef083df..c7662f5 100644 --- a/composer.json +++ b/composer.json @@ -25,11 +25,11 @@ "spatie/laravel-package-tools": "^1.13.0" }, "require-dev": { + "larastan/larastan": "^2.0.1", "laravel/pint": "^1.0", "nunomaduro/collision": "^7.0|^8.1", - "larastan/larastan": "^2.0.1", "orchestra/testbench": "^8|^9.0", - "pestphp/pest": "^2.0", + "pestphp/pest": "^2.34", "pestphp/pest-plugin-laravel": "^2.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", From c6e096a98e76214a38e5a47a0d4d2b0efed1ebeb Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Thu, 28 Mar 2024 09:56:38 -0400 Subject: [PATCH 18/26] Cleanup. --- composer.json | 3 +-- .../Invoicing/Database/Factories/VanillaModelFactory.php | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/composer.json b/composer.json index c7662f5..2594bd3 100644 --- a/composer.json +++ b/composer.json @@ -47,8 +47,7 @@ "App\\": "vendor/orchestra/testbench-core/laravel/app/", "Database\\Factories\\": "vendor/orchestra/testbench-core/laravel/database/factories/", "Database\\Seeders\\": "vendor/orchestra/testbench-core/laravel/database/seeders/", - "Domain\\": "vendor/orchestra/testbench-core/laravel/src/Domain/", - "TestDomain\\": "tests/Fixtures/Domain" + "Domain\\": "vendor/orchestra/testbench-core/laravel/src/Domain/" } }, "scripts": { diff --git a/tests/resources/Domain/Invoicing/Database/Factories/VanillaModelFactory.php b/tests/resources/Domain/Invoicing/Database/Factories/VanillaModelFactory.php index 6c18539..badcbf9 100644 --- a/tests/resources/Domain/Invoicing/Database/Factories/VanillaModelFactory.php +++ b/tests/resources/Domain/Invoicing/Database/Factories/VanillaModelFactory.php @@ -6,8 +6,6 @@ class VanillaModelFactory extends Factory { - // protected $model = Driver::class; - public function definition() { return []; From 91fe23f97f4473a2256271fcf91cc44594de96b8 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Thu, 28 Mar 2024 17:02:27 -0400 Subject: [PATCH 19/26] Update tests. --- composer.json | 8 +- tests/Autoload/CommandTest.php | 44 +++++++ tests/Autoload/FactoryTest.php | 62 +++++++--- tests/InstallTest.php | 7 ++ tests/TestCase.php | 107 +++++++++++++++--- .../Database/Factories/InvoiceFactory.php | 13 +++ .../Domain/Invoicing/Models/Payment.php | 9 ++ .../factories/Invoicing/InvoiceFactory.php | 13 +++ .../factories/Invoicing/PaymentFactory.php | 13 +++ .../factories/VanillaModelFactory.php | 13 +++ 10 files changed, 256 insertions(+), 33 deletions(-) create mode 100644 tests/resources/Domain/Invoicing/Database/Factories/InvoiceFactory.php create mode 100644 tests/resources/Domain/Invoicing/Models/Payment.php create mode 100644 tests/resources/database/factories/Invoicing/InvoiceFactory.php create mode 100644 tests/resources/database/factories/Invoicing/PaymentFactory.php create mode 100644 tests/resources/database/factories/VanillaModelFactory.php diff --git a/composer.json b/composer.json index 2594bd3..c1ae6cc 100644 --- a/composer.json +++ b/composer.json @@ -44,10 +44,10 @@ "autoload-dev": { "psr-4": { "Lunarstorm\\LaravelDDD\\Tests\\": "tests", - "App\\": "vendor/orchestra/testbench-core/laravel/app/", - "Database\\Factories\\": "vendor/orchestra/testbench-core/laravel/database/factories/", - "Database\\Seeders\\": "vendor/orchestra/testbench-core/laravel/database/seeders/", - "Domain\\": "vendor/orchestra/testbench-core/laravel/src/Domain/" + "App\\": "vendor/orchestra/testbench-core/laravel/app", + "Database\\Factories\\": "vendor/orchestra/testbench-core/laravel/database/factories", + "Database\\Seeders\\": "vendor/orchestra/testbench-core/laravel/database/seeders", + "Domain\\": "vendor/orchestra/testbench-core/laravel/src/Domain" } }, "scripts": { diff --git a/tests/Autoload/CommandTest.php b/tests/Autoload/CommandTest.php index fb0aab1..6a8edd6 100644 --- a/tests/Autoload/CommandTest.php +++ b/tests/Autoload/CommandTest.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\File; use Lunarstorm\LaravelDDD\Support\DomainAutoloader; use Symfony\Component\Console\Exception\CommandNotFoundException; @@ -33,4 +34,47 @@ Artisan::call('invoice:deliver'); expect(Artisan::output())->toContain('Invoice delivered!'); }); + + it('recognizes new commands created afterwards', function () { + expect(class_exists('Domain\Invoicing\Commands\InvoiceVoid'))->toBeFalse(); + + Artisan::call('ddd:command', [ + 'name' => 'InvoiceVoid', + '--domain' => 'Invoicing', + ]); + + $filepath = base_path('src/Domain/Invoicing/Commands/InvoiceVoid.php'); + + expect(file_exists($filepath))->toBeTrue(); + + $class = 'Domain\Invoicing\Commands\InvoiceVoid'; + + // dd( + // [ + // // pre-created files work fine + // 'App\Models\User' => [ + // 'path' => base_path('app/Models/User.php'), + // 'file_exists' => file_exists(base_path('app/Models/User.php')), + // 'class_exists' => class_exists('App\Models\User'), + // ], + + // 'Domain\Invoicing\Models\Invoice' => [ + // 'path' => base_path('src/Domain/Invoicing/Models/Invoice.php'), + // 'file_exists' => file_exists(base_path('src/Domain/Invoicing/Models/Invoice.php')), + // 'class_exists' => class_exists('Domain\Invoicing\Models\Invoice'), + // ], + + // // but runtime-created class created but not recognized by class_exists + // $class => [ + // 'path' => $filepath, + // 'file_exists' => file_exists($filepath), + // 'class_exists' => class_exists($class), + // ], + // ], + // ); + + $instance = new $class(); + + expect(class_exists($class))->toBeTrue(); + })->markTestIncomplete("Can't get this to work under test environment"); }); diff --git a/tests/Autoload/FactoryTest.php b/tests/Autoload/FactoryTest.php index 3079a85..cdae53e 100644 --- a/tests/Autoload/FactoryTest.php +++ b/tests/Autoload/FactoryTest.php @@ -8,24 +8,58 @@ $this->setupTestApplication(); Config::set('ddd.domain_namespace', 'Domain'); - - (new DomainAutoloader())->autoload(); }); -it('can autoload domain factory', function ($modelClass, $expectedFactoryClass) { - expect($modelClass::factory())->toBeInstanceOf($expectedFactoryClass); -})->with([ - ['Domain\Invoicing\Models\VanillaModel', 'Domain\Invoicing\Database\Factories\VanillaModelFactory'], - ['Domain\Internal\Reporting\Models\Report', 'Domain\Internal\Reporting\Database\Factories\ReportFactory'], -]); +describe('autoload enabled', function () { + beforeEach(function () { + Config::set('ddd.autoload.factories', true); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); + + it('can resolve domain factory', function ($modelClass, $expectedFactoryClass) { + expect($modelClass::factory())->toBeInstanceOf($expectedFactoryClass); + })->with([ + // VanillaModel is a vanilla eloquent model in the domain layer + ['Domain\Invoicing\Models\VanillaModel', 'Domain\Invoicing\Database\Factories\VanillaModelFactory'], + + // Invoice has a factory both in the domain layer and the old way, but domain layer should take precedence + ['Domain\Invoicing\Models\Invoice', 'Domain\Invoicing\Database\Factories\InvoiceFactory'], + + // Payment has a factory not in the domain layer (the old way) + ['Domain\Invoicing\Models\Payment', 'Database\Factories\Invoicing\PaymentFactory'], -it('gracefully falls back for non-domain factories', function () { - Artisan::call('make:model RegularModel -f'); + // A subdomain Internal\Reporting scenario + ['Domain\Internal\Reporting\Models\Report', 'Domain\Internal\Reporting\Database\Factories\ReportFactory'], + ]); + + it('gracefully falls back for non-domain factories', function () { + Artisan::call('make:model RegularModel -f'); + + $modelClass = 'App\Models\RegularModel'; + + expect(class_exists($modelClass))->toBeTrue(); + + expect($modelClass::factory()) + ->toBeInstanceOf('Database\Factories\RegularModelFactory'); + }); +}); - $modelClass = 'App\Models\RegularModel'; +describe('autoload disabled', function () { + beforeEach(function () { + Config::set('ddd.autoload.factories', false); - expect(class_exists($modelClass))->toBeTrue(); + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); - expect($modelClass::factory()) - ->toBeInstanceOf('Database\Factories\RegularModelFactory'); + it('cannot resolve factories that rely on autoloading', function ($modelClass) { + $modelClass::factory(); + })->throws(\Error::class)->with([ + ['Domain\Invoicing\Models\VanillaModel'], + ['Domain\Internal\Reporting\Models\Report'], + ]); }); diff --git a/tests/InstallTest.php b/tests/InstallTest.php index ae94877..dcb58e1 100644 --- a/tests/InstallTest.php +++ b/tests/InstallTest.php @@ -25,6 +25,13 @@ }); it('can initialize composer.json', function ($domainPath, $domainRoot) { + $this->updateComposer( + forget: [ + ['autoload', 'psr-4', 'Domains\\'], + ['autoload', 'psr-4', 'Domain\\'], + ] + ); + Config::set('ddd.domain_path', $domainPath); Config::set('ddd.domain_namespace', $domainRoot); diff --git a/tests/TestCase.php b/tests/TestCase.php index 2ed3fab..fdddabe 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -18,21 +18,35 @@ protected function setUp(): void $this->cleanFilesAndFolders(); - $composerFile = base_path('composer.json'); - $data = json_decode(file_get_contents($composerFile), true); - - // Reset the domain namespace - Arr::forget($data, ['autoload', 'psr-4', 'Domains\\']); - Arr::forget($data, ['autoload', 'psr-4', 'Domain\\']); - - // Set up the essential app namespaces - data_set($data, ['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'); - data_set($data, ['autoload', 'psr-4', 'Database\\Factories\\'], 'vendor/orchestra/testbench-core/laravel/database/factories'); - data_set($data, ['autoload', 'psr-4', 'Database\\Seeders\\'], 'vendor/orchestra/testbench-core/laravel/database/seeders'); - - file_put_contents($composerFile, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + // $composerFile = base_path('composer.json'); + // $data = json_decode(file_get_contents($composerFile), true); + + // // Reset the domain namespace + // Arr::forget($data, ['autoload', 'psr-4', 'Domains\\']); + // Arr::forget($data, ['autoload', 'psr-4', 'Domain\\']); + + // // Set up the essential app namespaces + // data_set($data, ['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'); + // data_set($data, ['autoload', 'psr-4', 'Database\\Factories\\'], 'vendor/orchestra/testbench-core/laravel/database/factories'); + // data_set($data, ['autoload', 'psr-4', 'Database\\Seeders\\'], 'vendor/orchestra/testbench-core/laravel/database/seeders'); + + // file_put_contents($composerFile, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + + $this->updateComposer( + [ + [['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'], + [['autoload', 'psr-4', 'Database\\Factories\\'], 'vendor/orchestra/testbench-core/laravel/database/factories'], + [['autoload', 'psr-4', 'Database\\Seeders\\'], 'vendor/orchestra/testbench-core/laravel/database/seeders'], + [['autoload', 'psr-4', 'Domain\\'], 'vendor/orchestra/testbench-core/laravel/src/Domain'], + ], + forget: [ + ['autoload', 'psr-4', 'Domains\\'], + ['autoload', 'psr-4', 'Domain\\'], + ] + ); - $this->composerReload(); + // This may not be needed after all (significantly slows down the tests) + // $this->composerReload(); Factory::guessFactoryNamesUsing( fn (string $modelName) => 'Lunarstorm\\LaravelDDD\\Database\\Factories\\'.class_basename($modelName).'Factory' @@ -43,6 +57,55 @@ protected function setUp(): void }); } + protected function getComposerFileContents() + { + return file_get_contents(base_path('composer.json')); + } + + protected function getComposerFileAsArray() + { + return json_decode($this->getComposerFileContents(), true); + } + + protected function updateComposerFileFromArray(array $data) + { + file_put_contents(base_path('composer.json'), json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + + return $this; + } + + protected function updateComposer($set = [], $forget = []) + { + $data = $this->getComposerFileAsArray(); + + foreach ($forget as $key) { + Arr::forget($data, $key); + } + + foreach ($set as $pair) { + [$key, $value] = $pair; + data_set($data, $key, $value); + } + + $this->updateComposerFileFromArray($data); + + return $this; + } + + protected function forgetComposerValues($keys) + { + $composerFile = base_path('composer.json'); + $data = json_decode(file_get_contents($composerFile), true); + + foreach ($keys as $key) { + Arr::forget($data, $key); + } + + file_put_contents($composerFile, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + + return $this; + } + protected function getPackageProviders($app) { return [ @@ -80,11 +143,25 @@ protected function cleanFilesAndFolders() DomainAutoloader::clearCache(); } - public function setupTestApplication() + protected function setupTestApplication() { File::copyDirectory(__DIR__.'/resources/app', app_path()); + File::copyDirectory(__DIR__.'/resources/database', base_path('database')); File::copyDirectory(__DIR__.'/resources/Domain', base_path('src/Domain')); File::ensureDirectoryExists(app_path('Models')); + + $this->setDomainPathInComposer('Domain', 'src/Domain'); + } + + protected function setDomainPathInComposer($domainNamespace, $domainPath) + { + $this->updateComposer( + set: [ + [['autoload', 'psr-4', $domainNamespace.'\\'], $domainPath], + ], + ); + + return $this; } } diff --git a/tests/resources/Domain/Invoicing/Database/Factories/InvoiceFactory.php b/tests/resources/Domain/Invoicing/Database/Factories/InvoiceFactory.php new file mode 100644 index 0000000..9cad4a0 --- /dev/null +++ b/tests/resources/Domain/Invoicing/Database/Factories/InvoiceFactory.php @@ -0,0 +1,13 @@ + Date: Thu, 28 Mar 2024 19:16:32 -0400 Subject: [PATCH 20/26] No need to keep iterating after the first match. --- src/ValueObjects/DomainObject.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ValueObjects/DomainObject.php b/src/ValueObjects/DomainObject.php index 7f8bddc..c4ca814 100644 --- a/src/ValueObjects/DomainObject.php +++ b/src/ValueObjects/DomainObject.php @@ -47,6 +47,8 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp $objectNamespace = str(data_get($matches, 0))->beforeLast('\\')->toString(); $objectType = $type; + + break; } // If there wasn't a recognized namespace, we'll assume it's a From cfb5073fb4a3d6c5cdbc67ababa695da40cf48c7 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Fri, 29 Mar 2024 23:29:35 -0400 Subject: [PATCH 21/26] Further refinements: - refactor autoload internals for better testability - make autoload caching opt-in via ddd:cache, otherwise the initial cache will never be busted - clean up tests --- config/ddd.php | 62 ++++---- src/Commands/CacheClearCommand.php | 20 +++ src/Commands/CacheCommand.php | 24 +++ src/Commands/DomainFactoryMakeCommand.php | 4 +- src/LaravelDDDServiceProvider.php | 3 + src/Support/Domain.php | 4 +- src/Support/DomainAutoloader.php | 145 +++++++++--------- src/Support/DomainCache.php | 63 ++++++++ src/ValueObjects/DomainObject.php | 4 +- .../app/Commands/InvoiceSecret.php | 0 .../app/Models/Post.php | 0 .../app/Models/User.php | 0 .../app/Policies/PostPolicy.php | 0 tests/.skeleton/composer.json | 25 +++ .../factories/Invoicing/InvoiceFactory.php | 0 .../factories/Invoicing/PaymentFactory.php | 0 .../factories/VanillaModelFactory.php | 0 .../Database/Factories/ReportFactory.php | 0 .../Internal/Reporting/Models/Report.php | 0 .../Invoicing/Commands/InvoiceDeliver.php | 0 .../Database/Factories/InvoiceFactory.php | 0 .../Factories/VanillaModelFactory.php | 0 .../src}/Domain/Invoicing/Models/Invoice.php | 0 .../src}/Domain/Invoicing/Models/Payment.php | 0 .../Domain/Invoicing/Models/VanillaModel.php | 0 .../Invoicing/Policies/InvoicePolicy.php | 0 .../Providers/InvoiceServiceProvider.php | 0 tests/Autoload/CommandTest.php | 94 +++++++----- tests/Autoload/FactoryTest.php | 4 +- tests/Autoload/PolicyTest.php | 1 + tests/Autoload/ProviderTest.php | 49 +++++- tests/Command/CacheTest.php | 42 +++++ tests/Generator/MakeFactoryTest.php | 2 +- tests/Generator/MakeModelTest.php | 2 +- tests/InstallTest.php | 3 +- tests/Pest.php | 5 + tests/Support/AutoloaderTest.php | 13 ++ tests/Support/CacheTest.php | 39 +++++ tests/Support/DomainTest.php | 14 +- tests/TestCase.php | 100 ++++++------ 40 files changed, 513 insertions(+), 209 deletions(-) create mode 100644 src/Commands/CacheClearCommand.php create mode 100644 src/Commands/CacheCommand.php create mode 100644 src/Support/DomainCache.php rename tests/{resources => .skeleton}/app/Commands/InvoiceSecret.php (100%) rename tests/{resources => .skeleton}/app/Models/Post.php (100%) rename tests/{resources => .skeleton}/app/Models/User.php (100%) rename tests/{resources => .skeleton}/app/Policies/PostPolicy.php (100%) create mode 100644 tests/.skeleton/composer.json rename tests/{resources => .skeleton}/database/factories/Invoicing/InvoiceFactory.php (100%) rename tests/{resources => .skeleton}/database/factories/Invoicing/PaymentFactory.php (100%) rename tests/{resources => .skeleton}/database/factories/VanillaModelFactory.php (100%) rename tests/{resources => .skeleton/src}/Domain/Internal/Reporting/Database/Factories/ReportFactory.php (100%) rename tests/{resources => .skeleton/src}/Domain/Internal/Reporting/Models/Report.php (100%) rename tests/{resources => .skeleton/src}/Domain/Invoicing/Commands/InvoiceDeliver.php (100%) rename tests/{resources => .skeleton/src}/Domain/Invoicing/Database/Factories/InvoiceFactory.php (100%) rename tests/{resources => .skeleton/src}/Domain/Invoicing/Database/Factories/VanillaModelFactory.php (100%) rename tests/{resources => .skeleton/src}/Domain/Invoicing/Models/Invoice.php (100%) rename tests/{resources => .skeleton/src}/Domain/Invoicing/Models/Payment.php (100%) rename tests/{resources => .skeleton/src}/Domain/Invoicing/Models/VanillaModel.php (100%) rename tests/{resources => .skeleton/src}/Domain/Invoicing/Policies/InvoicePolicy.php (100%) rename tests/{resources => .skeleton/src}/Domain/Invoicing/Providers/InvoiceServiceProvider.php (100%) create mode 100644 tests/Command/CacheTest.php create mode 100644 tests/Support/AutoloaderTest.php create mode 100644 tests/Support/CacheTest.php diff --git a/config/ddd.php b/config/ddd.php index 9b99c85..ffef9d2 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -63,41 +63,6 @@ 'scope' => 'Scopes', ], - /* - * The folder where the domain cache files will be stored. - */ - 'cache_directory' => 'bootstrap/cache', - - 'autoload' => [ - /* - | Autoload service providers from the domain namespace. - | By default, it loads any non-abstract class inside the domain layer extending Illuminate\Support\ServiceProvider. - | For example: Domain/Invoicing/Providers/InvoicingServiceProvider.php or Domain/Invoicing/InvoicingServiceProvider.php - */ - 'providers' => true, - - /* - | Autoload commands from the domain namespace. - | By default, it loads any non-abstract class inside the domain layer extending Illuminate\Console\Command. - | For example: Domain/Invoicing/Commands/CreateInvoiceCommand.php - */ - 'commands' => true, - - /* - | Autoload policies from the domain namespace. - | By default, it uses the configured `ddd.namespaces.policy` namespace to guess the policy name. - | For example: Domain/Invoicing/Policies/InvoicePolicy.php - */ - 'policies' => true, - - /* - | Autoload factories from the domain namespace. - | By default, it uses the configured `ddd.namespaces.factory` namespace to guess the factory name. - | For example: Domain/Invoicing/Database/Factories/InvoiceFactory.php - */ - 'factories' => true, - ], - /* |-------------------------------------------------------------------------- | Base Model @@ -146,4 +111,31 @@ | */ 'base_action' => null, + + /* + |-------------------------------------------------------------------------- + | Autoloading + |-------------------------------------------------------------------------- + | + | Configure whether domain providers, commands, policies, and factories + | should be auto-discovered and registered. + | + */ + 'autoload' => [ + 'providers' => true, + 'commands' => true, + 'policies' => true, + 'factories' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | The folder where the domain cache files will be stored. Used for domain + | autoloading. + | + */ + 'cache_directory' => 'bootstrap/cache', ]; diff --git a/src/Commands/CacheClearCommand.php b/src/Commands/CacheClearCommand.php new file mode 100644 index 0000000..5b7f668 --- /dev/null +++ b/src/Commands/CacheClearCommand.php @@ -0,0 +1,20 @@ +info('Domain cache cleared.'); + } +} diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php new file mode 100644 index 0000000..5e137e6 --- /dev/null +++ b/src/Commands/CacheCommand.php @@ -0,0 +1,24 @@ +info('Cached domain providers.'); + + DomainAutoloader::cacheCommands(); + + $this->info('Cached domain commands.'); + } +} diff --git a/src/Commands/DomainFactoryMakeCommand.php b/src/Commands/DomainFactoryMakeCommand.php index d870527..fe06086 100644 --- a/src/Commands/DomainFactoryMakeCommand.php +++ b/src/Commands/DomainFactoryMakeCommand.php @@ -92,8 +92,8 @@ protected function preparePlaceholders(): array // ]); return [ - 'namespacedModel' => $domainModel->fqn, - 'model' => class_basename($domainModel->fqn), + 'namespacedModel' => $domainModel->fullyQualifiedName, + 'model' => class_basename($domainModel->fullyQualifiedName), 'factory' => $this->getFactoryName(), 'namespace' => $domainFactory->namespace, ]; diff --git a/src/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php index c7cb0bf..4994536 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -22,6 +22,8 @@ public function configurePackage(Package $package): void ->hasConfigFile() ->hasCommands([ Commands\InstallCommand::class, + Commands\CacheCommand::class, + Commands\CacheClearCommand::class, Commands\DomainListCommand::class, Commands\DomainModelMakeCommand::class, Commands\DomainFactoryMakeCommand::class, @@ -64,6 +66,7 @@ public function packageBooted() public function packageRegistered() { (new DomainAutoloader())->autoload(); + Event::subscribe(CacheClearSubscriber::class); } } diff --git a/src/Support/Domain.php b/src/Support/Domain.php index fac0b26..3f1d7df 100644 --- a/src/Support/Domain.php +++ b/src/Support/Domain.php @@ -109,7 +109,7 @@ public function object(string $type, string $name): DomainObject name: $name, domain: $this->domain, namespace: $namespace, - fqn: $namespace.'\\'.$name, + fullyQualifiedName: $namespace.'\\'.$name, path: $this->path($namespace.'\\'.$name), type: $type ); @@ -128,7 +128,7 @@ public function factory(string $name): DomainObject name: $name, domain: $this->domain, namespace: $this->namespace->factories, - fqn: $this->namespace->factories.'\\'.$name, + fullyQualifiedName: $this->namespace->factories.'\\'.$name, path: str("database/factories/{$this->domainWithSubdomain}/{$name}.php") ->replace(['\\', '/'], DIRECTORY_SEPARATOR) ->toString(), diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index 300c42e..ca75ec8 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -9,7 +9,6 @@ use Illuminate\Foundation\Application; use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; @@ -21,90 +20,58 @@ class DomainAutoloader { - protected string $cacheDirectory; - - protected mixed $config; - public function __construct() { - $this->config = config('ddd'); - $this->cacheDirectory = $this->configValue('cache_directory') ?? 'bootstrap/cache/ddd'; - } - - protected function configValue($path) - { - return data_get($this->config, $path); + // } public function autoload(): void { - if ($value = $this->configValue('autoload.providers')) { - $this->registerProviders($value); + if (! config()->has('ddd.autoload')) { + return; } - if ($value = $this->configValue('autoload.commands')) { - $this->registerCommands($value); + $this->handleProviders(); + + if (app()->runningInConsole()) { + $this->handleCommands(); } - if ($this->configValue('autoload.policies') === true) { - $this->registerPolicies(); + if (config('ddd.autoload.policies') === true) { + $this->handlePolicies(); } - if ($this->configValue('autoload.factories') === true) { - $this->registerFactories(); + if (config('ddd.autoload.factories') === true) { + $this->handleFactories(); } } - protected function normalizePaths($path): array + protected static function normalizePaths($path): array { return collect($path) ->filter(fn ($path) => is_dir($path)) ->toArray(); } - protected function registerProviders(bool|string|array|null $path = null): void + protected function handleProviders(): void { - $paths = $this->normalizePaths($path === true ? app()->basePath(DomainResolver::domainPath()) : $path); - - $serviceProviders = $this->remember('ddd-domain-service-providers', static function () use ($paths) { - if (empty($paths)) { - return []; - } + $providers = DomainCache::has('domain-providers') + ? DomainCache::get('domain-providers') + : static::discoverProviders(); - $finder = Finder::create()->files()->in($paths); - - return Lody::classesFromFinder($finder) - ->isNotAbstract() - ->isInstanceOf(ServiceProvider::class) - ->toArray(); - }); - - $app = app(); - - foreach ($serviceProviders as $serviceProvider) { - $app->register($serviceProvider); + foreach ($providers as $provider) { + app()->register($provider); } } - protected function registerCommands(bool|string|array|null $path = null): void + protected function handleCommands(): void { - $paths = $this->normalizePaths($path === true ? app()->basePath(DomainResolver::domainPath()) : $path); + $commands = DomainCache::has('domain-commands') + ? DomainCache::get('domain-commands') + : static::discoverCommands(); - $commands = $this->remember('ddd-domain-commands', static function () use ($paths) { - if (empty($paths)) { - return []; - } - - $finder = Finder::create()->files()->in($paths); - - return Lody::classesFromFinder($finder) - ->isNotAbstract() - ->isInstanceOf(Command::class) - ->toArray(); - }); - - foreach ($commands as $class) { - $this->registerCommand($class); + foreach ($commands as $command) { + $this->registerCommand($command); } } @@ -115,13 +82,13 @@ protected function registerCommand($class) }); } - protected function registerPolicies(): void + protected function handlePolicies(): void { Gate::guessPolicyNamesUsing(static function (string $class): array|string { if ($model = DomainObject::fromClass($class, 'model')) { return (new Domain($model->domain)) ->object('policy', "{$model->name}Policy") - ->fqn; + ->fullyQualifiedName; } $classDirname = str_replace('/', '\\', dirname(str_replace('\\', '/', $class))); @@ -138,7 +105,7 @@ protected function registerPolicies(): void }); } - protected function registerFactories(): void + protected function handleFactories(): void { Factory::guessFactoryNamesUsing(function (string $modelName) { if (DomainResolver::isDomainClass($modelName)) { @@ -155,30 +122,60 @@ protected function registerFactories(): void }); } - protected function remember($fileName, $callback) + protected static function discoverProviders(): array { - // The cache is not available during booting, so we need to roll our own file based cache - $cacheFilePath = base_path($this->cacheDirectory.'/'.$fileName.'.php'); + $configValue = config('ddd.autoload.providers'); - $data = file_exists($cacheFilePath) ? include $cacheFilePath : null; + if ($configValue === false) { + return []; + } - if (is_null($data)) { - $data = $callback(); + $paths = static::normalizePaths( + $configValue === true ? app()->basePath(DomainResolver::domainPath()) : $configValue + ); - file_put_contents( - $cacheFilePath, - 'files()->in($paths)) + ->isNotAbstract() + ->isInstanceOf(ServiceProvider::class) + ->toArray(); } - public static function clearCache() + protected static function discoverCommands(): array { - $files = glob(base_path(config('ddd.cache_directory').'/ddd-*.php')); + $configValue = config('ddd.autoload.commands'); + + if ($configValue === false) { + return []; + } + + $paths = static::normalizePaths( + $configValue === true ? + app()->basePath(DomainResolver::domainPath()) + : $configValue + ); + + if (empty($paths)) { + return []; + } - File::delete($files); + return Lody::classesFromFinder(Finder::create()->files()->in($paths)) + ->isNotAbstract() + ->isInstanceOf(Command::class) + ->toArray(); + } + + public static function cacheProviders(): void + { + DomainCache::set('domain-providers', static::discoverProviders()); + } + + public static function cacheCommands(): void + { + DomainCache::set('domain-commands', static::discoverCommands()); } protected static function appNamespace() diff --git a/src/Support/DomainCache.php b/src/Support/DomainCache.php new file mode 100644 index 0000000..909d1b1 --- /dev/null +++ b/src/Support/DomainCache.php @@ -0,0 +1,63 @@ +setupTestApplication(); - - DomainAutoloader::clearCache(); }); describe('without autoload', function () { + beforeEach(function () { + Config::set('ddd.autoload.commands', false); + + $this->setupTestApplication(); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); + it('does not register the command', function () { expect(class_exists('Domain\Invoicing\Commands\InvoiceDeliver'))->toBeTrue(); expect(fn () => Artisan::call('invoice:deliver'))->toThrow(CommandNotFoundException::class); @@ -24,57 +30,73 @@ describe('with autoload', function () { beforeEach(function () { + Config::set('ddd.autoload.commands', true); + + $this->setupTestApplication(); + $this->afterApplicationCreated(function () { (new DomainAutoloader())->autoload(); }); }); - it('registers the command', function () { + it('registers existing commands', function () { + $command = 'invoice:deliver'; + + expect(collect(Artisan::all())) + ->has($command) + ->toBeTrue(); + expect(class_exists('Domain\Invoicing\Commands\InvoiceDeliver'))->toBeTrue(); - Artisan::call('invoice:deliver'); + Artisan::call($command); expect(Artisan::output())->toContain('Invoice delivered!'); }); - it('recognizes new commands created afterwards', function () { - expect(class_exists('Domain\Invoicing\Commands\InvoiceVoid'))->toBeFalse(); + it('registers newly created commands', function () { + $command = 'app:invoice-void'; + + expect(collect(Artisan::all())) + ->has($command) + ->toBeFalse(); Artisan::call('ddd:command', [ 'name' => 'InvoiceVoid', '--domain' => 'Invoicing', ]); - $filepath = base_path('src/Domain/Invoicing/Commands/InvoiceVoid.php'); + expect(collect(Artisan::all())) + ->has($command) + ->toBeTrue(); - expect(file_exists($filepath))->toBeTrue(); + $this->artisan($command)->assertSuccessful(); + })->skip("Can't get this to work, might not be test-able without a real app environment."); +}); + +describe('caching', function () { + beforeEach(function () { + Config::set('ddd.autoload.commands', true); - $class = 'Domain\Invoicing\Commands\InvoiceVoid'; + $this->setupTestApplication(); + }); - // dd( - // [ - // // pre-created files work fine - // 'App\Models\User' => [ - // 'path' => base_path('app/Models/User.php'), - // 'file_exists' => file_exists(base_path('app/Models/User.php')), - // 'class_exists' => class_exists('App\Models\User'), - // ], + it('remembers the last cached state', function () { + DomainCache::set('domain-commands', []); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); - // 'Domain\Invoicing\Models\Invoice' => [ - // 'path' => base_path('src/Domain/Invoicing/Models/Invoice.php'), - // 'file_exists' => file_exists(base_path('src/Domain/Invoicing/Models/Invoice.php')), - // 'class_exists' => class_exists('Domain\Invoicing\Models\Invoice'), - // ], + // command should not be recognized due to cached empty-state + expect(fn () => Artisan::call('invoice:deliver'))->toThrow(CommandNotFoundException::class); + }); - // // but runtime-created class created but not recognized by class_exists - // $class => [ - // 'path' => $filepath, - // 'file_exists' => file_exists($filepath), - // 'class_exists' => class_exists($class), - // ], - // ], - // ); + it('can bust the cache', function () { + DomainCache::set('domain-commands', []); + DomainCache::clear(); - $instance = new $class(); + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); - expect(class_exists($class))->toBeTrue(); - })->markTestIncomplete("Can't get this to work under test environment"); + $this->artisan('invoice:deliver')->assertSuccessful(); + }); }); diff --git a/tests/Autoload/FactoryTest.php b/tests/Autoload/FactoryTest.php index cdae53e..26f366c 100644 --- a/tests/Autoload/FactoryTest.php +++ b/tests/Autoload/FactoryTest.php @@ -57,8 +57,8 @@ }); it('cannot resolve factories that rely on autoloading', function ($modelClass) { - $modelClass::factory(); - })->throws(\Error::class)->with([ + expect(fn () => $modelClass::factory())->toThrow(Error::class); + })->with([ ['Domain\Invoicing\Models\VanillaModel'], ['Domain\Internal\Reporting\Models\Report'], ]); diff --git a/tests/Autoload/PolicyTest.php b/tests/Autoload/PolicyTest.php index a7bee0b..5a4212b 100644 --- a/tests/Autoload/PolicyTest.php +++ b/tests/Autoload/PolicyTest.php @@ -8,6 +8,7 @@ $this->setupTestApplication(); Config::set('ddd.domain_namespace', 'Domain'); + Config::set('ddd.autoload.factories', true); $this->afterApplicationCreated(function () { (new DomainAutoloader())->autoload(); diff --git a/tests/Autoload/ProviderTest.php b/tests/Autoload/ProviderTest.php index e292e4e..7e3e775 100644 --- a/tests/Autoload/ProviderTest.php +++ b/tests/Autoload/ProviderTest.php @@ -2,17 +2,26 @@ use Illuminate\Support\Facades\Config; use Lunarstorm\LaravelDDD\Support\DomainAutoloader; +use Lunarstorm\LaravelDDD\Support\DomainCache; beforeEach(function () { Config::set('ddd.domain_path', 'src/Domain'); Config::set('ddd.domain_namespace', 'Domain'); $this->setupTestApplication(); - - DomainAutoloader::clearCache(); }); describe('without autoload', function () { + beforeEach(function () { + config([ + 'ddd.autoload.providers' => false, + ]); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); + it('does not register the provider', function () { expect(fn () => app('invoicing'))->toThrow(Exception::class); }); @@ -20,6 +29,10 @@ describe('with autoload', function () { beforeEach(function () { + config([ + 'ddd.autoload.providers' => true, + ]); + $this->afterApplicationCreated(function () { (new DomainAutoloader())->autoload(); }); @@ -30,3 +43,35 @@ $this->artisan('invoice:deliver')->expectsOutputToContain('invoice-secret'); }); }); + +describe('caching', function () { + beforeEach(function () { + config([ + 'ddd.autoload.providers' => true, + ]); + + $this->setupTestApplication(); + }); + + it('remembers the last cached state', function () { + DomainCache::set('domain-providers', []); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + + expect(fn () => app('invoicing'))->toThrow(Exception::class); + }); + + it('can bust the cache', function () { + DomainCache::set('domain-providers', []); + DomainCache::clear(); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + + expect(app('invoicing'))->toEqual('invoicing-singleton'); + $this->artisan('invoice:deliver')->expectsOutputToContain('invoice-secret'); + }); +}); diff --git a/tests/Command/CacheTest.php b/tests/Command/CacheTest.php new file mode 100644 index 0000000..6522d64 --- /dev/null +++ b/tests/Command/CacheTest.php @@ -0,0 +1,42 @@ +setupTestApplication(); + DomainCache::clear(); +}); + +it('can cache discovered domain providers and commands', function () { + expect(DomainCache::get('domain-providers'))->toBeNull(); + + expect(DomainCache::get('domain-commands'))->toBeNull(); + + $this + ->artisan('ddd:cache') + ->expectsOutput('Cached domain providers.') + ->expectsOutput('Cached domain commands.') + ->execute(); + + expect(DomainCache::get('domain-providers')) + ->toContain('Domain\Invoicing\Providers\InvoiceServiceProvider'); + + expect(DomainCache::get('domain-commands')) + ->toContain('Domain\Invoicing\Commands\InvoiceDeliver'); +}); + +it('can clear the cache', function () { + Artisan::call('ddd:cache'); + + expect(DomainCache::get('domain-providers'))->not->toBeNull(); + expect(DomainCache::get('domain-commands'))->not->toBeNull(); + + $this + ->artisan('ddd:clear') + ->expectsOutput('Domain cache cleared.') + ->execute(); + + expect(DomainCache::get('domain-providers'))->toBeNull(); + expect(DomainCache::get('domain-commands'))->toBeNull(); +}); diff --git a/tests/Generator/MakeFactoryTest.php b/tests/Generator/MakeFactoryTest.php index 75173e8..5d02d90 100644 --- a/tests/Generator/MakeFactoryTest.php +++ b/tests/Generator/MakeFactoryTest.php @@ -48,7 +48,7 @@ expect($contents) ->toContain("namespace {$domainFactory->namespace};") - ->toContain("use {$domainModel->fqn};") + ->toContain("use {$domainModel->fullyQualifiedName};") ->toContain("class {$domainFactory->name} extends Factory") ->toContain("protected \$model = {$modelName}::class;"); })->with('domainPaths')->with('domainSubdomain'); diff --git a/tests/Generator/MakeModelTest.php b/tests/Generator/MakeModelTest.php index 50076a7..b4a5f04 100644 --- a/tests/Generator/MakeModelTest.php +++ b/tests/Generator/MakeModelTest.php @@ -84,7 +84,7 @@ expect(file_exists($expectedFactoryPath))->toBeTrue("Expecting factory file to be generated at {$expectedFactoryPath}"); expect(file_get_contents($expectedFactoryPath)) - ->toContain("use {$domainModel->fqn};") + ->toContain("use {$domainModel->fullyQualifiedName};") ->toContain("protected \$model = {$modelName}::class;"); })->with('domainPaths')->with('domainSubdomain'); diff --git a/tests/InstallTest.php b/tests/InstallTest.php index dcb58e1..5d58ab9 100644 --- a/tests/InstallTest.php +++ b/tests/InstallTest.php @@ -18,8 +18,7 @@ $command->execute(); expect(file_exists($path))->toBeTrue(); - expect(file_get_contents($path)) - ->toEqual(file_get_contents(__DIR__.'/../config/ddd.php')); + expect(file_get_contents($path))->toEqual(file_get_contents(__DIR__.'/../config/ddd.php')); unlink($path); }); diff --git a/tests/Pest.php b/tests/Pest.php index 1363be1..1691c65 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,3 +12,8 @@ function skipOnLaravelVersionsBelow($minimumVersion) test()->markTestSkipped("Only relevant from Laravel {$minimumVersion} onwards (Current version: {$version})."); } } + +function setConfigValues(array $values) +{ + TestCase::configValues($values); +} diff --git a/tests/Support/AutoloaderTest.php b/tests/Support/AutoloaderTest.php new file mode 100644 index 0000000..6b7edf9 --- /dev/null +++ b/tests/Support/AutoloaderTest.php @@ -0,0 +1,13 @@ +setupTestApplication(); +}); + +it('can run', function () { + $autoloader = new DomainAutoloader(); + + $autoloader->autoload(); +})->throwsNoExceptions(); diff --git a/tests/Support/CacheTest.php b/tests/Support/CacheTest.php new file mode 100644 index 0000000..e790139 --- /dev/null +++ b/tests/Support/CacheTest.php @@ -0,0 +1,39 @@ +toBeFalse(); + + DomainCache::set($key, $value); + + expect(DomainCache::has($key))->toBeTrue(); + + expect(DomainCache::get($key))->toEqual($value); +})->with([ + ['value', 'ddd'], + ['number', 123], + ['array', [12, 23, 34]], +]); + +it('can clear cache', function () { + DomainCache::set('one', [12, 23, 34]); + DomainCache::set('two', [45, 56, 67]); + DomainCache::set('three', [45, 56, 67]); + + expect(DomainCache::has('one'))->toBeTrue(); + expect(DomainCache::has('two'))->toBeTrue(); + expect(DomainCache::has('three'))->toBeTrue(); + + DomainCache::clear(); + + expect(DomainCache::has('one'))->toBeFalse(); + expect(DomainCache::has('two'))->toBeFalse(); + expect(DomainCache::has('three'))->toBeFalse(); +}); diff --git a/tests/Support/DomainTest.php b/tests/Support/DomainTest.php index 5e3d760..731be28 100644 --- a/tests/Support/DomainTest.php +++ b/tests/Support/DomainTest.php @@ -23,7 +23,7 @@ it('can describe a domain model', function ($domainName, $name, $expectedFQN, $expectedPath) { expect((new Domain($domainName))->model($name)) ->name->toBe($name) - ->fqn->toBe($expectedFQN) + ->fullyQualifiedName->toBe($expectedFQN) ->path->toBe(Path::normalize($expectedPath)); })->with([ ['Reporting', 'InvoiceReport', 'Domain\\Reporting\\Models\\InvoiceReport', 'src/Domain/Reporting/Models/InvoiceReport.php'], @@ -33,7 +33,7 @@ it('can describe a domain factory', function ($domainName, $name, $expectedFQN, $expectedPath) { expect((new Domain($domainName))->factory($name)) ->name->toBe($name) - ->fqn->toBe($expectedFQN) + ->fullyQualifiedName->toBe($expectedFQN) ->path->toBe(Path::normalize($expectedPath)); })->with([ ['Reporting', 'InvoiceReportFactory', 'Database\\Factories\\Reporting\\InvoiceReportFactory', 'database/factories/Reporting/InvoiceReportFactory.php'], @@ -43,7 +43,7 @@ it('can describe a data transfer object', function ($domainName, $name, $expectedFQN, $expectedPath) { expect((new Domain($domainName))->dataTransferObject($name)) ->name->toBe($name) - ->fqn->toBe($expectedFQN) + ->fullyQualifiedName->toBe($expectedFQN) ->path->toBe(Path::normalize($expectedPath)); })->with([ ['Reporting', 'InvoiceData', 'Domain\\Reporting\\Data\\InvoiceData', 'src/Domain/Reporting/Data/InvoiceData.php'], @@ -53,7 +53,7 @@ it('can describe a view model', function ($domainName, $name, $expectedFQN, $expectedPath) { expect((new Domain($domainName))->viewModel($name)) ->name->toBe($name) - ->fqn->toBe($expectedFQN) + ->fullyQualifiedName->toBe($expectedFQN) ->path->toBe(Path::normalize($expectedPath)); })->with([ ['Reporting', 'InvoiceReportViewModel', 'Domain\\Reporting\\ViewModels\\InvoiceReportViewModel', 'src/Domain/Reporting/ViewModels/InvoiceReportViewModel.php'], @@ -63,7 +63,7 @@ it('can describe a value object', function ($domainName, $name, $expectedFQN, $expectedPath) { expect((new Domain($domainName))->valueObject($name)) ->name->toBe($name) - ->fqn->toBe($expectedFQN) + ->fullyQualifiedName->toBe($expectedFQN) ->path->toBe(Path::normalize($expectedPath)); })->with([ ['Reporting', 'InvoiceTotal', 'Domain\\Reporting\\ValueObjects\\InvoiceTotal', 'src/Domain/Reporting/ValueObjects/InvoiceTotal.php'], @@ -73,7 +73,7 @@ it('can describe an action', function ($domainName, $name, $expectedFQN, $expectedPath) { expect((new Domain($domainName))->action($name)) ->name->toBe($name) - ->fqn->toBe($expectedFQN) + ->fullyQualifiedName->toBe($expectedFQN) ->path->toBe(Path::normalize($expectedPath)); })->with([ ['Reporting', 'SendInvoiceReport', 'Domain\\Reporting\\Actions\\SendInvoiceReport', 'src/Domain/Reporting/Actions/SendInvoiceReport.php'], @@ -83,7 +83,7 @@ it('can describe an anonymous domain object', function ($domainName, $objectType, $objectName, $expectedFQN, $expectedPath) { expect((new Domain($domainName))->object($objectType, $objectName)) ->name->toBe($objectName) - ->fqn->toBe($expectedFQN) + ->fullyQualifiedName->toBe($expectedFQN) ->path->toBe(Path::normalize($expectedPath)); })->with([ ['Invoicing', 'rule', 'SomeRule', 'Domain\\Invoicing\\Rules\\SomeRule', 'src/Domain/Invoicing/Rules/SomeRule.php'], diff --git a/tests/TestCase.php b/tests/TestCase.php index fdddabe..ef9b57e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,59 +2,71 @@ namespace Lunarstorm\LaravelDDD\Tests; +use Illuminate\Contracts\Config\Repository; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Arr; use Illuminate\Support\Facades\File; use Lunarstorm\LaravelDDD\LaravelDDDServiceProvider; -use Lunarstorm\LaravelDDD\Support\DomainAutoloader; +use Lunarstorm\LaravelDDD\Support\DomainCache; use Orchestra\Testbench\TestCase as Orchestra; use Symfony\Component\Process\Process; class TestCase extends Orchestra { + public static $configValues = []; + protected function setUp(): void { - parent::setUp(); - - $this->cleanFilesAndFolders(); - - // $composerFile = base_path('composer.json'); - // $data = json_decode(file_get_contents($composerFile), true); - - // // Reset the domain namespace - // Arr::forget($data, ['autoload', 'psr-4', 'Domains\\']); - // Arr::forget($data, ['autoload', 'psr-4', 'Domain\\']); - - // // Set up the essential app namespaces - // data_set($data, ['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'); - // data_set($data, ['autoload', 'psr-4', 'Database\\Factories\\'], 'vendor/orchestra/testbench-core/laravel/database/factories'); - // data_set($data, ['autoload', 'psr-4', 'Database\\Seeders\\'], 'vendor/orchestra/testbench-core/laravel/database/seeders'); - - // file_put_contents($composerFile, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + $this->afterApplicationCreated(function () { + $this->cleanSlate(); + + // $this->updateComposer( + // set: [ + // [['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'], + // [['autoload', 'psr-4', 'Database\\Factories\\'], 'vendor/orchestra/testbench-core/laravel/database/factories'], + // [['autoload', 'psr-4', 'Database\\Seeders\\'], 'vendor/orchestra/testbench-core/laravel/database/seeders'], + // [['autoload', 'psr-4', 'Domain\\'], 'vendor/orchestra/testbench-core/laravel/src/Domain'], + // ], + // forget: [ + // ['autoload', 'psr-4', 'Domains\\'], + // ['autoload', 'psr-4', 'Domain\\'], + // ] + // ); + + Factory::guessFactoryNamesUsing( + fn (string $modelName) => 'Lunarstorm\\LaravelDDD\\Database\\Factories\\'.class_basename($modelName).'Factory' + ); + }); - $this->updateComposer( - [ - [['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'], - [['autoload', 'psr-4', 'Database\\Factories\\'], 'vendor/orchestra/testbench-core/laravel/database/factories'], - [['autoload', 'psr-4', 'Database\\Seeders\\'], 'vendor/orchestra/testbench-core/laravel/database/seeders'], - [['autoload', 'psr-4', 'Domain\\'], 'vendor/orchestra/testbench-core/laravel/src/Domain'], - ], - forget: [ - ['autoload', 'psr-4', 'Domains\\'], - ['autoload', 'psr-4', 'Domain\\'], - ] - ); + $this->beforeApplicationDestroyed(function () { + $this->cleanSlate(); + }); - // This may not be needed after all (significantly slows down the tests) - // $this->composerReload(); + parent::setUp(); + } - Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Lunarstorm\\LaravelDDD\\Database\\Factories\\'.class_basename($modelName).'Factory' - ); + public static function configValues(array $values) + { + static::$configValues = $values; + } - $this->beforeApplicationDestroyed(function () { - $this->cleanFilesAndFolders(); + protected function defineEnvironment($app) + { + tap($app['config'], function (Repository $config) { + foreach (static::$configValues as $key => $value) { + $config->set($key, $value); + } }); + + // $this->updateComposer( + // set: [ + // [['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'], + // ], + // forget: [ + // ['autoload', 'psr-4', 'Domains\\'], + // ['autoload', 'psr-4', 'Domain\\'], + // ] + // ); } protected function getComposerFileContents() @@ -128,8 +140,10 @@ protected function composerReload() }); } - protected function cleanFilesAndFolders() + protected function cleanSlate() { + File::copy(__DIR__.'/.skeleton/composer.json', base_path('composer.json')); + File::delete(base_path('config/ddd.php')); File::cleanDirectory(app_path()); @@ -139,16 +153,16 @@ protected function cleanFilesAndFolders() File::deleteDirectory(base_path('Custom')); File::deleteDirectory(base_path('src/Domain')); File::deleteDirectory(base_path('src/Domains')); + File::deleteDirectory(app_path('Models')); - DomainAutoloader::clearCache(); + DomainCache::clear(); } protected function setupTestApplication() { - File::copyDirectory(__DIR__.'/resources/app', app_path()); - File::copyDirectory(__DIR__.'/resources/database', base_path('database')); - File::copyDirectory(__DIR__.'/resources/Domain', base_path('src/Domain')); - + File::copyDirectory(__DIR__.'/.skeleton/app', app_path()); + File::copyDirectory(__DIR__.'/.skeleton/database', base_path('database')); + File::copyDirectory(__DIR__.'/.skeleton/src/Domain', base_path('src/Domain')); File::ensureDirectoryExists(app_path('Models')); $this->setDomainPathInComposer('Domain', 'src/Domain'); From cbdb804af7bd9cc0ef827ab215f903560db4abe2 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Fri, 29 Mar 2024 23:54:36 -0400 Subject: [PATCH 22/26] Fix typos and match Laravel's cache:clear output formatting. --- src/Commands/CacheClearCommand.php | 2 +- src/Commands/CacheCommand.php | 4 ++-- src/Listeners/CacheClearSubscriber.php | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Commands/CacheClearCommand.php b/src/Commands/CacheClearCommand.php index 5b7f668..7e4c465 100644 --- a/src/Commands/CacheClearCommand.php +++ b/src/Commands/CacheClearCommand.php @@ -15,6 +15,6 @@ public function handle() { DomainCache::clear(); - $this->info('Domain cache cleared.'); + $this->components->info('Domain cache cleared successfully.'); } } diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php index 5e137e6..4d4fe68 100644 --- a/src/Commands/CacheCommand.php +++ b/src/Commands/CacheCommand.php @@ -15,10 +15,10 @@ public function handle() { DomainAutoloader::cacheProviders(); - $this->info('Cached domain providers.'); + $this->components->info('Domain providers cached successfully.'); DomainAutoloader::cacheCommands(); - $this->info('Cached domain commands.'); + $this->components->info('Domain commands cached successfully.'); } } diff --git a/src/Listeners/CacheClearSubscriber.php b/src/Listeners/CacheClearSubscriber.php index 9267bde..76886d6 100644 --- a/src/Listeners/CacheClearSubscriber.php +++ b/src/Listeners/CacheClearSubscriber.php @@ -3,7 +3,7 @@ namespace Lunarstorm\LaravelDDD\Listeners; use Illuminate\Events\Dispatcher; -use Lunarstorm\LaravelDDD\Support\DomainAutoloader; +use Lunarstorm\LaravelDDD\Support\DomainCache; class CacheClearSubscriber { @@ -13,7 +13,7 @@ public function __construct() public function handle(): void { - DomainAutoloader::clearCache(); + DomainCache::clear(); } /** From 73a27ac1bf208c335a039ee093c0dd9d92d0f461 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Fri, 29 Mar 2024 23:59:51 -0400 Subject: [PATCH 23/26] Update expectations. --- tests/Command/CacheTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Command/CacheTest.php b/tests/Command/CacheTest.php index 6522d64..029e4c2 100644 --- a/tests/Command/CacheTest.php +++ b/tests/Command/CacheTest.php @@ -15,8 +15,8 @@ $this ->artisan('ddd:cache') - ->expectsOutput('Cached domain providers.') - ->expectsOutput('Cached domain commands.') + ->expectsOutputToContain('Domain providers cached successfully.') + ->expectsOutputToContain('Domain commands cached successfully.') ->execute(); expect(DomainCache::get('domain-providers')) @@ -34,7 +34,7 @@ $this ->artisan('ddd:clear') - ->expectsOutput('Domain cache cleared.') + ->expectsOutputToContain('Domain cache cleared successfully.') ->execute(); expect(DomainCache::get('domain-providers'))->toBeNull(); From 4159b64ba9e65b2c8283a25681c0f089c62bb395 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Sat, 30 Mar 2024 11:02:17 -0400 Subject: [PATCH 24/26] Update readme. --- README.md | 194 ++++++++++++++++++++++++++++++++++--------------- config/ddd.php | 21 +++++- 2 files changed, 154 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 6310442..58c1461 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,7 @@ Laravel-DDD is a toolkit to support domain driven design (DDD) patterns in Laravel applications. One of the pain points when adopting DDD is the inability to use Laravel's native `make:model` artisan command to properly generate domain models, since domain models are not intended to be stored in the `App/Models/*` namespace. This package aims to fill the gaps by providing an equivalent command, `ddd:model`, plus many more. -## Version Compatibility - Laravel | LaravelDDD -:-----------|:----------- - 9.x | 0.x - <10.25 | 0.x - 10.25+ | 1.x - 11.x | 1.x - ## Installation - You can install the package via composer: ```bash @@ -28,9 +19,20 @@ You may then initialize the package using the `ddd:install` artisan command. Thi php artisan ddd:install ``` -## Usage +### Version Compatibility + Laravel | LaravelDDD +:---------------|:----------- + 9.x - 10.24.x | 0.x + 10.25.x | 1.x + 11.x | 1.x -Command syntax: +> +> 0.x is no longer supported. For 0.x usage, please refer to the [README for the latest 0.x release](https://github.com/lunarstorm/laravel-ddd/blob/v0.10.0/README.md). +> + +## Usage +### Syntax +All `ddd:*` generator commands use the following syntax: ```bash # Specifying the domain as an option php artisan ddd:{object} {name} --domain={domain} @@ -39,79 +41,84 @@ php artisan ddd:{object} {name} --domain={domain} php artisan ddd:{object} {domain}:{name} # Not specifying the domain at all, which will then prompt -# you to enter the domain name (with auto-completion) +# prompt for it (with auto-completion) php artisan ddd:{object} {name} ``` +## Available Commands +### Generators The following generators are currently available, shown using short-hand syntax: ```bash # Generate a domain model -php artisan ddd:model {domain}:{name} +php artisan ddd:model Invoicing:Invoice # Generate a domain model with factory -php artisan ddd:model {domain}:{name} -f -php artisan ddd:model {domain}:{name} --factory +php artisan ddd:model Invoicing:Invoice -f +php artisan ddd:model Invoicing:Invoice --factory # Generate a domain factory -php artisan ddd:factory {domain}:{name} [--model={model}] +php artisan ddd:factory Invoicing:InvoiceFactory +php artisan ddd:factory Invoicing:InvoiceFactory --model=Invoice # optionally specifying the model # Generate a data transfer object -php artisan ddd:dto {domain}:{name} +php artisan ddd:dto Invoicing:LineItemPayload # Generates a value object -php artisan ddd:value {domain}:{name} +php artisan ddd:value Shared:DollarAmount # Generates a view model -php artisan ddd:view-model {domain}:{name} +php artisan ddd:view-model Invoicing:ShowInvoiceViewModel # Generates an action -php artisan ddd:action {domain}:{name} +php artisan ddd:action Invoicing:SendInvoiceToCustomer # Extended Commands -# (extends Laravel's make:* generators and funnels the objects into the domain layer) -php artisan ddd:cast {domain}:{name} -php artisan ddd:channel {domain}:{name} -php artisan ddd:command {domain}:{name} -php artisan ddd:enum {domain}:{name} # Requires Laravel 11+ -php artisan ddd:event {domain}:{name} -php artisan ddd:exception {domain}:{name} -php artisan ddd:job {domain}:{name} -php artisan ddd:listener {domain}:{name} -php artisan ddd:mail {domain}:{name} -php artisan ddd:notification {domain}:{name} -php artisan ddd:observer {domain}:{name} -php artisan ddd:policy {domain}:{name} -php artisan ddd:provider {domain}:{name} -php artisan ddd:resource {domain}:{name} -php artisan ddd:rule {domain}:{name} -php artisan ddd:scope {domain}:{name} +# These extend Laravel's respective make:* commands and places the objects into the domain layer +php artisan ddd:cast Invoicing:MoneyCast +php artisan ddd:channel Invoicing:InvoiceChannel +php artisan ddd:command Invoicing:InvoiceDeliver +php artisan ddd:enum Customer:CustomerType # Laravel 11+ only +php artisan ddd:event Invoicing:PaymentWasReceived +php artisan ddd:exception Invoicing:InvoiceNotFoundException +php artisan ddd:job Invoicing:GenerateInvoicePdf +php artisan ddd:listener Invoicing:HandlePaymentReceived +php artisan ddd:mail Invoicing:OverduePaymentReminderEmail +php artisan ddd:notification Invoicing:YourPaymentWasReceived +php artisan ddd:observer Invoicing:InvoiceObserver +php artisan ddd:policy Invoicing:InvoicePolicy +php artisan ddd:provider Invoicing:InvoiceServiceProvider +php artisan ddd:resource Invoicing:InvoiceResource +php artisan ddd:rule Invoicing:ValidPaymentMethod +php artisan ddd:scope Invoicing:ArchivedInvoicesScope ``` +Generated objects will be placed in the appropriate domain namespace as specified by `ddd.namespaces.*` in the configuration file. -Examples: +### Other Commands ```bash -php artisan ddd:model Invoicing:LineItem # Domain/Invoicing/Models/LineItem -php artisan ddd:model Invoicing:LineItem -f # Domain/Invoicing/Models/LineItem + Database/Factories/Invoicing/LineItemFactory -php artisan ddd:factory Invoicing:LineItemFactory # Database/Factories/Invoicing/LineItemFactory -php artisan ddd:dto Invoicing:LinePayload # Domain/Invoicing/Data/LinePayload -php artisan ddd:value Shared:Percentage # Domain/Shared/ValueObjects/Percentage -php artisan ddd:view-model Invoicing:ShowInvoiceViewModel # Domain/Invoicing/ViewModels/ShowInvoiceViewModel -php artisan ddd:action Invoicing:SendInvoiceToCustomer # Domain/Invoicing/Actions/SendInvoiceToCustomer -``` +# Show a summary of current domains in the domain folder +php artisan ddd:list -Subdomains (nested domains) can be specified with dot notation: -```bash -php artisan ddd:model Invoicing.Customer:CustomerInvoice # Domain/Invoicing/Customer/Models/CustomerInvoice -php artisan ddd:factory Invoicing.Customer:CustomerInvoice # Database/Factories/Invoicing/Customer/CustomerInvoiceFactory -# (supported by all generator commands) +# Cache domain manifests (used for autoloading) +php artisan ddd:cache + +# Clear the domain cache +php artisan ddd:clear ``` -### Other Commands +### Subdomains (nested domains) +Subdomains can be specified with dot notation wherever a domain option is accepted. ```bash -# Show a summary of current domains in the domain folder -php artisan ddd:list +# Domain/Reporting/Internal/ViewModels/MonthlyInvoicesReportViewModel +php artisan ddd:view-model Reporting.Internal:MonthlyInvoicesReportViewModel + +# Domain/Reporting/Customer/ViewModels/MonthlyInvoicesReportViewModel +php artisan ddd:view-model Reporting.Customer:MonthlyInvoicesReportViewModel + +# (supported by all commands where a domain option is accepted) ``` -This package ships with opinionated (but sensible) configuration defaults. If you need to customize, you may do so by publishing the config file and generator stubs as needed: +### Customization +This package ships with opinionated (but sensible) configuration defaults. You may customize by publishing the config file and generator stubs as needed: ```bash php artisan vendor:publish --tag="ddd-config" @@ -119,6 +126,45 @@ php artisan vendor:publish --tag="ddd-stubs" ``` Note that the extended commands do not publish ddd-specific stubs, and inherit the respective application-level stubs published by Laravel. +## Domain Autoloading and Discovery +Autoloading behaviour can be configured with the `ddd.autoload` configuration option. By default, domain providers, commands, policies, and factories are auto-discovered and registered. + +```php +'autoload' => [ + 'providers' => true, + 'commands' => true, + 'policies' => true, + 'factories' => true, +], +``` +### Service Providers +When `ddd.autoload.providers` is enabled, any class within the domain layer extending `Illuminate\Support\ServiceProvider` will be auto-registered as a service provider. + +### Console Commands +When `ddd.autoload.commands` is enabled, any class within the domain layer extending `Illuminate\Console\Command` will be auto-registered as a command when running in console. + +### Policies +When `ddd.autoload.policies` is enabled, the package will register a custom policy discovery callback to resolve policy names for domain models, and fallback to Laravel's default for all other cases. If your application implements its own policy discovery using `Gate::guessPolicyNamesUsing()`, you should set `ddd.autoload.policies` to `false` to ensure it is not overridden. + +### Factories +When `ddd.autoload.factories` is enabled, the package will register a custom factory discovery callback to resolve factory names for domain models, and fallback to Laravel's default for all other cases. Note that this does not affect domain models using the `Lunarstorm\LaravelDDD\Factories\HasDomainFactory` trait. Where this is useful is with regular models in the domain layer that use the standard `Illuminate\Database\Eloquent\Factories\HasFactory` trait. + +If your application implements its own factory discovery using `Factory::guessFactoryNamesUsing()`, you should set `ddd.autoload.factories` to `false` to ensure it is not overridden. + +### Disabling Autoloading +You may disable autoloading by setting the respective autoload options to `false` in the configuration file as needed, or by commenting out the autoload configuration entirely. +```php +// 'autoload' => [ +// 'providers' => true, +// 'commands' => true, +// 'policies' => true, +// 'factories' => true, +// ], +``` +## Autoloading in Production +In production, you should cache the autoload manifests using the `ddd:cache` command as part of your application's deployment process. This will speed up the auto-discovery and registration of domain providers and commands. The `ddd:clear` command may be used to clear the cache if needed. + +## Configuration File This is the content of the published config file (`ddd.php`): ```php @@ -153,11 +199,11 @@ return [ | objects relative to the domain namespace of which the object | belongs to. | - | e.g., Domain/Invoicing/Models/* - | Domain/Invoicing/Data/* - | Domain/Invoicing/ViewModels/* - | Domain/Invoicing/ValueObjects/* - | Domain/Invoicing/Actions/* + | e.g., Domain\Invoicing\Models\* + | Domain\Invoicing\Data\* + | Domain\Invoicing\ViewModels\* + | Domain\Invoicing\ValueObjects\* + | Domain\Invoicing\Actions\* | */ 'namespaces' => [ @@ -172,6 +218,7 @@ return [ 'enum' => 'Enums', 'event' => 'Events', 'exception' => 'Exceptions', + 'factory' => 'Database\Factories', 'job' => 'Jobs', 'listener' => 'Listeners', 'mail' => 'Mail', @@ -232,6 +279,33 @@ return [ | */ 'base_action' => null, + + /* + |-------------------------------------------------------------------------- + | Autoloading + |-------------------------------------------------------------------------- + | + | Configure whether domain providers, commands, policies, and factories + | should be auto-discovered and registered. + | + */ + 'autoload' => [ + 'providers' => true, + 'commands' => true, + 'policies' => true, + 'factories' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | The folder where the domain cache files will be stored. Used for domain + | autoloading. + | + */ + 'cache_directory' => 'bootstrap/cache', ]; ``` diff --git a/config/ddd.php b/config/ddd.php index ffef9d2..6e45920 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -122,9 +122,28 @@ | */ 'autoload' => [ + /** + * When enabled, any class within the domain layer extending `Illuminate\Support\ServiceProvider` + * will be auto-registered as a service provider + */ 'providers' => true, + + /** + * When enabled, any class within the domain layer extending `Illuminate\Console\Command` + * will be auto-registered as a command when running in console. + */ 'commands' => true, + + /** + * When enabled, the package will register a custom policy discovery callback to resolve policy names + * for domain models, and fallback to Laravel's default for all other cases. + */ 'policies' => true, + + /** + * When enabled, the package will register a custom factory discovery callback to resolve factory names + * for domain models, and fallback to Laravel's default for all other cases. + */ 'factories' => true, ], @@ -137,5 +156,5 @@ | autoloading. | */ - 'cache_directory' => 'bootstrap/cache', + 'cache_directory' => 'bootstrap/cache/ddd', ]; From b0232a4bd098fd35c32061a066f2581ac5ae5e23 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Sat, 30 Mar 2024 11:03:02 -0400 Subject: [PATCH 25/26] Sync config content. --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 58c1461..d33b254 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,7 @@ In production, you should cache the autoload manifests using the `ddd:cache` com This is the content of the published config file (`ddd.php`): ```php + return [ /* @@ -290,9 +291,28 @@ return [ | */ 'autoload' => [ + /** + * When enabled, any class within the domain layer extending `Illuminate\Support\ServiceProvider` + * will be auto-registered as a service provider + */ 'providers' => true, + + /** + * When enabled, any class within the domain layer extending `Illuminate\Console\Command` + * will be auto-registered as a command when running in console. + */ 'commands' => true, + + /** + * When enabled, the package will register a custom policy discovery callback to resolve policy names + * for domain models, and fallback to Laravel's default for all other cases. + */ 'policies' => true, + + /** + * When enabled, the package will register a custom factory discovery callback to resolve factory names + * for domain models, and fallback to Laravel's default for all other cases. + */ 'factories' => true, ], @@ -305,7 +325,7 @@ return [ | autoloading. | */ - 'cache_directory' => 'bootstrap/cache', + 'cache_directory' => 'bootstrap/cache/ddd', ]; ``` From 4d2556151347ad80cc96f27ed68c90143edbd665 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Sat, 30 Mar 2024 11:05:53 -0400 Subject: [PATCH 26/26] Ensure cache directory exists. --- src/Support/DomainCache.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Support/DomainCache.php b/src/Support/DomainCache.php index 909d1b1..b6836f8 100644 --- a/src/Support/DomainCache.php +++ b/src/Support/DomainCache.php @@ -10,6 +10,8 @@ public static function set($key, $value) { $cacheDirectory = config('ddd.cache_directory', 'bootstrap/cache/ddd'); + File::ensureDirectoryExists(base_path($cacheDirectory)); + $cacheFilePath = base_path("{$cacheDirectory}/ddd-{$key}.php"); file_put_contents(