diff --git a/CHANGELOG.md b/CHANGELOG.md index b27ee2c..c017684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to `laravel-ddd` will be documented in this file. +## [Unversioned] +### Added +- Add `ddd:class` generator extending Laravel's `make:class` (Laravel 11 only). +- Add `ddd:interface` generator extending Laravel's `make:interface` (Laravel 11 only). +- Add `ddd:trait` generator extending Laravel's `make:trait` (Laravel 11 only). +- Allow overriding configured namespaces at runtime by specifying an absolute name starting with /: +```bash +# Generate a provider in the default configured namespace +# -> Domain\Invoicing\Providers\InvoiceServiceProvider +php artisan ddd:provider Invoicing:InvoiceServiceProvider + +# Override the configured namespace at runtime +# -> Domain\Invoicing\InvoiceServiceProvider +php artisan ddd:provider Invoicing:/InvoiceServiceProvider + +# Can be deeply nested if desired +# -> Domain\Invoicing\Models\Exceptions\InvoiceNotFoundException +php artisan ddd:exception Invoicing:/Models/Exceptions/InvoiceNotFoundException +``` + +### Changed +- Internals: Handle a variety of additional edge cases when generating base models and base view models. + ## [1.0.0] - 2024-03-31 ### Added - `ddd:list` to show a summary of current domains in the domain folder. diff --git a/src/Commands/Concerns/ResolvesDomainFromInput.php b/src/Commands/Concerns/ResolvesDomainFromInput.php index 33e1587..9f323b6 100644 --- a/src/Commands/Concerns/ResolvesDomainFromInput.php +++ b/src/Commands/Concerns/ResolvesDomainFromInput.php @@ -12,6 +12,8 @@ trait ResolvesDomainFromInput { use CanPromptForDomain; + protected $nameIsAbsolute = false; + protected ?Domain $domain = null; protected function getOptions() @@ -41,7 +43,9 @@ protected function guessObjectType(): string protected function getDefaultNamespace($rootNamespace) { if ($this->domain) { - return $this->domain->namespaceFor($this->guessObjectType()); + return $this->nameIsAbsolute + ? $this->domain->namespace->root + : $this->domain->namespaceFor($this->guessObjectType()); } return parent::getDefaultNamespace($rootNamespace); @@ -51,7 +55,11 @@ protected function getPath($name) { if ($this->domain) { return Path::normalize($this->laravel->basePath( - $this->domain->object($this->guessObjectType(), class_basename($name))->path + $this->domain->object( + type: $this->guessObjectType(), + name: $name, + absolute: $this->nameIsAbsolute + )->path )); } @@ -68,7 +76,7 @@ public function handle() if (Str::contains($nameInput, ':')) { $domainExtractedFromName = Str::before($nameInput, ':'); - $this->input->setArgument('name', Str::after($nameInput, ':')); + $nameInput = Str::after($nameInput, ':'); } $this->domain = match (true) { @@ -86,6 +94,18 @@ public function handle() $this->domain = new Domain($this->promptForDomainName()); } + // Now that the domain part is handled, + // we will deal with the name portion. + + // Normalize slash and dot separators + $nameInput = Str::replace(['.', '\\', '/'], '/', $nameInput); + + if ($this->nameIsAbsolute = Str::startsWith($nameInput, ['/'])) { + // $nameInput = Str::after($nameInput, '/'); + } + + $this->input->setArgument('name', $nameInput); + parent::handle(); } } diff --git a/src/Commands/DomainModelMakeCommand.php b/src/Commands/DomainModelMakeCommand.php index 334d5cc..18430c2 100644 --- a/src/Commands/DomainModelMakeCommand.php +++ b/src/Commands/DomainModelMakeCommand.php @@ -2,6 +2,7 @@ namespace Lunarstorm\LaravelDDD\Commands; +use Illuminate\Support\Str; use Lunarstorm\LaravelDDD\Support\DomainResolver; use Symfony\Component\Console\Input\InputOption; @@ -55,41 +56,45 @@ public function handle() protected function createBaseModelIfNeeded() { - $baseModel = config('ddd.base_model'); - - if (class_exists($baseModel)) { + if (! $this->shouldCreateModel()) { return; } - $this->warn("Configured base model {$baseModel} doesn't exist."); - - // If the base model is out of scope, we won't attempt to create it - // because we don't want to interfere with external folders. - $allowedNamespacePrefixes = [ - $this->rootNamespace(), - ]; + $baseModel = config('ddd.base_model'); - if (! str($baseModel)->startsWith($allowedNamespacePrefixes)) { - return; - } + $this->warn("Base model {$baseModel} doesn't exist, generating..."); $domain = DomainResolver::guessDomainFromClass($baseModel); - if (! $domain) { - return; - } + $name = Str::after($baseModel, $domain); - $baseModelName = class_basename($baseModel); - $baseModelPath = $this->getPath($baseModel); + $this->call(DomainBaseModelMakeCommand::class, [ + '--domain' => $domain, + 'name' => $name, + ]); + } + + protected function shouldCreateModel(): bool + { + $baseModel = config('ddd.base_model'); - if (! file_exists($baseModelPath)) { - $this->info("Generating {$baseModel}..."); + // If the class exists, we don't need to create it. + if (class_exists($baseModel)) { + return false; + } - $this->call(DomainBaseModelMakeCommand::class, [ - '--domain' => $domain, - 'name' => $baseModelName, - ]); + // If the class is outside of the domain layer, we won't attempt to create it. + if (! DomainResolver::isDomainClass($baseModel)) { + return false; } + + // At this point the class is probably a domain object, but we should + // check if the expected path exists. + if (file_exists(app()->basePath(DomainResolver::guessPathFromClass($baseModel)))) { + return false; + } + + return true; } protected function createFactory() diff --git a/src/Commands/DomainViewModelMakeCommand.php b/src/Commands/DomainViewModelMakeCommand.php index f2e9e23..e097cae 100644 --- a/src/Commands/DomainViewModelMakeCommand.php +++ b/src/Commands/DomainViewModelMakeCommand.php @@ -52,7 +52,7 @@ public function handle() $domain = DomainResolver::guessDomainFromClass($baseViewModel); - $name = Str::after($baseViewModel, "{$domain}\\"); + $name = Str::after($baseViewModel, $domain); $this->call(DomainBaseViewModelMakeCommand::class, [ '--domain' => $domain, diff --git a/src/Support/Domain.php b/src/Support/Domain.php index 18a2dd4..062aedc 100644 --- a/src/Support/Domain.php +++ b/src/Support/Domain.php @@ -59,16 +59,6 @@ public function __construct(string $domain, ?string $subdomain = null) $this->path = Path::join(DomainResolver::domainPath(), $this->domainWithSubdomain); } - protected function registerDomainObjects() - { - // WIP - } - - protected function registerDomainObject() - { - // WIP - } - protected function getDomainBasePath() { return app()->basePath(DomainResolver::domainPath()); @@ -99,18 +89,33 @@ public function namespaceFor(string $type): string return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type); } - public function object(string $type, string $name): DomainObject + public function guessNamespaceFromName(string $name): string + { + $baseName = class_basename($name); + + return str($name) + ->before($baseName) + ->trim('\\') + ->prepend(DomainResolver::domainRootNamespace().'\\'.$this->domainWithSubdomain.'\\') + ->toString(); + } + + public function object(string $type, string $name, bool $absolute = false): DomainObject { - $namespace = $this->namespaceFor($type); + $namespace = match (true) { + $absolute => $this->namespace->root, + str($name)->startsWith('\\') => $this->guessNamespaceFromName($name), + default => $this->namespaceFor($type), + }; - $name = str($name)->replace("{$namespace}\\", '')->toString(); + $baseName = str($name)->replace($namespace, '')->trim('\\')->toString(); return new DomainObject( - name: $name, + name: $baseName, domain: $this->domain, namespace: $namespace, - fullyQualifiedName: $namespace.'\\'.$name, - path: $this->path($namespace.'\\'.$name), + fullyQualifiedName: $namespace.'\\'.$baseName, + path: $this->path($namespace.'\\'.$baseName), type: $type ); } diff --git a/src/Support/DomainResolver.php b/src/Support/DomainResolver.php index 9a06e2f..9d52db8 100644 --- a/src/Support/DomainResolver.php +++ b/src/Support/DomainResolver.php @@ -47,7 +47,11 @@ public static function getRelativeObjectNamespace(string $type): string public static function getDomainObjectNamespace(string $domain, string $type, ?string $object = null): string { - $namespace = implode('\\', [static::domainRootNamespace(), $domain, static::getRelativeObjectNamespace($type)]); + $namespace = collect([ + static::domainRootNamespace(), + $domain, + static::getRelativeObjectNamespace($type), + ])->filter()->implode('\\'); if ($object) { $namespace .= "\\{$object}"; diff --git a/src/ValueObjects/DomainObject.php b/src/ValueObjects/DomainObject.php index 3ec28dd..2f1d2da 100644 --- a/src/ValueObjects/DomainObject.php +++ b/src/ValueObjects/DomainObject.php @@ -34,6 +34,10 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp : config('ddd.namespaces', []); foreach ($possibleObjectNamespaces as $type => $namespace) { + if (blank($namespace)) { + continue; + } + $rootObjectNamespace = preg_quote($namespace); $pattern = "/({$rootObjectNamespace})(.*)$/"; @@ -44,21 +48,27 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp continue; } - $objectNamespace = str(data_get($matches, 0))->beforeLast('\\')->toString(); + $objectNamespace = str(data_get($matches, 1))->toString(); + + $objectName = str(data_get($matches, 2)) + ->trim('\\') + ->toString(); $objectType = $type; break; } - // If there wasn't a recognized namespace, we'll assume it's a - // domain object in an ad-hoc namespace. + // If there wasn't a resolvable namespace, we'll treat it + // as a root-level domain object. if (! $objectNamespace) { - // e.g., Domain\Invoicing\AdHoc\Nested\Thing - $objectNamespace = str($fullyQualifiedClass) + // Examples: + // - Domain\Invoicing\[Nested\Thing] + // - Domain\Invoicing\[Deeply\Nested\Thing] + // - Domain\Invoicing\[Thing] + $objectName = str($fullyQualifiedClass) ->after(Str::finish(DomainResolver::domainRootNamespace(), '\\')) ->after('\\') - ->before("\\{$objectName}") ->toString(); } @@ -68,6 +78,14 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp ->before("\\{$objectNamespace}") ->toString(); + // Edge case to handle root-level domain objects + if ( + $objectName === $objectNamespace + && ! str($fullyQualifiedClass)->endsWith("{$objectNamespace}\\{$objectName}") + ) { + $objectNamespace = ''; + } + // Reconstruct the path $path = Path::join( DomainResolver::domainPath(), @@ -76,6 +94,15 @@ public static function fromClass(string $fullyQualifiedClass, ?string $objectTyp "{$objectName}.php", ); + // dump([ + // 'fullyQualifiedClass' => $fullyQualifiedClass, + // 'fullNamespace' => $fullNamespace, + // 'domainName' => $domainName, + // 'objectNamespace' => $objectNamespace, + // 'objectName' => $objectName, + // 'objectType' => $objectType, + // ]); + return new self( name: $objectName, domain: $domainName, diff --git a/tests/Generator/AbsoluteNameTest.php b/tests/Generator/AbsoluteNameTest.php new file mode 100644 index 0000000..31113b7 --- /dev/null +++ b/tests/Generator/AbsoluteNameTest.php @@ -0,0 +1,50 @@ +headline()->plural()->toString()); + + $expectedFullPath = Path::normalize(base_path($expectedPath)); + + if (file_exists($expectedFullPath)) { + unlink($expectedFullPath); + } + + expect(file_exists($expectedFullPath))->toBeFalse(); + + $command = "ddd:{$type} {$nameInput}"; + + Artisan::call($command); + + $output = Artisan::output(); + + expect($output)->toContainFilepath($expectedPath); + + expect(file_exists($expectedFullPath))->toBeTrue(); + + $contents = file_get_contents($expectedFullPath); + + expect($contents)->toContain("namespace {$expectedNamespace};"); +})->with([ + 'model' => ['model', 'Other:/MyModels/MyModel', 'Domain\Other\MyModels', 'src/Domain/Other/MyModels/MyModel.php'], + 'model (without overriding)' => ['model', 'Other:MyModels/MyModel', 'Domain\Other\Models\MyModels', 'src/Domain/Other/Models/MyModels/MyModel.php'], + 'exception inside models directory' => ['exception', 'Invoicing:/Models/Exceptions/InvoiceNotFoundException', 'Domain\Invoicing\Models\Exceptions', 'src/Domain/Invoicing/Models/Exceptions/InvoiceNotFoundException.php'], + 'provider' => ['provider', 'Other:/RootLevelProvider', 'Domain\Other', 'src/Domain/Other/RootLevelProvider.php'], + 'policy' => ['policy', 'Other:/RootLevelPolicy', 'Domain\Other', 'src/Domain/Other/RootLevelPolicy.php'], + 'job' => ['job', 'Other:/Custom/Namespaced/Job', 'Domain\Other\Custom\Namespaced', 'src/Domain/Other/Custom/Namespaced/Job.php'], + 'class' => ['class', 'Other:/Models/FakeModel', 'Domain\Other\Models', 'src/Domain/Other/Models/FakeModel.php'], + 'class (subdomain)' => ['class', 'Other.Subdomain:/Models/FakeModel', 'Domain\Other\Subdomain\Models', 'src/Domain/Other/Subdomain/Models/FakeModel.php'], + 'provider (subdomain)' => ['provider', 'Other.Subdomain:/RootLevelProvider', 'Domain\Other\Subdomain', 'src/Domain/Other/Subdomain/RootLevelProvider.php'], +]); diff --git a/tests/Generator/ExtendedCommandsTest.php b/tests/Generator/ExtendedCommandsTest.php index 2678e2d..4c0819f 100644 --- a/tests/Generator/ExtendedCommandsTest.php +++ b/tests/Generator/ExtendedCommandsTest.php @@ -5,7 +5,7 @@ use Lunarstorm\LaravelDDD\Support\Domain; it('can generate extended objects', function ($type, $objectName, $domainPath, $domainRoot) { - if (in_array($type, ['enum'])) { + if (in_array($type, ['class', 'enum', 'interface', 'trait'])) { skipOnLaravelVersionsBelow('11'); } @@ -38,7 +38,6 @@ 'cast' => ['cast', 'SomeCast'], 'channel' => ['channel', 'SomeChannel'], 'command' => ['command', 'SomeCommand'], - 'enum' => ['enum', 'SomeEnum'], 'event' => ['event', 'SomeEvent'], 'exception' => ['exception', 'SomeException'], 'job' => ['job', 'SomeJob'], @@ -51,4 +50,8 @@ 'resource' => ['resource', 'SomeResource'], 'rule' => ['rule', 'SomeRule'], 'scope' => ['scope', 'SomeScope'], + 'class' => ['class', 'SomeClass'], + 'enum' => ['enum', 'SomeEnum'], + 'interface' => ['interface', 'SomeInterface'], + 'trait' => ['trait', 'SomeTrait'], ])->with('domainPaths'); diff --git a/tests/Generator/MakeModelTest.php b/tests/Generator/MakeModelTest.php index b4a5f04..8772f20 100644 --- a/tests/Generator/MakeModelTest.php +++ b/tests/Generator/MakeModelTest.php @@ -149,6 +149,7 @@ })->with([ ['Domain\Shared\Models\CustomBaseModel', 'src/Domain/Shared/Models/CustomBaseModel.php'], ['Domain\Core\Models\CustomBaseModel', 'src/Domain/Core/Models/CustomBaseModel.php'], + ['Domain\Core\BaseModels\CustomBaseModel', 'src/Domain/Core/BaseModels/CustomBaseModel.php'], ]); it('will not generate a base model if the configured base model is out of scope', function ($baseModel) { @@ -158,9 +159,7 @@ Artisan::call('ddd:model Fruits:Lemon'); - expect(Artisan::output()) - ->toContain("Configured base model {$baseModel} doesn't exist.") - ->not->toContain("Generating {$baseModel}"); + expect(Artisan::output())->not->toContain("Configured base model {$baseModel} doesn't exist, generating..."); expect(class_exists($baseModel))->toBeFalse(); })->with([ @@ -175,9 +174,7 @@ Artisan::call('ddd:model Fruits:Lemon'); - expect(Artisan::output()) - ->not->toContain("Configured base model {$baseModel} doesn't exist.") - ->not->toContain("Generating {$baseModel}"); + expect(Artisan::output())->not->toContain("Configured base model {$baseModel} doesn't exist, generating..."); })->with([ ['Illuminate\Database\Eloquent\Model'], ['Lunarstorm\LaravelDDD\Models\DomainModel'], diff --git a/tests/Generator/MakeViewModelTest.php b/tests/Generator/MakeViewModelTest.php index dbaf6e9..c15d412 100644 --- a/tests/Generator/MakeViewModelTest.php +++ b/tests/Generator/MakeViewModelTest.php @@ -103,7 +103,11 @@ Artisan::call("ddd:view-model {$domain}:{$className}"); - expect(Artisan::output())->toContain("Base view model {$baseViewModel} doesn't exist, generating"); + $output = Artisan::output(); + + dump($output); + + expect($output)->toContain("Base view model {$baseViewModel} doesn't exist, generating"); expect(file_exists($expectedBaseViewModelPath))->toBeTrue(); diff --git a/tests/ValueObject/DomainObjectTest.php b/tests/ValueObject/DomainObjectTest.php index d096477..4bbaad7 100644 --- a/tests/ValueObject/DomainObjectTest.php +++ b/tests/ValueObject/DomainObjectTest.php @@ -15,16 +15,65 @@ ->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'], + [ + // Full Class Name + 'Domain\Invoicing\Models\Invoice', + + // Domain + 'Invoicing', + + // Object Namespace + 'Models', + + // Object Name + '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', + ], + + [ + 'Domain\Invoicing\InvoicingServiceProvider', + 'Invoicing', + '', + 'InvoicingServiceProvider', + ], // 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'], + // ['Domain\Internal\Invoicing\AdHoc\Thing', 'Internal\Invoicing', '', 'Adhoc\Thing'], + // ['Domain\Internal\Invoicing\Deeply\Nested\Adhoc\Thing', 'Internal\Invoicing', '', 'Deeply\Nested\Adhoc\Thing'], ]); it('cannot create a domain object from unresolvable classes', function (string $class) {