diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c822f8..7e7c8b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `laravel-ddd` will be documented in this file. +## [Unversioned] +### Added +- Formal support for subdomains (nested domains). For example, to generate model `Domain\Reporting\Internal\Models\InvoiceReport`, the domain argument can be specified in any of the following ways: + - `ddd:model Reporting\\Internal InvoiceReport` + - `ddd:model Reporting/Internal InvoiceReport` + - `ddd:model Reporting.Internal InvoiceReport` +- Implement abstract `Lunarstorm\LaravelDDD\Factories\DomainFactory` extension of `Illuminate\Database\Eloquent\Factories\Factory`: + - Implements `DomainFactory::resolveFactoryName()` to resolve the corresponding factory for a domain model. + - Will resolve the correct factory if the model belongs to a subdomain; `Domain\Reporting\Internal\Models\InvoiceReport` will correctly resolve to `Database\Factories\Reporting\Internal\InvoiceReportFactory`. + +### Changed +- Default base model implementation in `base-model.php.stub` now uses using `DomainFactory::factoryForModel()` inside the `newFactory` method to resolve the model factory. + ## [0.6.1] - 2023-08-14 ### Fixed - Ensure generated domain factories set the `protected $model` property. diff --git a/composer.json b/composer.json index f5e7b06..76d4256 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,10 @@ }, "autoload-dev": { "psr-4": { - "Lunarstorm\\LaravelDDD\\Tests\\": "tests" + "Lunarstorm\\LaravelDDD\\Tests\\": "tests", + "App\\": "vendor/orchestra/testbench-core/laravel/app/", + "Database\\Factories\\": "vendor/orchestra/testbench-core/laravel/database/factories/", + "Domain\\": "vendor/orchestra/testbench-core/laravel/src/Domain/" } }, "scripts": { @@ -50,7 +53,8 @@ "analyse": "vendor/bin/phpstan analyse", "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", - "format": "vendor/bin/pint" + "format": "vendor/bin/pint", + "lint": "vendor/bin/pint" }, "config": { "sort-packages": true, diff --git a/src/Commands/DomainGeneratorCommand.php b/src/Commands/DomainGeneratorCommand.php index 38aeee4..5d01d86 100644 --- a/src/Commands/DomainGeneratorCommand.php +++ b/src/Commands/DomainGeneratorCommand.php @@ -50,6 +50,7 @@ protected function getDomain() { return str($this->getDomainInput()) ->trim() + ->replace(['.', '/'], '\\') ->studly() ->toString(); } diff --git a/src/Commands/MakeFactory.php b/src/Commands/MakeFactory.php index 235986e..0fc7085 100644 --- a/src/Commands/MakeFactory.php +++ b/src/Commands/MakeFactory.php @@ -92,14 +92,17 @@ protected function preparePlaceholders(): array $name = $this->getNameInput(); - $namespacedModel = $this->option('model') - ? $domain->namespacedModel($this->option('model')) - : $domain->namespacedModel($this->guessModelName($name)); + $modelName = $this->option('model') ?: $this->guessModelName($name); + + $domainModel = $domain->model($modelName); + + $domainFactory = $domain->factory($name); return [ - 'namespacedModel' => $namespacedModel, - 'model' => class_basename($namespacedModel), + 'namespacedModel' => $domainModel->fqn, + 'model' => class_basename($domainModel->fqn), 'factory' => $this->getFactoryName(), + 'namespace' => $domainFactory->namespace, ]; } @@ -109,6 +112,6 @@ protected function guessModelName($name) $name = substr($name, 0, -7); } - return (new Domain($this->getDomain()))->namespacedModel($name); + return (new Domain($this->getDomain()))->model($name)->name; } } diff --git a/src/Factories/DomainFactory.php b/src/Factories/DomainFactory.php new file mode 100644 index 0000000..52a7bfa --- /dev/null +++ b/src/Factories/DomainFactory.php @@ -0,0 +1,53 @@ + $modelName + * @return null|class-string<\Illuminate\Database\Eloquent\Factories\Factory> + */ + public static function resolveFactoryName(string $modelName) + { + $resolver = function (string $modelName) { + $domainNamespace = static::domainNamespace(); + $modelNamespace = config('ddd.namespaces.models'); + + // Expected domain model FQN: + // {DomainNamespace}\{Domain}\{ModelNamespace}\{Model} + + if (! Str::startsWith($modelName, $domainNamespace)) { + // Not a domain model + return null; + } + + $domain = str($modelName) + ->after($domainNamespace) + ->beforeLast($modelNamespace) + ->trim('\\') + ->toString(); + + $modelBaseName = class_basename($modelName); + + return static::$namespace."{$domain}\\{$modelBaseName}Factory"; + }; + + return $resolver($modelName); + } +} diff --git a/src/Models/DomainModel.php b/src/Models/DomainModel.php new file mode 100644 index 0000000..dd01188 --- /dev/null +++ b/src/Models/DomainModel.php @@ -0,0 +1,19 @@ +domainRoot = basename(config('ddd.paths.domains')); + if (is_null($subdomain)) { + // If a subdomain isn't explicitly specified, we + // will attempt to parse it from the domain. + $parts = str($domain) + ->replace(['\\', '/'], '.') + ->explode('.') + ->filter(); + + $domain = $parts->shift(); + + if ($parts->count() > 0) { + $subdomain = $parts->implode('.'); + } + } + + $domain = str($domain)->trim('\\/')->toString(); + + $subdomain = str($subdomain)->trim('\\/')->toString(); + + $this->domainWithSubdomain = str($domain) + ->when($subdomain, fn ($domain) => $domain->append("\\{$subdomain}")) + ->toString(); + + $this->domain = $domain; + + $this->subdomain = $subdomain ?: null; + + $this->dotName = $this->subdomain + ? "{$this->domain}.{$this->subdomain}" + : $this->domain; + + $this->namespace = DomainNamespaces::from($this->domain, $this->subdomain); + + $this->path = Path::join(config('ddd.paths.domains'), $this->domainWithSubdomain); } - public function relativePath(string $path = ''): string + protected function getDomainBasePath() { - return implode(DIRECTORY_SEPARATOR, [ - $this->domain, - $path, - ]); + return app()->basePath(config('ddd.paths.domains')); } - public function namespacedModel(?string $model): string + public function path(string $path = null): string { - $prefix = implode('\\', [ - $this->domainRoot, - $this->domain, - config('ddd.namespaces.models'), - ]); + if (is_null($path)) { + return $this->path; + } - $model = str($model) - ->replace($prefix, '') - ->ltrim('\\') + $path = str($path) + ->replace($this->namespace->root, '') + ->replace(['\\', '/'], DIRECTORY_SEPARATOR) + ->append('.php') ->toString(); - return implode('\\', [$prefix, $model]); + return Path::join($this->path, $path); + } + + public function relativePath(string $path = ''): string + { + return collect([$this->domain, $path])->filter()->implode(DIRECTORY_SEPARATOR); + } + + public function model(string $name): DomainObject + { + $name = str_replace($this->namespace->models.'\\', '', $name); + + return new DomainObject( + name: $name, + namespace: $this->namespace->models, + fqn: $this->namespace->models.'\\'.$name, + path: $this->path($this->namespace->models.'\\'.$name), + ); + } + + public function factory(string $name): DomainObject + { + $name = str_replace($this->namespace->factories.'\\', '', $name); + + return new DomainObject( + name: $name, + namespace: $this->namespace->factories, + fqn: $this->namespace->factories.'\\'.$name, + path: str("database/factories/{$this->domainWithSubdomain}/{$name}.php") + ->replace(['\\', '/'], DIRECTORY_SEPARATOR) + ->toString() + ); + } + + public function dataTransferObject(string $name): DomainObject + { + $name = str_replace($this->namespace->dataTransferObjects.'\\', '', $name); + + return new DomainObject( + name: $name, + namespace: $this->namespace->dataTransferObjects, + fqn: $this->namespace->dataTransferObjects.'\\'.$name, + path: $this->path($this->namespace->dataTransferObjects.'\\'.$name), + ); + } + + public function dto(string $name): DomainObject + { + return $this->dataTransferObject($name); + } + + public function viewModel(string $name): DomainObject + { + $name = str_replace($this->namespace->viewModels.'\\', '', $name); + + return new DomainObject( + name: $name, + namespace: $this->namespace->viewModels, + fqn: $this->namespace->viewModels.'\\'.$name, + path: $this->path($this->namespace->viewModels.'\\'.$name), + ); + } + + public function valueObject(string $name): DomainObject + { + $name = str_replace($this->namespace->valueObjects.'\\', '', $name); + + return new DomainObject( + name: $name, + namespace: $this->namespace->valueObjects, + fqn: $this->namespace->valueObjects.'\\'.$name, + path: $this->path($this->namespace->valueObjects.'\\'.$name), + ); + } + + public function action(string $name): DomainObject + { + $name = str_replace($this->namespace->actions.'\\', '', $name); + + return new DomainObject( + name: $name, + namespace: $this->namespace->actions, + fqn: $this->namespace->actions.'\\'.$name, + path: $this->path($this->namespace->actions.'\\'.$name), + ); } } diff --git a/src/Support/DomainResolver.php b/src/Support/DomainResolver.php new file mode 100644 index 0000000..43e075d --- /dev/null +++ b/src/Support/DomainResolver.php @@ -0,0 +1,10 @@ +when($subdomain, fn ($domain) => $domain->append("\\{$subdomain}")) + ->toString(); + + $root = basename(config('ddd.paths.domains')); + + $domainNamespace = implode('\\', [$root, $domainWithSubdomain]); + + return new self( + root: $domainNamespace, + models: "{$domainNamespace}\\".config('ddd.namespaces.models'), + factories: "Database\\Factories\\{$domainWithSubdomain}", + dataTransferObjects: "{$domainNamespace}\\".config('ddd.namespaces.data_transfer_objects'), + viewModels: "{$domainNamespace}\\".config('ddd.namespaces.view_models'), + valueObjects: "{$domainNamespace}\\".config('ddd.namespaces.value_objects'), + actions: "{$domainNamespace}\\".config('ddd.namespaces.actions'), + ); + } +} diff --git a/src/ValueObjects/DomainObject.php b/src/ValueObjects/DomainObject.php new file mode 100644 index 0000000..8d77121 --- /dev/null +++ b/src/ValueObjects/DomainObject.php @@ -0,0 +1,14 @@ +explode('\\'); - $domain = $parts[1]; - $model = $parts->last(); - - return app("Database\\Factories\\{$domain}\\{$model}Factory"); + return DomainFactory::factoryForModel(get_called_class()); } } diff --git a/tests/Datasets/Domains.php b/tests/Datasets/Domains.php index ac2b920..56842c1 100644 --- a/tests/Datasets/Domains.php +++ b/tests/Datasets/Domains.php @@ -6,3 +6,9 @@ ['Custom/PathTo/Domain', 'Domain'], ['Custom/PathTo/Domains', 'Domains'], ]); + +dataset('domainSubdomain', [ + // Domain, Subdomain + ['Customer', 'Reporting'], + ['Customer', null], +]); diff --git a/tests/Fixtures/Models/Invoice.php b/tests/Fixtures/Models/Invoice.php new file mode 100644 index 0000000..7c0f6a2 --- /dev/null +++ b/tests/Fixtures/Models/Invoice.php @@ -0,0 +1,9 @@ +word()); - $domain = Str::studly(fake()->word()); + $factoryName = "{$modelName}Factory"; - $domainHelper = new Domain($domain); - $namespacedModel = $domainHelper->namespacedModel($modelName); - // Domain factories are expected to be generated in: - // database/factories/{Domain}/{Factory}.php + $domainArgument = str($domain) + ->when($subdomain, fn ($domain) => $domain->append("\\{$subdomain}")) + ->toString(); + + $domain = new Domain($domainArgument); + + $domainModel = $domain->model($modelName); - $relativePath = implode('/', [ - 'database/factories', - $domain, - "{$factoryName}.php", - ]); + $domainFactory = $domain->factory($factoryName); - $expectedFactoryPath = base_path($relativePath); + $expectedFactoryPath = base_path($domainFactory->path); if (file_exists($expectedFactoryPath)) { unlink($expectedFactoryPath); @@ -33,30 +31,26 @@ expect(file_exists($expectedFactoryPath))->toBeFalse(); - Artisan::call("ddd:factory {$domain} {$modelName}"); + Artisan::call("ddd:factory {$domain->dotName} {$modelName}"); + + $outputPath = str_replace('\\', '/', $domainFactory->path); expect(Artisan::output())->when( Feature::IncludeFilepathInGeneratorCommandOutput->exists(), - fn ($output) => $output->toContain($relativePath), + fn ($output) => $output->toContain($outputPath), ); expect(file_exists($expectedFactoryPath))->toBeTrue( "Expected factory to be generated in {$expectedFactoryPath}" ); - $expectedNamespace = implode('\\', [ - 'Database', - 'Factories', - $domain, - ]); - $contents = file_get_contents($expectedFactoryPath); expect($contents) - ->toContain("namespace {$expectedNamespace};") - ->toContain("use {$namespacedModel};") - ->toContain("class {$factoryName} extends Factory") + ->toContain("namespace {$domainFactory->namespace};") + ->toContain("use {$domainModel->fqn};") + ->toContain("class {$domainFactory->name} extends Factory") ->toContain("protected \$model = {$modelName}::class;"); -})->with('domainPaths'); +})->with('domainPaths')->with('domainSubdomain'); it('normalizes factory classes with Factory suffix')->markTestIncomplete(); diff --git a/tests/Generator/MakeModelTest.php b/tests/Generator/MakeModelTest.php index 040e1f5..55628ae 100644 --- a/tests/Generator/MakeModelTest.php +++ b/tests/Generator/MakeModelTest.php @@ -3,6 +3,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; +use Lunarstorm\LaravelDDD\Support\Domain; use Lunarstorm\LaravelDDD\Tests\Fixtures\Enums\Feature; it('can generate domain models', function ($domainPath, $domainRoot) { @@ -44,62 +45,51 @@ expect(file_get_contents($expectedModelPath))->toContain("namespace {$expectedNamespace};"); })->with('domainPaths'); -it('can generate a domain model with factory', function ($domainPath, $domainRoot) { +it('can generate a domain model with factory', function ($domainPath, $domainRoot, $domainName, $subdomain) { Config::set('ddd.paths.domains', $domainPath); $modelName = Str::studly(fake()->word()); - $domain = Str::studly(fake()->word()); + + $domain = new Domain($domainName, $subdomain); $factoryName = "{$modelName}Factory"; - $relativePath = implode('/', [ - $domainPath, - $domain, - config('ddd.namespaces.models'), - "{$modelName}.php", - ]); + $domainModel = $domain->model($modelName); - $expectedModelPath = base_path($relativePath); + $domainFactory = $domain->factory($factoryName); + + $expectedModelPath = base_path($domainModel->path); if (file_exists($expectedModelPath)) { unlink($expectedModelPath); } - $expectedFactoryPath = base_path(implode('/', [ - 'database/factories', - $domain, - "{$factoryName}.php", - ])); + $expectedFactoryPath = base_path($domainFactory->path); if (file_exists($expectedFactoryPath)) { unlink($expectedFactoryPath); } Artisan::call('ddd:model', [ - 'domain' => $domain, + 'domain' => $domain->dotName, 'name' => $modelName, '--factory' => true, ]); + $outputPath = str_replace('\\', '/', $domainModel->path); + expect(Artisan::output())->when( Feature::IncludeFilepathInGeneratorCommandOutput->exists(), - fn ($output) => $output->toContain($relativePath), + fn ($output) => $output->toContain($outputPath), ); expect(file_exists($expectedModelPath))->toBeTrue(); expect(file_exists($expectedFactoryPath))->toBeTrue(); - $expectedNamespacedModel = implode('\\', [ - $domainRoot, - $domain, - config('ddd.namespaces.models'), - $modelName, - ]); - expect(file_get_contents($expectedFactoryPath)) - ->toContain("use {$expectedNamespacedModel};") + ->toContain("use {$domainModel->fqn};") ->toContain("protected \$model = {$modelName}::class;"); -})->with('domainPaths'); +})->with('domainPaths')->with('domainSubdomain'); it('normalizes generated model to pascal case', function ($given, $normalized) { $domain = Str::studly(fake()->word()); diff --git a/tests/Model/FactoryTest.php b/tests/Model/FactoryTest.php new file mode 100644 index 0000000..9b33e11 --- /dev/null +++ b/tests/Model/FactoryTest.php @@ -0,0 +1,23 @@ +toBe($expectedFactoryClass); +})->with([ + ["Domain\Customer\Models\Invoice", "Database\Factories\Customer\InvoiceFactory"], + ["Domain\Reports\Accounting\Models\InvoiceReport", "Database\Factories\Reports\Accounting\InvoiceReportFactory"], + ["App\Models\Invoice", null], +]); + +it('can instantiate a domain model factory', function ($domainParameter, $modelName, $modelClass) { + Artisan::call("ddd:model -f {$domainParameter} {$modelName}"); + expect(class_exists($modelClass))->toBeTrue(); + expect($modelClass::factory())->toBeInstanceOf(Factory::class); +})->with([ + // Domain, Model, Model FQN + ['Fruits', 'Apple', 'Domain\Fruits\Models\Apple'], + ['Fruits.Citrus', 'Lime', 'Domain\Fruits\Citrus\Models\Lime'], +]); diff --git a/tests/Support/DomainTest.php b/tests/Support/DomainTest.php new file mode 100644 index 0000000..5b20189 --- /dev/null +++ b/tests/Support/DomainTest.php @@ -0,0 +1,81 @@ +domain->toBe($domainName) + ->subdomain->toBe($subdomainName); +})->with('domainSubdomain'); + +it('can extract domain and subdomain from dot or slash separated string', function ($domainName, $subdomainName, $separator) { + $dotPath = $subdomainName ? "{$domainName}{$separator}{$subdomainName}" : $domainName; + $domain = new Domain($dotPath); + + expect($domain) + ->domain->toBe($domainName) + ->subdomain->toBe($subdomainName); +})->with('domainSubdomain')->with(['.', '\\', '/']); + +it('can describe a domain model', function ($domainName, $name, $expectedFQN, $expectedPath) { + expect((new Domain($domainName))->model($name)) + ->name->toBe($name) + ->fqn->toBe($expectedFQN) + ->path->toBe(Path::normalize($expectedPath)); +})->with([ + ['Reporting', 'InvoiceReport', 'Domain\\Reporting\\Models\\InvoiceReport', 'src/Domain/Reporting/Models/InvoiceReport.php'], + ['Reporting.Internal', 'InvoiceReport', 'Domain\\Reporting\\Internal\\Models\\InvoiceReport', 'src/Domain/Reporting/Internal/Models/InvoiceReport.php'], +]); + +it('can describe a domain factory', function ($domainName, $name, $expectedFQN, $expectedPath) { + expect((new Domain($domainName))->factory($name)) + ->name->toBe($name) + ->fqn->toBe($expectedFQN) + ->path->toBe(Path::normalize($expectedPath)); +})->with([ + ['Reporting', 'InvoiceReportFactory', 'Database\\Factories\\Reporting\\InvoiceReportFactory', 'database/factories/Reporting/InvoiceReportFactory.php'], + ['Reporting.Internal', 'InvoiceReportFactory', 'Database\\Factories\\Reporting\\Internal\\InvoiceReportFactory', 'database/factories/Reporting/Internal/InvoiceReportFactory.php'], +]); + +it('can describe a data transfer object', function ($domainName, $name, $expectedFQN, $expectedPath) { + expect((new Domain($domainName))->dataTransferObject($name)) + ->name->toBe($name) + ->fqn->toBe($expectedFQN) + ->path->toBe(Path::normalize($expectedPath)); +})->with([ + ['Reporting', 'InvoiceData', 'Domain\\Reporting\\Data\\InvoiceData', 'src/Domain/Reporting/Data/InvoiceData.php'], + ['Reporting.Internal', 'InvoiceData', 'Domain\\Reporting\\Internal\\Data\\InvoiceData', 'src/Domain/Reporting/Internal/Data/InvoiceData.php'], +]); + +it('can describe a view model', function ($domainName, $name, $expectedFQN, $expectedPath) { + expect((new Domain($domainName))->viewModel($name)) + ->name->toBe($name) + ->fqn->toBe($expectedFQN) + ->path->toBe(Path::normalize($expectedPath)); +})->with([ + ['Reporting', 'InvoiceReportViewModel', 'Domain\\Reporting\\ViewModels\\InvoiceReportViewModel', 'src/Domain/Reporting/ViewModels/InvoiceReportViewModel.php'], + ['Reporting.Internal', 'InvoiceReportViewModel', 'Domain\\Reporting\\Internal\\ViewModels\\InvoiceReportViewModel', 'src/Domain/Reporting/Internal/ViewModels/InvoiceReportViewModel.php'], +]); + +it('can describe a value object', function ($domainName, $name, $expectedFQN, $expectedPath) { + expect((new Domain($domainName))->valueObject($name)) + ->name->toBe($name) + ->fqn->toBe($expectedFQN) + ->path->toBe(Path::normalize($expectedPath)); +})->with([ + ['Reporting', 'InvoiceTotal', 'Domain\\Reporting\\ValueObjects\\InvoiceTotal', 'src/Domain/Reporting/ValueObjects/InvoiceTotal.php'], + ['Reporting.Internal', 'InvoiceTotal', 'Domain\\Reporting\\Internal\\ValueObjects\\InvoiceTotal', 'src/Domain/Reporting/Internal/ValueObjects/InvoiceTotal.php'], +]); + +it('can describe an action', function ($domainName, $name, $expectedFQN, $expectedPath) { + expect((new Domain($domainName))->action($name)) + ->name->toBe($name) + ->fqn->toBe($expectedFQN) + ->path->toBe(Path::normalize($expectedPath)); +})->with([ + ['Reporting', 'SendInvoiceReport', 'Domain\\Reporting\\Actions\\SendInvoiceReport', 'src/Domain/Reporting/Actions/SendInvoiceReport.php'], + ['Reporting.Internal', 'SendInvoiceReport', 'Domain\\Reporting\\Internal\\Actions\\SendInvoiceReport', 'src/Domain/Reporting/Internal/Actions/SendInvoiceReport.php'], +]); diff --git a/tests/TestCase.php b/tests/TestCase.php index 73bc786..44aaa10 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -70,6 +70,7 @@ protected function cleanFilesAndFolders() File::deleteDirectory(resource_path('stubs/ddd')); File::deleteDirectory(base_path('Custom')); + File::deleteDirectory(base_path('src/Domain')); File::deleteDirectory(base_path('src/Domains')); } }