diff --git a/README.md b/README.md index 6310442..d33b254 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,9 +126,49 @@ 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 + return [ /* @@ -153,11 +200,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 +219,7 @@ return [ 'enum' => 'Enums', 'event' => 'Events', 'exception' => 'Exceptions', + 'factory' => 'Database\Factories', 'job' => 'Jobs', 'listener' => 'Listeners', 'mail' => 'Mail', @@ -232,6 +280,52 @@ return [ | */ 'base_action' => null, + + /* + |-------------------------------------------------------------------------- + | Autoloading + |-------------------------------------------------------------------------- + | + | Configure whether domain providers, commands, policies, and factories + | should be auto-discovered and registered. + | + */ + '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, + ], + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | The folder where the domain cache files will be stored. Used for domain + | autoloading. + | + */ + 'cache_directory' => 'bootstrap/cache/ddd', ]; ``` diff --git a/composer.json b/composer.json index 51cfca0..c1ae6cc 100644 --- a/composer.json +++ b/composer.json @@ -21,14 +21,15 @@ "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": { + "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", @@ -43,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/config/ddd.php b/config/ddd.php index a1bd431..6e45920 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', @@ -110,4 +111,50 @@ | */ 'base_action' => null, + + /* + |-------------------------------------------------------------------------- + | Autoloading + |-------------------------------------------------------------------------- + | + | Configure whether domain providers, commands, policies, and factories + | should be auto-discovered and registered. + | + */ + '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, + ], + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | The folder where the domain cache files will be stored. Used for domain + | autoloading. + | + */ + 'cache_directory' => 'bootstrap/cache/ddd', ]; diff --git a/src/Commands/CacheClearCommand.php b/src/Commands/CacheClearCommand.php new file mode 100644 index 0000000..7e4c465 --- /dev/null +++ b/src/Commands/CacheClearCommand.php @@ -0,0 +1,20 @@ +components->info('Domain cache cleared successfully.'); + } +} diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php new file mode 100644 index 0000000..4d4fe68 --- /dev/null +++ b/src/Commands/CacheCommand.php @@ -0,0 +1,24 @@ +components->info('Domain providers cached successfully.'); + + DomainAutoloader::cacheCommands(); + + $this->components->info('Domain commands cached successfully.'); + } +} 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/Factories/DomainFactory.php b/src/Factories/DomainFactory.php index 6518d0d..47f7fe1 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/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php index cd5cbc4..4994536 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -2,6 +2,9 @@ namespace Lunarstorm\LaravelDDD; +use Illuminate\Support\Facades\Event; +use Lunarstorm\LaravelDDD\Listeners\CacheClearSubscriber; +use Lunarstorm\LaravelDDD\Support\DomainAutoloader; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -19,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, @@ -57,4 +62,11 @@ public function packageBooted() $this->package->basePath('/../stubs') => resource_path("stubs/{$this->package->shortName()}"), ], "{$this->package->shortName()}-stubs"); } + + public function packageRegistered() + { + (new DomainAutoloader())->autoload(); + + Event::subscribe(CacheClearSubscriber::class); + } } diff --git a/src/Listeners/CacheClearSubscriber.php b/src/Listeners/CacheClearSubscriber.php new file mode 100644 index 0000000..76886d6 --- /dev/null +++ b/src/Listeners/CacheClearSubscriber.php @@ -0,0 +1,26 @@ +listen('cache:clearing', [$this, 'handle']); + } +} diff --git a/src/Support/Domain.php b/src/Support/Domain.php index 17bf5e1..3f1d7df 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, + fullyQualifiedName: $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, + fullyQualifiedName: $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 new file mode 100644 index 0000000..ca75ec8 --- /dev/null +++ b/src/Support/DomainAutoloader.php @@ -0,0 +1,191 @@ +has('ddd.autoload')) { + return; + } + + $this->handleProviders(); + + if (app()->runningInConsole()) { + $this->handleCommands(); + } + + if (config('ddd.autoload.policies') === true) { + $this->handlePolicies(); + } + + if (config('ddd.autoload.factories') === true) { + $this->handleFactories(); + } + } + + protected static function normalizePaths($path): array + { + return collect($path) + ->filter(fn ($path) => is_dir($path)) + ->toArray(); + } + + protected function handleProviders(): void + { + $providers = DomainCache::has('domain-providers') + ? DomainCache::get('domain-providers') + : static::discoverProviders(); + + foreach ($providers as $provider) { + app()->register($provider); + } + } + + protected function handleCommands(): void + { + $commands = DomainCache::has('domain-commands') + ? DomainCache::get('domain-commands') + : static::discoverCommands(); + + foreach ($commands as $command) { + $this->registerCommand($command); + } + } + + protected function registerCommand($class) + { + ConsoleApplication::starting(function ($artisan) use ($class) { + $artisan->resolve($class); + }); + } + + 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") + ->fullyQualifiedName; + } + + $classDirname = str_replace('/', '\\', dirname(str_replace('\\', '/', $class))); + + $classDirnameSegments = explode('\\', $classDirname); + + 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'; + })->reverse()->values()->first(function ($class) { + return class_exists($class); + }) ?: [$classDirname.'\\Policies\\'.class_basename($class).'Policy']); + }); + } + + protected function handleFactories(): void + { + 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\\') + : Str::after($modelName, $appNamespace); + + return 'Database\\Factories\\'.$modelName.'Factory'; + }); + } + + protected static function discoverProviders(): array + { + $configValue = config('ddd.autoload.providers'); + + if ($configValue === false) { + return []; + } + + $paths = static::normalizePaths( + $configValue === true ? app()->basePath(DomainResolver::domainPath()) : $configValue + ); + + if (empty($paths)) { + return []; + } + + return Lody::classesFromFinder(Finder::create()->files()->in($paths)) + ->isNotAbstract() + ->isInstanceOf(ServiceProvider::class) + ->toArray(); + } + + protected static function discoverCommands(): array + { + $configValue = config('ddd.autoload.commands'); + + if ($configValue === false) { + return []; + } + + $paths = static::normalizePaths( + $configValue === true ? + app()->basePath(DomainResolver::domainPath()) + : $configValue + ); + + if (empty($paths)) { + return []; + } + + 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() + { + try { + return Container::getInstance() + ->make(Application::class) + ->getNamespace(); + } catch (Throwable) { + return 'App\\'; + } + } +} diff --git a/src/Support/DomainCache.php b/src/Support/DomainCache.php new file mode 100644 index 0000000..b6836f8 --- /dev/null +++ b/src/Support/DomainCache.php @@ -0,0 +1,65 @@ +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/Support/Path.php b/src/Support/Path.php index c516dbd..931230c 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/src/ValueObjects/DomainObject.php b/src/ValueObjects/DomainObject.php index 8d77121..3ec28dd 100644 --- a/src/ValueObjects/DomainObject.php +++ b/src/ValueObjects/DomainObject.php @@ -2,13 +2,87 @@ namespace Lunarstorm\LaravelDDD\ValueObjects; +use Illuminate\Support\Str; +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 $fullyQualifiedName, 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; + + break; + } + + // 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, + fullyQualifiedName: $fullyQualifiedClass, + path: $path, + type: $objectType, + ); + } } diff --git a/src/ValueObjects/DomainObjectNamespace.php b/src/ValueObjects/DomainObjectNamespace.php index 0681f8d..23c5240 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, ) { } @@ -25,6 +25,6 @@ public static function make(string $key, string $domain, ?string $subdomain = nu $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/.skeleton/app/Commands/InvoiceSecret.php b/tests/.skeleton/app/Commands/InvoiceSecret.php new file mode 100644 index 0000000..5798f48 --- /dev/null +++ b/tests/.skeleton/app/Commands/InvoiceSecret.php @@ -0,0 +1,18 @@ +line(Invoice::getSecret() ?? 'Invoice secret not set.'); + } +} diff --git a/tests/.skeleton/app/Models/Post.php b/tests/.skeleton/app/Models/Post.php new file mode 100644 index 0000000..a53eab2 --- /dev/null +++ b/tests/.skeleton/app/Models/Post.php @@ -0,0 +1,13 @@ + 'datetime', + ]; +} diff --git a/tests/.skeleton/app/Policies/PostPolicy.php b/tests/.skeleton/app/Policies/PostPolicy.php new file mode 100644 index 0000000..689c12e --- /dev/null +++ b/tests/.skeleton/app/Policies/PostPolicy.php @@ -0,0 +1,17 @@ +id === $post->user_id; + } +} diff --git a/tests/.skeleton/composer.json b/tests/.skeleton/composer.json new file mode 100644 index 0000000..6e77450 --- /dev/null +++ b/tests/.skeleton/composer.json @@ -0,0 +1,25 @@ +{ + "name": "laravel/laravel", + "description": "The Laravel Framework.", + "keywords": [ + "framework", + "laravel" + ], + "license": "MIT", + "type": "project", + "autoload": { + "classmap": [ + "database", + "tests/TestCase.php" + ], + "psr-4": { + "App\\": "app/" + } + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "minimum-stability": "dev" +} diff --git a/tests/.skeleton/database/factories/Invoicing/InvoiceFactory.php b/tests/.skeleton/database/factories/Invoicing/InvoiceFactory.php new file mode 100644 index 0000000..2301d46 --- /dev/null +++ b/tests/.skeleton/database/factories/Invoicing/InvoiceFactory.php @@ -0,0 +1,13 @@ +info('Invoice delivered!'); + + if ($secret = Invoice::getSecret()) { + $this->line($secret); + + return; + } + } +} diff --git a/tests/.skeleton/src/Domain/Invoicing/Database/Factories/InvoiceFactory.php b/tests/.skeleton/src/Domain/Invoicing/Database/Factories/InvoiceFactory.php new file mode 100644 index 0000000..9cad4a0 --- /dev/null +++ b/tests/.skeleton/src/Domain/Invoicing/Database/Factories/InvoiceFactory.php @@ -0,0 +1,13 @@ +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/Autoload/CommandTest.php b/tests/Autoload/CommandTest.php new file mode 100644 index 0000000..6fe1bf0 --- /dev/null +++ b/tests/Autoload/CommandTest.php @@ -0,0 +1,102 @@ +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); + }); +}); + +describe('with autoload', function () { + beforeEach(function () { + Config::set('ddd.autoload.commands', true); + + $this->setupTestApplication(); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); + + 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($command); + expect(Artisan::output())->toContain('Invoice delivered!'); + }); + + 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', + ]); + + expect(collect(Artisan::all())) + ->has($command) + ->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); + + $this->setupTestApplication(); + }); + + it('remembers the last cached state', function () { + DomainCache::set('domain-commands', []); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + + // command should not be recognized due to cached empty-state + expect(fn () => Artisan::call('invoice:deliver'))->toThrow(CommandNotFoundException::class); + }); + + it('can bust the cache', function () { + DomainCache::set('domain-commands', []); + DomainCache::clear(); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + + $this->artisan('invoice:deliver')->assertSuccessful(); + }); +}); diff --git a/tests/Autoload/FactoryTest.php b/tests/Autoload/FactoryTest.php new file mode 100644 index 0000000..26f366c --- /dev/null +++ b/tests/Autoload/FactoryTest.php @@ -0,0 +1,65 @@ +setupTestApplication(); + + Config::set('ddd.domain_namespace', 'Domain'); +}); + +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'], + + // 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'); + }); +}); + +describe('autoload disabled', function () { + beforeEach(function () { + Config::set('ddd.autoload.factories', false); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); + + it('cannot resolve factories that rely on autoloading', function ($modelClass) { + 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 new file mode 100644 index 0000000..5a4212b --- /dev/null +++ b/tests/Autoload/PolicyTest.php @@ -0,0 +1,30 @@ +setupTestApplication(); + + Config::set('ddd.domain_namespace', 'Domain'); + Config::set('ddd.autoload.factories', true); + + $this->afterApplicationCreated(function () { + (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('gracefully falls back for non-domain policies', 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/Autoload/ProviderTest.php b/tests/Autoload/ProviderTest.php new file mode 100644 index 0000000..7e3e775 --- /dev/null +++ b/tests/Autoload/ProviderTest.php @@ -0,0 +1,77 @@ +setupTestApplication(); +}); + +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); + }); +}); + +describe('with autoload', function () { + beforeEach(function () { + config([ + 'ddd.autoload.providers' => true, + ]); + + $this->afterApplicationCreated(function () { + (new DomainAutoloader())->autoload(); + }); + }); + + it('registers the provider', function () { + expect(app('invoicing'))->toEqual('invoicing-singleton'); + $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..029e4c2 --- /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') + ->expectsOutputToContain('Domain providers cached successfully.') + ->expectsOutputToContain('Domain commands cached successfully.') + ->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') + ->expectsOutputToContain('Domain cache cleared successfully.') + ->execute(); + + expect(DomainCache::get('domain-providers'))->toBeNull(); + expect(DomainCache::get('domain-commands'))->toBeNull(); +}); 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/Fixtures/Models/Invoice.php b/tests/Fixtures/Models/Invoice.php deleted file mode 100644 index 7c0f6a2..0000000 --- a/tests/Fixtures/Models/Invoice.php +++ /dev/null @@ -1,9 +0,0 @@ -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 ae94877..5d58ab9 100644 --- a/tests/InstallTest.php +++ b/tests/InstallTest.php @@ -18,13 +18,19 @@ $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); }); 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/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 9788b7f..ef9b57e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,44 +2,120 @@ 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\DomainCache; use Orchestra\Testbench\TestCase as Orchestra; use Symfony\Component\Process\Process; class TestCase extends Orchestra { + public static $configValues = []; + protected function setUp(): void { + $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->beforeApplicationDestroyed(function () { + $this->cleanSlate(); + }); + parent::setUp(); + } - $this->cleanFilesAndFolders(); + public static function configValues(array $values) + { + static::$configValues = $values; + } - $composerFile = base_path('composer.json'); - $data = json_decode(file_get_contents($composerFile), true); + protected function defineEnvironment($app) + { + tap($app['config'], function (Repository $config) { + foreach (static::$configValues as $key => $value) { + $config->set($key, $value); + } + }); - // Reset the domain namespace - Arr::forget($data, ['autoload', 'psr-4', 'Domains\\']); - Arr::forget($data, ['autoload', 'psr-4', 'Domain\\']); + // $this->updateComposer( + // set: [ + // [['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'], + // ], + // forget: [ + // ['autoload', 'psr-4', 'Domains\\'], + // ['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'); + protected function getComposerFileContents() + { + return file_get_contents(base_path('composer.json')); + } - file_put_contents($composerFile, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + protected function getComposerFileAsArray() + { + return json_decode($this->getComposerFileContents(), true); + } - $this->composerReload(); + protected function updateComposerFileFromArray(array $data) + { + file_put_contents(base_path('composer.json'), json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); - Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Lunarstorm\\LaravelDDD\\Database\\Factories\\'.class_basename($modelName).'Factory' - ); + return $this; + } - $this->beforeApplicationDestroyed(function () { - $this->cleanFilesAndFolders(); - }); + 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) @@ -64,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()); @@ -75,5 +153,29 @@ 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')); + + DomainCache::clear(); + } + + protected function setupTestApplication() + { + 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'); + } + + protected function setDomainPathInComposer($domainNamespace, $domainPath) + { + $this->updateComposer( + set: [ + [['autoload', 'psr-4', $domainNamespace.'\\'], $domainPath], + ], + ); + + return $this; } } diff --git a/tests/ValueObject/DomainObjectTest.php b/tests/ValueObject/DomainObjectTest.php new file mode 100644 index 0000000..d096477 --- /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'], +]);