diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 3855a08..c1a2fa8 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -16,7 +16,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' coverage: none - name: Install composer dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f14c12e..afe477a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, next] jobs: test: @@ -14,18 +14,15 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] php: [8.3, 8.2, 8.1] - laravel: [11.*, 10.*, 9.*] + laravel: [11.*, 10.25.*] stability: [prefer-lowest, prefer-stable] include: - laravel: 11.* testbench: 9.* carbon: ^3.0 - - laravel: 10.* + - laravel: 10.25.* testbench: 8.* - carbon: ^2.63 - - laravel: 9.* - testbench: 7.* - carbon: ^2.63 + carbon: 2.* exclude: - laravel: 11.* php: 8.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bad98b..e136668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to `laravel-ddd` will be documented in this file. +## [Unversioned] +### Added +- `ddd:list` to show a summary of current domains in the domain folder. +- For all generator commands, if a domain isn't specified, prompt for it with auto-completion suggestions based on the contents of the root domain folder. +- Command aliases for some generators: + - Data Transfer Object: `ddd:dto`, `ddd:data`, `ddd:data-transfer-object`, `ddd:datatransferobject` + - Value Object: `ddd:value`, `ddd:valueobject`, `ddd:value-object` + - View Model: `ddd:view-model`, `ddd:viewmodel` +- Additional generators that extend Laravel's generators and funnel the generated objects into the domain layer: + - `ddd:cast {domain}:{name}` + - `ddd:channel {domain}:{name}` + - `ddd:command {domain}:{name}` + - `ddd:enum {domain}:{name}` (Laravel 11 only) + - `ddd:event {domain}:{name}` + - `ddd:exception {domain}:{name}` + - `ddd:job {domain}:{name}` + - `ddd:listener {domain}:{name}` + - `ddd:mail {domain}:{name}` + - `ddd:notification {domain}:{name}` + - `ddd:observer {domain}:{name}` + - `ddd:policy {domain}:{name}` + - `ddd:provider {domain}:{name}` + - `ddd:resource {domain}:{name}` + - `ddd:rule {domain}:{name}` + - `ddd:scope {domain}:{name}` +- Support for autoloading and discovery of domain service providers, commands, policies, and factories. + +### Changed +- (BREAKING) For applications that published the config prior to this release, config should be removed, re-published, and re-configured. +- (BREAKING) Generator commands no longer receive a domain argument. Instead of `ddd:action Invoicing CreateInvoice`, one of the following would be used: + - Using the --domain option: `ddd:action CreateInvoice --domain=Invoicing` (this takes precedence). + - Shorthand syntax: `ddd:action Invoicing:CreateInvoice`. + - Or simply `ddd:action CreateInvoice` to be prompted for the domain afterwards. +- Improved the reliability of generating base view models when `ddd.base_view_model` is something other than the default `Domain\Shared\ViewModels\ViewModel`. +- Domain factories are now generated inside the domain layer under the configured factory namespace `ddd.namespaces.factory` (default `Database\Factories`). Factories located in `/database/factories//*` (v0.x) will continue to work as a fallback when attempting to resolve a domain model's factory. +- Minimum supported Laravel version is now 10.25. + +### Chore +- Dropped Laravel 9 support. + ## [0.10.0] - 2024-03-23 ### Added - Add `ddd.domain_path` and `ddd.domain_namespace` to config, to specify the path to the domain layer and root domain namespace more explicitly (replaces the previous `ddd.paths.domains` config). diff --git a/README.md b/README.md index da8c48d..387447d 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,9 @@ [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/lunarstorm/laravel-ddd/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/lunarstorm/laravel-ddd/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) [![Total Downloads](https://img.shields.io/packagist/dt/lunarstorm/laravel-ddd.svg?style=flat-square)](https://packagist.org/packages/lunarstorm/laravel-ddd) -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 a few more. - -> :warning: **Disclaimer**: This package is subject to frequent design changes as it evolves towards a stable v1.0 release. It is currently being tested and fine tuned within Lunarstorm's client projects. +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. ## Installation - You can install the package via composer: ```bash @@ -22,62 +19,163 @@ You may then initialize the package using the `ddd:install` artisan command. Thi php artisan ddd:install ``` +### Version Compatibility + Laravel | LaravelDDD +:---------------|:----------- + 9.x - 10.24.x | 0.x + 10.25.x | 1.x + 11.x | 1.x + +> This documentation is for 1.x. For 0.x usage, please refer to the [0.x README](https://github.com/lunarstorm/laravel-ddd/blob/v0.10.0/README.md). +> + +### Upgrading from 0.x +Things to be aware of when upgrading from 0.x: +- If the config file was published, it should be removed, re-published, and re-configured according to the latest format. A helper command `ddd:upgrade` is available to assist with this. +- If stubs were published, they should also be re-published and updated to ensure everything is up-to-date. +- In production, `ddd:cache` should be run during the deployment process to optimize autoloading. See the [Autoloading in Production](#autoloading-in-production) section for more details. + ## Usage +### Syntax +All domain generator commands use the following syntax: +```bash +# Specifying the domain as an option +php artisan ddd:{object} {name} --domain={domain} + +# Specifying the domain as part of the name (short-hand syntax) +php artisan ddd:{object} {domain}:{name} -The following generator commands are currently available: +# Not specifying the domain at all, which will then prompt +# 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 +# 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 + +# Cache domain manifests (used for autoloading) +php artisan ddd:cache + +# Clear the domain cache +php artisan ddd:clear ``` -Subdomains (nested domains) can be specified with dot notation: +### Subdomains (nested domains) +Subdomains can be specified with dot notation wherever a domain option is accepted. ```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) +# 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" 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 [ /* @@ -109,19 +207,36 @@ 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' => [ - 'models' => 'Models', - 'data_transfer_objects' => 'Data', - 'view_models' => 'ViewModels', - 'value_objects' => 'ValueObjects', - 'actions' => 'Actions', + 'model' => 'Models', + 'data_transfer_object' => 'Data', + 'view_model' => 'ViewModels', + 'value_object' => 'ValueObjects', + 'action' => 'Actions', + 'cast' => 'Casts', + 'channel' => 'Channels', + 'command' => 'Commands', + 'enum' => 'Enums', + 'event' => 'Events', + 'exception' => 'Exceptions', + 'factory' => 'Database\Factories', + 'job' => 'Jobs', + 'listener' => 'Listeners', + 'mail' => 'Mail', + 'notification' => 'Notifications', + 'observer' => 'Observers', + 'policy' => 'Policies', + 'provider' => 'Providers', + 'resource' => 'Resources', + 'rule' => 'Rules', + 'scope' => 'Scopes', ], /* @@ -172,6 +287,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 2f83313..c1ae6cc 100644 --- a/composer.json +++ b/composer.json @@ -19,20 +19,21 @@ ], "require": { "php": "^8.1|^8.2|^8.3", - "spatie/laravel-package-tools": "^1.13.0", - "illuminate/contracts": "^9.0|^10.0|^11.0" + "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": { - "laravel/pint": "^1.0", - "nunomaduro/collision": "^6.0|^7.0|^8.1", "larastan/larastan": "^2.0.1", - "orchestra/testbench": "^7|^8|^9.0", - "pestphp/pest": "^1.22|^2.0", - "pestphp/pest-plugin-laravel": "^1.1|^2.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.0|^8.1", + "orchestra/testbench": "^8|^9.0", + "pestphp/pest": "^2.34", + "pestphp/pest-plugin-laravel": "^2.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5|^10" + "phpstan/phpstan-phpunit": "^1.0" }, "autoload": { "psr-4": { @@ -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 e7dd5bf..6e45920 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -31,19 +31,36 @@ | 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' => [ - 'models' => 'Models', - 'data_transfer_objects' => 'Data', - 'view_models' => 'ViewModels', - 'value_objects' => 'ValueObjects', - 'actions' => 'Actions', + 'model' => 'Models', + 'data_transfer_object' => 'Data', + 'view_model' => 'ViewModels', + 'value_object' => 'ValueObjects', + 'action' => 'Actions', + 'cast' => 'Casts', + 'channel' => 'Channels', + 'command' => 'Commands', + 'enum' => 'Enums', + 'event' => 'Events', + 'exception' => 'Exceptions', + 'factory' => 'Database\Factories', + 'job' => 'Jobs', + 'listener' => 'Listeners', + 'mail' => 'Mail', + 'notification' => 'Notifications', + 'observer' => 'Observers', + 'policy' => 'Policies', + 'provider' => 'Providers', + 'resource' => 'Resources', + 'rule' => 'Rules', + 'scope' => 'Scopes', ], /* @@ -94,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/config/ddd.php.stub b/config/ddd.php.stub new file mode 100644 index 0000000..325d5ce --- /dev/null +++ b/config/ddd.php.stub @@ -0,0 +1,160 @@ + {{domain_path}}, + + /* + |-------------------------------------------------------------------------- + | Domain Namespace + |-------------------------------------------------------------------------- + | + | The root domain namespace. + | + */ + 'domain_namespace' => {{domain_namespace}}, + + /* + |-------------------------------------------------------------------------- + | Domain Object Namespaces + |-------------------------------------------------------------------------- + | + | This value contains the default namespaces of generated domain + | 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\* + | + */ + 'namespaces' => [ + 'model' => {{namespaces.model}}, + 'data_transfer_object' => {{namespaces.data_transfer_object}}, + 'view_model' => {{namespaces.view_model}}, + 'value_object' => {{namespaces.value_object}}, + 'action' => {{namespaces.action}}, + 'cast' => 'Casts', + 'channel' => 'Channels', + 'command' => 'Commands', + 'enum' => 'Enums', + 'event' => 'Events', + 'exception' => 'Exceptions', + 'factory' => 'Database\Factories', + 'job' => 'Jobs', + 'listener' => 'Listeners', + 'mail' => 'Mail', + 'notification' => 'Notifications', + 'observer' => 'Observers', + 'policy' => 'Policies', + 'provider' => 'Providers', + 'resource' => 'Resources', + 'rule' => 'Rules', + 'scope' => 'Scopes', + ], + + /* + |-------------------------------------------------------------------------- + | Base Model + |-------------------------------------------------------------------------- + | + | The base class which generated domain models should extend. By default, + | generated domain models will extend `Domain\Shared\Models\BaseModel`, + | which will be created if it doesn't already exist. + | + */ + 'base_model' => {{base_model}}, + + /* + |-------------------------------------------------------------------------- + | Base DTO + |-------------------------------------------------------------------------- + | + | The base class which generated data transfer objects should extend. By + | default, generated DTOs will extend `Spatie\LaravelData\Data` from + | Spatie's Laravel-data package, a highly recommended data object + | package to work with. + | + */ + 'base_dto' => {{base_dto}}, + + /* + |-------------------------------------------------------------------------- + | Base ViewModel + |-------------------------------------------------------------------------- + | + | The base class which generated view models should extend. By default, + | generated domain models will extend `Domain\Shared\ViewModels\BaseViewModel`, + | which will be created if it doesn't already exist. + | + */ + 'base_view_model' => {{base_view_model}}, + + /* + |-------------------------------------------------------------------------- + | Base Action + |-------------------------------------------------------------------------- + | + | The base class which generated action objects should extend. By default, + | generated actions are based on the `lorisleiva/laravel-actions` package + | and do not extend anything. + | + */ + 'base_action' => {{base_action}}, + + /* + |-------------------------------------------------------------------------- + | 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/phpunit.xml.dist b/phpunit.xml.dist index 0ea972c..c646074 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,23 @@ - - - tests - - - - - - - - - - - - - - - ./src - - + + + tests + + + + + + + + + + + + + + + ./src + + 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/Concerns/CanPromptForDomain.php b/src/Commands/Concerns/CanPromptForDomain.php new file mode 100644 index 0000000..f4d95e9 --- /dev/null +++ b/src/Commands/Concerns/CanPromptForDomain.php @@ -0,0 +1,35 @@ +mapWithKeys(fn ($name) => [Str::lower($name) => $name]); + + // Prompt for the domain + $domainName = suggest( + label: 'What is the domain?', + options: fn ($value) => collect($choices) + ->filter(fn ($name) => Str::contains($name, $value, ignoreCase: true)) + ->toArray(), + placeholder: 'Start typing to search...', + required: true + ); + + // Normalize the case of the domain name + // if it is an existing domain. + if ($match = $choices->get(Str::lower($domainName))) { + $domainName = $match; + } + + return $domainName; + } +} diff --git a/src/Commands/Concerns/ResolvesDomainFromInput.php b/src/Commands/Concerns/ResolvesDomainFromInput.php new file mode 100644 index 0000000..33e1587 --- /dev/null +++ b/src/Commands/Concerns/ResolvesDomainFromInput.php @@ -0,0 +1,91 @@ +name) { + 'ddd:base-view-model' => 'view_model', + 'ddd:base-model' => 'model', + 'ddd:value' => 'value_object', + 'ddd:dto' => 'data_transfer_object', + default => str($this->name)->after(':')->snake()->toString(), + }; + } + + protected function getDefaultNamespace($rootNamespace) + { + if ($this->domain) { + return $this->domain->namespaceFor($this->guessObjectType()); + } + + return parent::getDefaultNamespace($rootNamespace); + } + + protected function getPath($name) + { + if ($this->domain) { + return Path::normalize($this->laravel->basePath( + $this->domain->object($this->guessObjectType(), class_basename($name))->path + )); + } + + return parent::getPath($name); + } + + public function handle() + { + $nameInput = $this->getNameInput(); + + // If the name contains a domain prefix, extract it + // and strip it from the name argument. + $domainExtractedFromName = null; + + if (Str::contains($nameInput, ':')) { + $domainExtractedFromName = Str::before($nameInput, ':'); + $this->input->setArgument('name', Str::after($nameInput, ':')); + } + + $this->domain = match (true) { + // Domain was specified explicitly via option (priority) + filled($this->option('domain')) => new Domain($this->option('domain')), + + // Domain was specified as a prefix in the name + filled($domainExtractedFromName) => new Domain($domainExtractedFromName), + + default => null, + }; + + // If the domain is not set, prompt for it + if (! $this->domain) { + $this->domain = new Domain($this->promptForDomainName()); + } + + parent::handle(); + } +} diff --git a/src/Commands/MakeAction.php b/src/Commands/DomainActionMakeCommand.php similarity index 53% rename from src/Commands/MakeAction.php rename to src/Commands/DomainActionMakeCommand.php index eb4b6e8..c1a14f2 100644 --- a/src/Commands/MakeAction.php +++ b/src/Commands/DomainActionMakeCommand.php @@ -2,9 +2,7 @@ namespace Lunarstorm\LaravelDDD\Commands; -use Symfony\Component\Console\Input\InputArgument; - -class MakeAction extends DomainGeneratorCommand +class DomainActionMakeCommand extends DomainGeneratorCommand { protected $name = 'ddd:action'; @@ -17,29 +15,11 @@ class MakeAction extends DomainGeneratorCommand protected $type = 'Action'; - protected function getArguments() - { - return [ - ...parent::getArguments(), - - new InputArgument( - 'name', - InputArgument::REQUIRED, - 'The name of the Action', - ), - ]; - } - protected function getStub() { return $this->resolveStubPath('action.php.stub'); } - protected function getRelativeDomainNamespace(): string - { - return config('ddd.namespaces.actions', 'Actions'); - } - protected function preparePlaceholders(): array { $baseClass = config('ddd.base_action'); diff --git a/src/Commands/MakeBaseModel.php b/src/Commands/DomainBaseModelMakeCommand.php similarity index 83% rename from src/Commands/MakeBaseModel.php rename to src/Commands/DomainBaseModelMakeCommand.php index 7b4062e..559cd59 100644 --- a/src/Commands/MakeBaseModel.php +++ b/src/Commands/DomainBaseModelMakeCommand.php @@ -4,7 +4,7 @@ use Symfony\Component\Console\Input\InputArgument; -class MakeBaseModel extends DomainGeneratorCommand +class DomainBaseModelMakeCommand extends DomainGeneratorCommand { protected $name = 'ddd:base-model'; @@ -20,8 +20,6 @@ class MakeBaseModel extends DomainGeneratorCommand protected function getArguments() { return [ - ...parent::getArguments(), - new InputArgument( 'name', InputArgument::OPTIONAL, @@ -38,6 +36,6 @@ protected function getStub() protected function getRelativeDomainNamespace(): string { - return config('ddd.namespaces.models', 'Models'); + return config('ddd.namespaces.model', 'Models'); } } diff --git a/src/Commands/MakeBaseViewModel.php b/src/Commands/DomainBaseViewModelMakeCommand.php similarity index 82% rename from src/Commands/MakeBaseViewModel.php rename to src/Commands/DomainBaseViewModelMakeCommand.php index 5966277..afac4f7 100644 --- a/src/Commands/MakeBaseViewModel.php +++ b/src/Commands/DomainBaseViewModelMakeCommand.php @@ -4,7 +4,7 @@ use Symfony\Component\Console\Input\InputArgument; -class MakeBaseViewModel extends DomainGeneratorCommand +class DomainBaseViewModelMakeCommand extends DomainGeneratorCommand { protected $name = 'ddd:base-view-model'; @@ -20,8 +20,6 @@ class MakeBaseViewModel extends DomainGeneratorCommand protected function getArguments() { return [ - ...parent::getArguments(), - new InputArgument( 'name', InputArgument::OPTIONAL, @@ -38,6 +36,6 @@ protected function getStub() protected function getRelativeDomainNamespace(): string { - return config('ddd.namespaces.view_models', 'ViewModels'); + return config('ddd.namespaces.view_model', 'ViewModels'); } } diff --git a/src/Commands/DomainCastMakeCommand.php b/src/Commands/DomainCastMakeCommand.php new file mode 100644 index 0000000..30531e4 --- /dev/null +++ b/src/Commands/DomainCastMakeCommand.php @@ -0,0 +1,13 @@ +setAliases([ + 'ddd:data-transfer-object', + 'ddd:datatransferobject', + 'ddd:data', + ]); - new InputArgument( - 'name', - InputArgument::REQUIRED, - 'The name of the DTO', - ), - ]; + parent::configure(); } protected function getStub() @@ -37,7 +33,7 @@ protected function getStub() protected function getRelativeDomainNamespace(): string { - return config('ddd.namespaces.data_transfer_objects', 'Data'); + return config('ddd.namespaces.data_transfer_object', 'Data'); } protected function preparePlaceholders(): array diff --git a/src/Commands/DomainEnumMakeCommand.php b/src/Commands/DomainEnumMakeCommand.php new file mode 100644 index 0000000..d3110b7 --- /dev/null +++ b/src/Commands/DomainEnumMakeCommand.php @@ -0,0 +1,13 @@ +resolveStubPath('factory.php.stub'); } - protected function rootNamespace() - { - return 'Database\\Factories\\'; - } - - protected function getDefaultNamespace($rootNamespace) - { - $domain = $this->getDomain(); - - return $rootNamespace.'\\'.$domain; - } - - protected function getRelativeDomainNamespace(): string - { - return ''; - } - protected function getPath($name) { if (! str_ends_with($name, 'Factory')) { $name .= 'Factory'; } - $name = str($name) - ->replaceFirst($this->rootNamespace(), '') - ->replace('\\', '/') - ->ltrim('/') - ->append('.php') - ->toString(); - - return Path::normalize(base_path('database/factories/'.$name)); + return parent::getPath($name); } protected function getFactoryName() @@ -89,7 +50,7 @@ protected function getFactoryName() protected function preparePlaceholders(): array { - $domain = new Domain($this->getDomain()); + $domain = $this->domain; $name = $this->getNameInput(); @@ -99,9 +60,15 @@ protected function preparePlaceholders(): array $domainFactory = $domain->factory($name); + // dump('preparing placeholders', [ + // 'name' => $name, + // 'modelName' => $modelName, + // 'domainFactory' => $domainFactory, + // ]); + return [ - 'namespacedModel' => $domainModel->fqn, - 'model' => class_basename($domainModel->fqn), + 'namespacedModel' => $domainModel->fullyQualifiedName, + 'model' => class_basename($domainModel->fullyQualifiedName), 'factory' => $this->getFactoryName(), 'namespace' => $domainFactory->namespace, ]; @@ -113,6 +80,6 @@ protected function guessModelName($name) $name = substr($name, 0, -7); } - return (new Domain($this->getDomain()))->model($name)->name; + return $this->domain->model($name)->name; } } diff --git a/src/Commands/DomainGeneratorCommand.php b/src/Commands/DomainGeneratorCommand.php index 016c417..6421409 100644 --- a/src/Commands/DomainGeneratorCommand.php +++ b/src/Commands/DomainGeneratorCommand.php @@ -4,78 +4,23 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; +use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; use Lunarstorm\LaravelDDD\Support\DomainResolver; -use Lunarstorm\LaravelDDD\Support\Path; -use Symfony\Component\Console\Input\InputArgument; abstract class DomainGeneratorCommand extends GeneratorCommand { - protected function getArguments() - { - return [ - new InputArgument( - 'domain', - InputArgument::REQUIRED, - 'The domain' - ), - ]; - } - - protected function rootNamespace() - { - return str(DomainResolver::getConfiguredDomainNamespace()) - ->rtrim('/\\') - ->toString(); - } + use ResolvesDomainFromInput; - protected function getDefaultNamespace($rootNamespace) + protected function getRelativeDomainNamespace(): string { - $domain = $this->getDomain(); - - return $rootNamespace.'\\'.$domain.'\\'.$this->getRelativeDomainNamespace(); + return DomainResolver::getRelativeObjectNamespace($this->guessObjectType()); } - abstract protected function getRelativeDomainNamespace(): string; - protected function getNameInput() { return Str::studly($this->argument('name')); } - protected function getDomainInput() - { - return $this->argument('domain'); - } - - protected function getDomain() - { - return str($this->getDomainInput()) - ->trim() - ->replace(['.', '/'], '\\') - ->studly() - ->toString(); - } - - protected function getDomainBasePath() - { - return Path::normalize($this->laravel->basePath( - DomainResolver::getConfiguredDomainPath() ?? 'src/Domain' - )); - } - - protected function getPath($name) - { - $path = str($name) - ->replaceFirst($this->rootNamespace(), '') - ->replace('\\', '/') - ->ltrim('/') - ->append('.php') - ->prepend($this->getDomainBasePath().DIRECTORY_SEPARATOR) - ->toString(); - - return Path::normalize($path); - } - protected function resolveStubPath($path) { $path = ltrim($path, '/\\'); diff --git a/src/Commands/DomainJobMakeCommand.php b/src/Commands/DomainJobMakeCommand.php new file mode 100644 index 0000000..7fc5e37 --- /dev/null +++ b/src/Commands/DomainJobMakeCommand.php @@ -0,0 +1,13 @@ +map(function (string $name) { + $domain = new Domain($name); + + return [ + $domain->domain, + $domain->namespace->root, + Path::normalize($domain->path), + ]; + }) + ->toArray(); + + $this->table($headings, $table); + + $countDomains = count($table); + + $this->info(trans_choice("{$countDomains} domain|{$countDomains} domains", $countDomains)); + } +} diff --git a/src/Commands/DomainListenerMakeCommand.php b/src/Commands/DomainListenerMakeCommand.php new file mode 100644 index 0000000..9726d20 --- /dev/null +++ b/src/Commands/DomainListenerMakeCommand.php @@ -0,0 +1,13 @@ +resolveStubPath('model.php.stub'); } - protected function getRelativeDomainNamespace(): string - { - return config('ddd.namespaces.models', 'Models'); - } - protected function preparePlaceholders(): array { $baseClass = config('ddd.base_model'); @@ -103,8 +85,8 @@ protected function createBaseModelIfNeeded() if (! file_exists($baseModelPath)) { $this->info("Generating {$baseModel}..."); - $this->call(MakeBaseModel::class, [ - 'domain' => $domain, + $this->call(DomainBaseModelMakeCommand::class, [ + '--domain' => $domain, 'name' => $baseModelName, ]); } @@ -112,9 +94,9 @@ protected function createBaseModelIfNeeded() protected function createFactory() { - $this->call(MakeFactory::class, [ - 'domain' => $this->getDomain(), + $this->call(DomainFactoryMakeCommand::class, [ 'name' => $this->getNameInput().'Factory', + '--domain' => $this->domain->dotName, '--model' => $this->qualifyClass($this->getNameInput()), ]); } diff --git a/src/Commands/DomainNotificationMakeCommand.php b/src/Commands/DomainNotificationMakeCommand.php new file mode 100644 index 0000000..04de8ab --- /dev/null +++ b/src/Commands/DomainNotificationMakeCommand.php @@ -0,0 +1,13 @@ +setAliases([ + 'ddd:value-object', + 'ddd:valueobject', + ]); + + parent::configure(); + } + + protected function getStub() + { + return $this->resolveStubPath('value-object.php.stub'); + } +} diff --git a/src/Commands/DomainViewModelMakeCommand.php b/src/Commands/DomainViewModelMakeCommand.php new file mode 100644 index 0000000..f2e9e23 --- /dev/null +++ b/src/Commands/DomainViewModelMakeCommand.php @@ -0,0 +1,88 @@ +setAliases([ + 'ddd:viewmodel', + ]); + + parent::configure(); + } + + protected function getStub() + { + return $this->resolveStubPath('view-model.php.stub'); + } + + protected function preparePlaceholders(): array + { + $baseClass = config('ddd.base_view_model'); + $baseClassName = class_basename($baseClass); + + return [ + 'extends' => filled($baseClass) ? " extends {$baseClassName}" : '', + 'baseClassImport' => filled($baseClass) ? "use {$baseClass};" : '', + ]; + } + + public function handle() + { + if ($this->shouldCreateBaseViewModel()) { + $baseViewModel = config('ddd.base_view_model'); + + $this->warn("Base view model {$baseViewModel} doesn't exist, generating..."); + + $domain = DomainResolver::guessDomainFromClass($baseViewModel); + + $name = Str::after($baseViewModel, "{$domain}\\"); + + $this->call(DomainBaseViewModelMakeCommand::class, [ + '--domain' => $domain, + 'name' => $name, + ]); + } + + parent::handle(); + } + + protected function shouldCreateBaseViewModel(): bool + { + $baseViewModel = config('ddd.base_view_model'); + + // If the class exists, we don't need to create it. + if (class_exists($baseViewModel)) { + return false; + } + + // If the class is outside of the domain layer, we won't attempt to create it. + if (! DomainResolver::isDomainClass($baseViewModel)) { + 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($baseViewModel)))) { + return false; + } + + return true; + } +} diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index b566358..1ad031e 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -35,9 +35,9 @@ public function handle(): int public function registerDomainAutoload() { - $domainPath = DomainResolver::getConfiguredDomainPath(); + $domainPath = DomainResolver::domainPath(); - $domainRootNamespace = str(DomainResolver::getConfiguredDomainNamespace()) + $domainRootNamespace = str(DomainResolver::domainRootNamespace()) ->rtrim('/\\') ->toString(); diff --git a/src/Commands/MakeValueObject.php b/src/Commands/MakeValueObject.php deleted file mode 100644 index ad54ea5..0000000 --- a/src/Commands/MakeValueObject.php +++ /dev/null @@ -1,42 +0,0 @@ -resolveStubPath('value-object.php.stub'); - } -} diff --git a/src/Commands/MakeViewModel.php b/src/Commands/MakeViewModel.php deleted file mode 100644 index 16ebd16..0000000 --- a/src/Commands/MakeViewModel.php +++ /dev/null @@ -1,62 +0,0 @@ -resolveStubPath('view-model.php.stub'); - } - - public function handle() - { - $baseViewModel = config('ddd.base_view_model'); - - $parts = str($baseViewModel)->explode('\\'); - $baseName = $parts->last(); - $basePath = $this->getPath($baseViewModel); - - if (! file_exists($basePath)) { - $this->warn("Base view model {$baseViewModel} doesn't exist, generating..."); - - $this->call(MakeBaseViewModel::class, [ - 'domain' => 'Shared', - 'name' => $baseName, - ]); - } - - parent::handle(); - } -} diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php new file mode 100644 index 0000000..e696605 --- /dev/null +++ b/src/Commands/UpgradeCommand.php @@ -0,0 +1,61 @@ +components->warn('Config file was not published. Nothing to upgrade!'); + + return; + } + + $replacements = [ + 'domain_path' => 'paths.domain', + 'domain_namespace' => 'domain_namespace', + 'namespaces.model' => 'namespaces.models', + 'namespaces.data_transfer_object' => 'namespaces.data_transfer_objects', + 'namespaces.view_model' => 'namespaces.view_models', + 'namespaces.value_object' => 'namespaces.value_objects', + 'namespaces.action' => 'namespaces.actions', + 'base_model' => 'base_model', + 'base_dto' => 'base_dto', + 'base_view_model' => 'base_view_model', + 'base_action' => 'base_action', + ]; + + $oldConfig = require config_path('ddd.php'); + $oldConfig = Arr::dot($oldConfig); + + // Grab a flesh copy of the new config + $newConfigContent = file_get_contents(__DIR__.'/../../config/ddd.php.stub'); + + foreach ($replacements as $dotPath => $legacyKey) { + $value = match (true) { + array_key_exists($dotPath, $oldConfig) => $oldConfig[$dotPath], + array_key_exists($legacyKey, $oldConfig) => $oldConfig[$legacyKey], + default => config("ddd.{$dotPath}"), + }; + + $newConfigContent = str_replace( + '{{'.$dotPath.'}}', + var_export($value, true), + $newConfigContent + ); + } + + // Write the new config to the config file + file_put_contents(config_path('ddd.php'), $newConfigContent); + + $this->components->info('Configuration upgraded successfully.'); + } +} diff --git a/src/Factories/DomainFactory.php b/src/Factories/DomainFactory.php index 8767e49..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 { @@ -15,7 +16,7 @@ abstract class DomainFactory extends Factory */ protected static function domainNamespace() { - return Str::finish(DomainResolver::getConfiguredDomainNamespace(), '\\'); + return Str::finish(DomainResolver::domainRootNamespace(), '\\'); } /** @@ -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.models'); + $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 5827049..5246ecf 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -2,15 +2,9 @@ namespace Lunarstorm\LaravelDDD; -use Lunarstorm\LaravelDDD\Commands\InstallCommand; -use Lunarstorm\LaravelDDD\Commands\MakeAction; -use Lunarstorm\LaravelDDD\Commands\MakeBaseModel; -use Lunarstorm\LaravelDDD\Commands\MakeBaseViewModel; -use Lunarstorm\LaravelDDD\Commands\MakeDTO; -use Lunarstorm\LaravelDDD\Commands\MakeFactory; -use Lunarstorm\LaravelDDD\Commands\MakeModel; -use Lunarstorm\LaravelDDD\Commands\MakeValueObject; -use Lunarstorm\LaravelDDD\Commands\MakeViewModel; +use Illuminate\Support\Facades\Event; +use Lunarstorm\LaravelDDD\Listeners\CacheClearSubscriber; +use Lunarstorm\LaravelDDD\Support\DomainAutoloader; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -27,16 +21,40 @@ public function configurePackage(Package $package): void ->name('laravel-ddd') ->hasConfigFile() ->hasCommands([ - InstallCommand::class, - MakeModel::class, - MakeFactory::class, - MakeBaseModel::class, - MakeDTO::class, - MakeValueObject::class, - MakeViewModel::class, - MakeBaseViewModel::class, - MakeAction::class, + Commands\InstallCommand::class, + Commands\UpgradeCommand::class, + Commands\CacheCommand::class, + Commands\CacheClearCommand::class, + Commands\DomainListCommand::class, + Commands\DomainModelMakeCommand::class, + Commands\DomainFactoryMakeCommand::class, + Commands\DomainBaseModelMakeCommand::class, + Commands\DomainDtoMakeCommand::class, + Commands\DomainValueObjectMakeCommand::class, + Commands\DomainViewModelMakeCommand::class, + Commands\DomainBaseViewModelMakeCommand::class, + Commands\DomainActionMakeCommand::class, + Commands\DomainCastMakeCommand::class, + Commands\DomainChannelMakeCommand::class, + Commands\DomainConsoleMakeCommand::class, + Commands\DomainEventMakeCommand::class, + Commands\DomainExceptionMakeCommand::class, + Commands\DomainJobMakeCommand::class, + Commands\DomainListenerMakeCommand::class, + Commands\DomainMailMakeCommand::class, + Commands\DomainNotificationMakeCommand::class, + Commands\DomainObserverMakeCommand::class, + Commands\DomainPolicyMakeCommand::class, + Commands\DomainProviderMakeCommand::class, + Commands\DomainResourceMakeCommand::class, + Commands\DomainRuleMakeCommand::class, + Commands\DomainScopeMakeCommand::class, ]); + + // Enum generator only in Laravel 11 + if (app()->version() >= 11) { + $package->hasCommand(\Lunarstorm\LaravelDDD\Commands\DomainEnumMakeCommand::class); + } } public function packageBooted() @@ -45,4 +63,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 c539313..18a2dd4 100644 --- a/src/Support/Domain.php +++ b/src/Support/Domain.php @@ -19,6 +19,8 @@ class Domain public readonly DomainNamespaces $namespace; + public static array $objects = []; + public function __construct(string $domain, ?string $subdomain = null) { if (is_null($subdomain)) { @@ -54,12 +56,22 @@ public function __construct(string $domain, ?string $subdomain = null) $this->namespace = DomainNamespaces::from($this->domain, $this->subdomain); - $this->path = Path::join(DomainResolver::getConfiguredDomainPath(), $this->domainWithSubdomain); + $this->path = Path::join(DomainResolver::domainPath(), $this->domainWithSubdomain); + } + + protected function registerDomainObjects() + { + // WIP + } + + protected function registerDomainObject() + { + // WIP } protected function getDomainBasePath() { - return app()->basePath(DomainResolver::getConfiguredDomainPath()); + return app()->basePath(DomainResolver::domainPath()); } public function path(?string $path = null): string @@ -82,42 +94,40 @@ public function relativePath(string $path = ''): string return collect([$this->domain, $path])->filter()->implode(DIRECTORY_SEPARATOR); } - public function model(string $name): DomainObject + public function namespaceFor(string $type): string { - $name = str_replace($this->namespace->models.'\\', '', $name); + return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type); + } + + public function object(string $type, string $name): DomainObject + { + $namespace = $this->namespaceFor($type); + + $name = str($name)->replace("{$namespace}\\", '')->toString(); return new DomainObject( name: $name, - namespace: $this->namespace->models, - fqn: $this->namespace->models.'\\'.$name, - path: $this->path($this->namespace->models.'\\'.$name), + domain: $this->domain, + namespace: $namespace, + fullyQualifiedName: $namespace.'\\'.$name, + path: $this->path($namespace.'\\'.$name), + type: $type ); } - public function factory(string $name): DomainObject + public function model(string $name): DomainObject { - $name = str_replace($this->namespace->factories.'\\', '', $name); + return $this->object('model', $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 factory(string $name): DomainObject + { + return $this->object('factory', $name); } 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), - ); + return $this->object('data_transfer_object', $name); } public function dto(string $name): DomainObject @@ -127,37 +137,66 @@ public function dto(string $name): DomainObject 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), - ); + return $this->object('view_model', $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), - ); + return $this->object('value_object', $name); } public function action(string $name): DomainObject { - $name = str_replace($this->namespace->actions.'\\', '', $name); + return $this->object('action', $name); + } - return new DomainObject( - name: $name, - namespace: $this->namespace->actions, - fqn: $this->namespace->actions.'\\'.$name, - path: $this->path($this->namespace->actions.'\\'.$name), - ); + public function cast(string $name): DomainObject + { + return $this->object('cast', $name); + } + + public function command(string $name): DomainObject + { + return $this->object('command', $name); + } + + public function enum(string $name): DomainObject + { + return $this->object('enum', $name); + } + + public function job(string $name): DomainObject + { + return $this->object('job', $name); + } + + public function mail(string $name): DomainObject + { + return $this->object('mail', $name); + } + + public function notification(string $name): DomainObject + { + return $this->object('notification', $name); + } + + public function resource(string $name): DomainObject + { + return $this->object('resource', $name); + } + + public function rule(string $name): DomainObject + { + return $this->object('rule', $name); + } + + public function event(string $name): DomainObject + { + return $this->object('event', $name); + } + + public function exception(string $name): DomainObject + { + return $this->object('exception', $name); } } 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 @@ +basePath(static::domainPath().'/*'), GLOB_ONLYDIR); + + return collect($folders) + ->map(fn ($path) => basename($path)) + ->sort() + ->toArray(); + } + /** + * Get the current configured domain path. + */ + public static function domainPath(): ?string + { return config('ddd.domain_path'); } - public static function getConfiguredDomainNamespace(): ?string + /** + * Get the current configured root domain namespace. + */ + public static function domainRootNamespace(): ?string { - if (Config::has('ddd.paths.domains')) { - // Deprecated - return basename(config('ddd.paths.domains')); + return config('ddd.domain_namespace'); + } + + /** + * Resolve the relative domain object namespace. + * + * @param string $type The domain object type. + */ + public static function getRelativeObjectNamespace(string $type): string + { + return config("ddd.namespaces.{$type}", str($type)->plural()->studly()->toString()); + } + + public static function getDomainObjectNamespace(string $domain, string $type, ?string $object = null): string + { + $namespace = implode('\\', [static::domainRootNamespace(), $domain, static::getRelativeObjectNamespace($type)]); + + if ($object) { + $namespace .= "\\{$object}"; } - return config('ddd.domain_namespace'); + return $namespace; } + /** + * Attempt to resolve the domain of a given domain class. + */ public static function guessDomainFromClass(string $class): ?string { - $domainNamespace = Str::finish(DomainResolver::getConfiguredDomainNamespace(), '\\'); - - if (! str($class)->startsWith($domainNamespace)) { - // Not a domain model + if (! static::isDomainClass($class)) { + // Not a domain object return null; } $domain = str($class) - ->after($domainNamespace) + ->after(Str::finish(static::domainRootNamespace(), '\\')) ->before('\\') ->toString(); return $domain; } + + /** + * Attempt to resolve the file path of a given domain class. + */ + public static function guessPathFromClass(string $class): ?string + { + if (! static::isDomainClass($class)) { + // Not a domain object + return null; + } + + $classWithoutDomainRoot = str($class) + ->after(Str::finish(static::domainRootNamespace(), '\\')) + ->toString(); + + return Path::join(...[static::domainPath(), "{$classWithoutDomainRoot}.php"]); + } + + /** + * Determine whether a class is an object within the domain layer. + * + * @param string $class The fully qualified class name. + */ + public static function isDomainClass(string $class): bool + { + return str($class)->startsWith(Str::finish(static::domainRootNamespace(), '\\')); + } } 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/DomainNamespaces.php b/src/ValueObjects/DomainNamespaces.php index 44f9780..3f1d9d9 100644 --- a/src/ValueObjects/DomainNamespaces.php +++ b/src/ValueObjects/DomainNamespaces.php @@ -14,6 +14,16 @@ public function __construct( public readonly string $viewModels, public readonly string $valueObjects, public readonly string $actions, + public readonly string $casts, + public readonly string $commands, + public readonly string $enums, + public readonly string $events, + public readonly string $exceptions, + public readonly string $jobs, + public readonly string $mail, + public readonly string $notifications, + public readonly string $resources, + public readonly string $rules, ) { } @@ -23,18 +33,28 @@ public static function from(string $domain, ?string $subdomain = null): self ->when($subdomain, fn ($domain) => $domain->append("\\{$subdomain}")) ->toString(); - $root = DomainResolver::getConfiguredDomainNamespace(); + $root = DomainResolver::domainRootNamespace(); $domainNamespace = implode('\\', [$root, $domainWithSubdomain]); return new self( root: $domainNamespace, - models: "{$domainNamespace}\\".config('ddd.namespaces.models'), + models: "{$domainNamespace}\\".config('ddd.namespaces.model', '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'), + dataTransferObjects: "{$domainNamespace}\\".config('ddd.namespaces.data_transfer_object', 'Data'), + viewModels: "{$domainNamespace}\\".config('ddd.namespaces.view_model', 'ViewModels'), + valueObjects: "{$domainNamespace}\\".config('ddd.namespaces.value_object', 'ValueObjects'), + actions: "{$domainNamespace}\\".config('ddd.namespaces.action', 'Actions'), + enums: "{$domainNamespace}\\".config('ddd.namespaces.enums', 'Enums'), + events: "{$domainNamespace}\\".config('ddd.namespaces.event', 'Events'), + casts: "{$domainNamespace}\\".config('ddd.namespaces.cast', 'Casts'), + commands: "{$domainNamespace}\\".config('ddd.namespaces.command', 'Commands'), + exceptions: "{$domainNamespace}\\".config('ddd.namespaces.exception', 'Exceptions'), + jobs: "{$domainNamespace}\\".config('ddd.namespaces.job', 'Jobs'), + mail: "{$domainNamespace}\\".config('ddd.namespaces.mail', 'Mail'), + notifications: "{$domainNamespace}\\".config('ddd.namespaces.notification', 'Notifications'), + resources: "{$domainNamespace}\\".config('ddd.namespaces.resource', 'Resources'), + rules: "{$domainNamespace}\\".config('ddd.namespaces.rule', 'Rules'), ); } } 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 new file mode 100644 index 0000000..23c5240 --- /dev/null +++ b/src/ValueObjects/DomainObjectNamespace.php @@ -0,0 +1,30 @@ +when($subdomain, fn ($domain) => $domain->append("\\{$subdomain}")) + ->toString(); + + $root = DomainResolver::domainRootNamespace(); + + $domainNamespace = implode('\\', [$root, $domainWithSubdomain]); + + $namespace = "{$domainNamespace}\\".config("ddd.namespaces.{$key}", Str::studly($key)); + + return new self(type: $key, namespace: $namespace); + } +} diff --git a/stubs/view-model.php.stub b/stubs/view-model.php.stub index 718ebcc..bbfa729 100644 --- a/stubs/view-model.php.stub +++ b/stubs/view-model.php.stub @@ -2,9 +2,9 @@ namespace {{ namespace }}; -use {{ rootNamespace }}\Shared\ViewModels\ViewModel; +{{ baseClassImport }} -class {{ class }} extends ViewModel +class {{ class }}{{ extends }} { public function __construct() { 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/InstallTest.php b/tests/Command/InstallTest.php similarity index 85% rename from tests/InstallTest.php rename to tests/Command/InstallTest.php index ae94877..df49882 100644 --- a/tests/InstallTest.php +++ b/tests/Command/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/Command/ListTest.php b/tests/Command/ListTest.php new file mode 100644 index 0000000..36afcee --- /dev/null +++ b/tests/Command/ListTest.php @@ -0,0 +1,41 @@ +artisan('ddd:model', [ + 'name' => 'Invoice', + '--domain' => 'Invoicing', + ]); + + $this->artisan('ddd:dto', [ + 'name' => 'CustomerProfile', + '--domain' => 'Customer', + ]); + + $this->expectedDomains = [ + 'Customer', + 'Invoicing', + 'Shared', + ]; +}); + +it('can list domains', function () { + $expectedTableContent = collect($this->expectedDomains) + ->map(function (string $name) { + return [ + $name, + "Domain\\{$name}", + Path::normalize("src/Domain/{$name}"), + ]; + }) + ->toArray(); + + $this + ->artisan('ddd:list') + ->expectsTable([ + 'Domain', + 'Namespace', + 'Path', + ], $expectedTableContent); +}); diff --git a/tests/Command/UpgradeTest.php b/tests/Command/UpgradeTest.php new file mode 100644 index 0000000..9d649e6 --- /dev/null +++ b/tests/Command/UpgradeTest.php @@ -0,0 +1,44 @@ +toBeTrue(); + + $this->artisan('ddd:upgrade') + ->expectsOutputToContain('Configuration upgraded successfully.') + ->execute(); + + Artisan::call('config:clear'); + + $expectedValues = Arr::dot($expectedValues); + + $configAsArray = require config_path('ddd.php'); + + foreach ($expectedValues as $path => $value) { + expect(data_get($configAsArray, $path)) + ->toEqual($value, "Config {$path} does not match expected value."); + } +})->with('configUpgrades'); + +it('skips upgrade if config file was not published', function () { + $path = config_path('ddd.php'); + + if (file_exists($path)) { + unlink($path); + } + + expect(file_exists($path))->toBeFalse(); + + $this->artisan('ddd:upgrade') + ->expectsOutputToContain('Config file was not published. Nothing to upgrade!') + ->execute(); + + expect(file_exists($path))->toBeFalse(); +}); diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index c42fd63..526fb88 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -8,11 +8,11 @@ Config::set('ddd.domain_path', $path); - expect(DomainResolver::getConfiguredDomainPath())->toEqual($path); + expect(DomainResolver::domainPath())->toEqual($path); }); it('can customize the domain root namespace via ddd.domain_namespace', function () { Config::set('ddd.domain_namespace', 'Doughmain'); - expect(DomainResolver::getConfiguredDomainNamespace())->toEqual('Doughmain'); + expect(DomainResolver::domainRootNamespace())->toEqual('Doughmain'); }); diff --git a/tests/Datasets/Config.php b/tests/Datasets/Config.php new file mode 100644 index 0000000..eac108b --- /dev/null +++ b/tests/Datasets/Config.php @@ -0,0 +1,48 @@ + [ + __DIR__.'/resources/config.0.10.0.php', + + // Expected net result + [ + 'domain_path' => 'src/CustomDomainFolder', + 'domain_namespace' => 'CustomDomainNamespace', + 'namespaces' => [ + 'model' => 'CustomModels', + 'data_transfer_object' => 'CustomData', + 'view_model' => 'CustomViewModels', + 'value_object' => 'CustomValueObjects', + 'action' => 'CustomActions', + 'cast' => 'Casts', + 'channel' => 'Channels', + 'command' => 'Commands', + 'enum' => 'Enums', + 'event' => 'Events', + 'exception' => 'Exceptions', + 'factory' => 'Database\Factories', + 'job' => 'Jobs', + 'listener' => 'Listeners', + 'mail' => 'Mail', + 'notification' => 'Notifications', + 'observer' => 'Observers', + 'policy' => 'Policies', + 'provider' => 'Providers', + 'resource' => 'Resources', + 'rule' => 'Rules', + 'scope' => 'Scopes', + ], + 'base_model' => 'Domain\Shared\Models\CustomBaseModel', + 'base_dto' => 'Spatie\LaravelData\Data', + 'base_view_model' => 'Domain\Shared\ViewModels\CustomViewModel', + 'base_action' => null, + 'autoload' => [ + 'providers' => true, + 'commands' => true, + 'policies' => true, + 'factories' => true, + ], + 'cache_directory' => 'bootstrap/cache/ddd', + ], + ], +]); 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/Datasets/resources/config.0.10.0.php b/tests/Datasets/resources/config.0.10.0.php new file mode 100644 index 0000000..59e9940 --- /dev/null +++ b/tests/Datasets/resources/config.0.10.0.php @@ -0,0 +1,96 @@ + 'src/CustomDomainFolder', + + /* + |-------------------------------------------------------------------------- + | Domain Namespace + |-------------------------------------------------------------------------- + | + | The root domain namespace. + | + */ + 'domain_namespace' => 'CustomDomainNamespace', + + /* + |-------------------------------------------------------------------------- + | Domain Object Namespaces + |-------------------------------------------------------------------------- + | + | This value contains the default namespaces of generated domain + | 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/* + | + */ + 'namespaces' => [ + 'models' => 'CustomModels', + 'data_transfer_objects' => 'CustomData', + 'view_models' => 'CustomViewModels', + 'value_objects' => 'CustomValueObjects', + 'actions' => 'CustomActions', + ], + + /* + |-------------------------------------------------------------------------- + | Base Model + |-------------------------------------------------------------------------- + | + | The base class which generated domain models should extend. By default, + | generated domain models will extend `Domain\Shared\Models\BaseModel`, + | which will be created if it doesn't already exist. + | + */ + 'base_model' => 'Domain\Shared\Models\CustomBaseModel', + + /* + |-------------------------------------------------------------------------- + | Base DTO + |-------------------------------------------------------------------------- + | + | The base class which generated data transfer objects should extend. By + | default, generated DTOs will extend `Spatie\LaravelData\Data` from + | Spatie's Laravel-data package, a highly recommended data object + | package to work with. + | + */ + 'base_dto' => 'Spatie\LaravelData\Data', + + /* + |-------------------------------------------------------------------------- + | Base ViewModel + |-------------------------------------------------------------------------- + | + | The base class which generated view models should extend. By default, + | generated domain models will extend `Domain\Shared\ViewModels\BaseViewModel`, + | which will be created if it doesn't already exist. + | + */ + 'base_view_model' => 'Domain\Shared\ViewModels\CustomViewModel', + + /* + |-------------------------------------------------------------------------- + | Base Action + |-------------------------------------------------------------------------- + | + | The base class which generated action objects should extend. By default, + | generated actions are based on the `lorisleiva/laravel-actions` package + | and do not extend anything. + | + */ + 'base_action' => null, +]; diff --git a/tests/Model/FactoryTest.php b/tests/Factory/DomainFactoryTest.php similarity index 57% rename from tests/Model/FactoryTest.php rename to tests/Factory/DomainFactoryTest.php index afe9368..7c44d46 100644 --- a/tests/Model/FactoryTest.php +++ b/tests/Factory/DomainFactoryTest.php @@ -6,16 +6,28 @@ use Lunarstorm\LaravelDDD\Factories\DomainFactory; it('can resolve the factory name of a domain model', function ($modelClass, $expectedFactoryClass) { + $this->setupTestApplication(); + expect(DomainFactory::resolveFactoryName($modelClass))->toBe($expectedFactoryClass); })->with([ - ["Domain\Customer\Models\Invoice", "Database\Factories\Customer\InvoiceFactory"], - ["Domain\Reports\Accounting\Models\InvoiceReport", "Database\Factories\Reports\Accounting\InvoiceReportFactory"], + ["Domain\Invoicing\Models\Invoice", "Domain\Invoicing\Database\Factories\InvoiceFactory"], + ["Domain\Invoicing\Models\VanillaModel", "Domain\Invoicing\Database\Factories\VanillaModelFactory"], ["App\Models\Invoice", null], ]); +it('is backwards compatible with factories located in database/factories/**/*', function ($modelClass, $expectedFactoryClass) { + $this->setupTestApplication(); + + expect(DomainFactory::resolveFactoryName($modelClass))->toBe($expectedFactoryClass); +})->with([ + ["Domain\Customer\Models\Customer", "Database\Factories\Customer\CustomerFactory"], + ["Domain\Reports\Accounting\Models\InvoiceReport", "Database\Factories\Reports\Accounting\InvoiceReportFactory"], + ["Domain\Invoicing\Models\Payment", "Database\Factories\Invoicing\PaymentFactory"], +]); + it('can instantiate a domain model factory', function ($domainParameter, $modelName, $modelClass) { Config::set('ddd.base_model', 'Lunarstorm\LaravelDDD\Models\DomainModel'); - Artisan::call("ddd:model -f {$domainParameter} {$modelName}"); + Artisan::call("ddd:model -f {$domainParameter}:{$modelName}"); expect(class_exists($modelClass))->toBeTrue(); expect($modelClass::factory())->toBeInstanceOf(Factory::class); })->with([ diff --git a/tests/Fixtures/Enums/Feature.php b/tests/Fixtures/Enums/Feature.php index 97de399..21c2aa1 100644 --- a/tests/Fixtures/Enums/Feature.php +++ b/tests/Fixtures/Enums/Feature.php @@ -6,6 +6,7 @@ enum Feature: string { case PromptForMissingInput = '9.49.0'; case IncludeFilepathInGeneratorCommandOutput = '9.32.0'; + case LaravelPromptsPackage = '10.17'; public function exists(): bool { 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 @@ -object($type, $objectName); + + $relativePath = $domainObject->path; + $expectedNamespace = $domainObject->namespace; + $expectedPath = base_path($relativePath); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + expect(file_exists($expectedPath))->toBeFalse(); + + $command = "ddd:{$type} {$domain->domain}:{$objectName}"; + + Artisan::call($command); + + expect(Artisan::output())->toContainFilepath($relativePath); + + expect(file_exists($expectedPath))->toBeTrue(); + + expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); +})->with([ + 'cast' => ['cast', 'SomeCast'], + 'channel' => ['channel', 'SomeChannel'], + 'command' => ['command', 'SomeCommand'], + 'enum' => ['enum', 'SomeEnum'], + 'event' => ['event', 'SomeEvent'], + 'exception' => ['exception', 'SomeException'], + 'job' => ['job', 'SomeJob'], + 'listener' => ['listener', 'SomeListener'], + 'mail' => ['mail', 'SomeMail'], + 'notification' => ['notification', 'SomeNotification'], + 'observer' => ['observer', 'SomeObserver'], + 'policy' => ['policy', 'SomePolicy'], + 'provider' => ['provider', 'SomeProvider'], + 'resource' => ['resource', 'SomeResource'], + 'rule' => ['rule', 'SomeRule'], + 'scope' => ['scope', 'SomeScope'], +])->with('domainPaths'); diff --git a/tests/Generator/MakeActionTest.php b/tests/Generator/MakeActionTest.php index d80cb6f..2bdb1f2 100644 --- a/tests/Generator/MakeActionTest.php +++ b/tests/Generator/MakeActionTest.php @@ -15,7 +15,7 @@ $relativePath = implode('/', [ $domainPath, $domain, - config('ddd.namespaces.actions'), + config('ddd.namespaces.action'), "{$name}.php", ]); @@ -27,7 +27,7 @@ expect(file_exists($expectedPath))->toBeFalse(); - Artisan::call("ddd:action {$domain} {$name}"); + Artisan::call("ddd:action {$domain}:{$name}"); expect(Artisan::output())->when( Feature::IncludeFilepathInGeneratorCommandOutput->exists(), @@ -39,7 +39,7 @@ $expectedNamespace = implode('\\', [ $domainRoot, $domain, - config('ddd.namespaces.actions'), + config('ddd.namespaces.action'), ]); expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); @@ -51,22 +51,15 @@ $expectedPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.actions'), + config('ddd.namespaces.action'), "{$normalized}.php", ])); - Artisan::call("ddd:action {$domain} {$given}"); + Artisan::call("ddd:action {$domain}:{$given}"); expect(file_exists($expectedPath))->toBeTrue(); })->with('makeActionInputs'); -it('shows meaningful hints when prompting for missing input', function () { - $this->artisan('ddd:action') - ->expectsQuestion('What is the domain?', 'Utility') - ->expectsQuestion('What should the action be named?', 'DoThatThing') - ->assertExitCode(0); -})->ifSupportsPromptForMissingInput(); - it('extends a base action if specified in config', function ($baseAction) { Config::set('ddd.base_action', $baseAction); @@ -76,7 +69,7 @@ $expectedPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.actions'), + config('ddd.namespaces.action'), "{$name}.php", ])); @@ -84,7 +77,7 @@ unlink($expectedPath); } - Artisan::call("ddd:action {$domain} {$name}"); + Artisan::call("ddd:action {$domain}:{$name}"); expect(file_exists($expectedPath))->toBeTrue(); @@ -103,7 +96,7 @@ $expectedPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.actions'), + config('ddd.namespaces.action'), "{$name}.php", ])); @@ -111,7 +104,7 @@ unlink($expectedPath); } - Artisan::call("ddd:action {$domain} {$name}"); + Artisan::call("ddd:action {$domain}:{$name}"); expect(file_exists($expectedPath))->toBeTrue(); expect(file_get_contents($expectedPath))->toContain("class {$name}".PHP_EOL.'{'); diff --git a/tests/Generator/MakeBaseModelTest.php b/tests/Generator/MakeBaseModelTest.php index f7507e8..7a30076 100644 --- a/tests/Generator/MakeBaseModelTest.php +++ b/tests/Generator/MakeBaseModelTest.php @@ -14,7 +14,7 @@ $relativePath = implode('/', [ $domainPath, $domain, - config('ddd.namespaces.models'), + config('ddd.namespaces.model'), "{$modelName}.php", ]); @@ -26,7 +26,7 @@ expect(file_exists($expectedPath))->toBeFalse(); - Artisan::call("ddd:base-model {$domain} {$modelName}"); + Artisan::call("ddd:base-model {$domain}:{$modelName}"); expect(Artisan::output())->when( Feature::IncludeFilepathInGeneratorCommandOutput->exists(), @@ -38,14 +38,8 @@ $expectedNamespace = implode('\\', [ $domainRoot, $domain, - config('ddd.namespaces.models'), + config('ddd.namespaces.model'), ]); expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); })->with('domainPaths'); - -it('shows meaningful hints when prompting for missing input', function () { - $this->artisan('ddd:base-model') - ->expectsQuestion('What is the domain?', 'Shared') - ->assertExitCode(0); -})->ifSupportsPromptForMissingInput(); diff --git a/tests/Generator/MakeBaseViewModelTest.php b/tests/Generator/MakeBaseViewModelTest.php index 9b85555..2527ce2 100644 --- a/tests/Generator/MakeBaseViewModelTest.php +++ b/tests/Generator/MakeBaseViewModelTest.php @@ -2,7 +2,6 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Config; -use Lunarstorm\LaravelDDD\Tests\Fixtures\Enums\Feature; it('can generate base view model', function ($domainPath, $domainRoot) { Config::set('ddd.domain_path', $domainPath); @@ -14,7 +13,7 @@ $relativePath = implode('/', [ $domainPath, $domain, - config('ddd.namespaces.view_models'), + config('ddd.namespaces.view_model'), "{$className}.php", ]); @@ -26,26 +25,17 @@ expect(file_exists($expectedPath))->toBeFalse(); - Artisan::call("ddd:base-view-model {$domain} {$className}"); + Artisan::call("ddd:base-view-model {$domain}:{$className}"); - expect(Artisan::output())->when( - Feature::IncludeFilepathInGeneratorCommandOutput->exists(), - fn ($output) => $output->toContainFilepath($relativePath), - ); + expect(Artisan::output())->toContainFilepath($relativePath); expect(file_exists($expectedPath))->toBeTrue(); $expectedNamespace = implode('\\', [ $domainRoot, $domain, - config('ddd.namespaces.view_models'), + config('ddd.namespaces.view_model'), ]); expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); })->with('domainPaths'); - -it('shows meaningful hints when prompting for missing input', function () { - $this->artisan('ddd:base-view-model') - ->expectsQuestion('What is the domain?', 'Shared') - ->assertExitCode(0); -})->ifSupportsPromptForMissingInput(); diff --git a/tests/Generator/MakeDataTransferObjectTest.php b/tests/Generator/MakeDataTransferObjectTest.php index 5cfd786..8c7a408 100644 --- a/tests/Generator/MakeDataTransferObjectTest.php +++ b/tests/Generator/MakeDataTransferObjectTest.php @@ -15,7 +15,7 @@ $relativePath = implode('/', [ $domainPath, $domain, - config('ddd.namespaces.data_transfer_objects'), + config('ddd.namespaces.data_transfer_object'), "{$dtoName}.php", ]); @@ -27,7 +27,7 @@ expect(file_exists($expectedPath))->toBeFalse(); - Artisan::call("ddd:dto {$domain} {$dtoName}"); + Artisan::call("ddd:dto {$domain}:{$dtoName}"); expect(Artisan::output())->when( Feature::IncludeFilepathInGeneratorCommandOutput->exists(), @@ -39,30 +39,35 @@ $expectedNamespace = implode('\\', [ $domainRoot, $domain, - config('ddd.namespaces.data_transfer_objects'), + config('ddd.namespaces.data_transfer_object'), ]); expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); })->with('domainPaths'); +it('recognizes command aliases', function ($commandName) { + $this->artisan($commandName, [ + 'name' => 'InvoicePayload', + '--domain' => 'Invoicing', + ])->assertExitCode(0); +})->with([ + 'ddd:dto', + 'ddd:data-transfer-object', + 'ddd:datatransferobject', + 'ddd:data', +]); + it('normalizes generated data transfer object to pascal case', function ($given, $normalized) { $domain = Str::studly(fake()->word()); $expectedPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.data_transfer_objects'), + config('ddd.namespaces.data_transfer_object'), "{$normalized}.php", ])); - Artisan::call("ddd:dto {$domain} {$given}"); + Artisan::call("ddd:dto {$domain}:{$given}"); expect(file_exists($expectedPath))->toBeTrue(); })->with('makeDtoInputs'); - -it('shows meaningful hints when prompting for missing input', function () { - $this->artisan('ddd:dto') - ->expectsQuestion('What is the domain?', 'Utility') - ->expectsQuestion('What should the data transfer object be named?', 'Belt') - ->assertExitCode(0); -})->ifSupportsPromptForMissingInput(); diff --git a/tests/Generator/MakeFactoryTest.php b/tests/Generator/MakeFactoryTest.php index 2af1625..5d02d90 100644 --- a/tests/Generator/MakeFactoryTest.php +++ b/tests/Generator/MakeFactoryTest.php @@ -33,7 +33,7 @@ expect(file_exists($expectedFactoryPath))->toBeFalse(); - Artisan::call("ddd:factory {$domain->dotName} {$modelName}"); + Artisan::call("ddd:factory {$domain->dotName}:{$modelName}"); expect(Artisan::output())->when( Feature::IncludeFilepathInGeneratorCommandOutput->exists(), @@ -48,7 +48,7 @@ expect($contents) ->toContain("namespace {$domainFactory->namespace};") - ->toContain("use {$domainModel->fqn};") + ->toContain("use {$domainModel->fullyQualifiedName};") ->toContain("class {$domainFactory->name} extends Factory") ->toContain("protected \$model = {$modelName}::class;"); })->with('domainPaths')->with('domainSubdomain'); diff --git a/tests/Generator/MakeModelTest.php b/tests/Generator/MakeModelTest.php index d4abde0..b4a5f04 100644 --- a/tests/Generator/MakeModelTest.php +++ b/tests/Generator/MakeModelTest.php @@ -2,7 +2,6 @@ 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; @@ -10,13 +9,13 @@ Config::set('ddd.domain_path', $domainPath); Config::set('ddd.domain_namespace', $domainRoot); - $modelName = Str::studly(fake()->word()); - $domain = Str::studly(fake()->word()); + $modelName = 'Record'; + $domain = 'World'; $relativePath = implode('/', [ $domainPath, $domain, - config('ddd.namespaces.models'), + config('ddd.namespaces.model'), "{$modelName}.php", ]); @@ -28,7 +27,7 @@ expect(file_exists($expectedModelPath))->toBeFalse(); - Artisan::call("ddd:model {$domain} {$modelName}"); + Artisan::call("ddd:model {$domain}:{$modelName}"); expect(Artisan::output())->when( Feature::IncludeFilepathInGeneratorCommandOutput->exists(), @@ -40,7 +39,7 @@ $expectedNamespace = implode('\\', [ $domainRoot, $domain, - config('ddd.namespaces.models'), + config('ddd.namespaces.model'), ]); expect(file_get_contents($expectedModelPath))->toContain("namespace {$expectedNamespace};"); @@ -49,7 +48,7 @@ it('can generate a domain model with factory', function ($domainPath, $domainRoot, $domainName, $subdomain) { Config::set('ddd.domain_path', $domainPath); - $modelName = Str::studly(fake()->word()); + $modelName = 'Record'; $domain = new Domain($domainName, $subdomain); @@ -72,56 +71,55 @@ } Artisan::call('ddd:model', [ - 'domain' => $domain->dotName, 'name' => $modelName, + '--domain' => $domain->dotName, '--factory' => true, ]); - expect(Artisan::output())->when( - Feature::IncludeFilepathInGeneratorCommandOutput->exists(), - fn ($output) => $output->toContainFilepath($domainModel->path), - ); + $output = Artisan::output(); - expect(file_exists($expectedModelPath))->toBeTrue(); - expect(file_exists($expectedFactoryPath))->toBeTrue(); + expect($output)->toContainFilepath($domainModel->path); + + expect(file_exists($expectedModelPath))->toBeTrue("Expecting model file to be generated at {$expectedModelPath}"); + 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'); it('normalizes generated model to pascal case', function ($given, $normalized) { - $domain = Str::studly(fake()->word()); + $domain = 'World'; $expectedModelPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.models'), + config('ddd.namespaces.model'), "{$normalized}.php", ])); - Artisan::call("ddd:model {$domain} {$given}"); + Artisan::call("ddd:model {$domain}:{$given}"); expect(file_exists($expectedModelPath))->toBeTrue(); })->with('makeModelInputs'); it('generates the base model when possible', function ($baseModelClass, $baseModelPath) { - $modelName = Str::studly(fake()->word()); - $domain = Str::studly(fake()->word()); + $modelName = 'Record'; + $domain = 'World'; Config::set('ddd.base_model', $baseModelClass); $expectedModelPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.models'), + config('ddd.namespaces.model'), "{$modelName}.php", ])); $expectedModelClass = implode('\\', [ basename(config('ddd.domain_path')), $domain, - config('ddd.namespaces.models'), + config('ddd.namespaces.model'), $modelName, ]); @@ -141,7 +139,7 @@ expect(file_exists($expectedBaseModelPath))->toBeFalse("{$baseModelPath} expected not to exist."); - Artisan::call("ddd:model {$domain} {$modelName}"); + Artisan::call("ddd:model {$domain}:{$modelName}"); expect(file_exists($expectedBaseModelPath))->toBeTrue("Expecting base model file to be generated at {$baseModelPath}"); @@ -158,7 +156,7 @@ expect(class_exists($baseModel))->toBeFalse(); - Artisan::call('ddd:model Fruits Lemon'); + Artisan::call('ddd:model Fruits:Lemon'); expect(Artisan::output()) ->toContain("Configured base model {$baseModel} doesn't exist.") @@ -175,7 +173,7 @@ expect(class_exists($baseModel))->toBeTrue(); - Artisan::call('ddd:model Fruits Lemon'); + Artisan::call('ddd:model Fruits:Lemon'); expect(Artisan::output()) ->not->toContain("Configured base model {$baseModel} doesn't exist.") @@ -184,10 +182,3 @@ ['Illuminate\Database\Eloquent\Model'], ['Lunarstorm\LaravelDDD\Models\DomainModel'], ]); - -it('shows meaningful hints when prompting for missing input', function () { - $this->artisan('ddd:model') - ->expectsQuestion('What is the domain?', 'Utility') - ->expectsQuestion('What should the model be named?', 'Belt') - ->assertExitCode(0); -})->ifSupportsPromptForMissingInput(); diff --git a/tests/Generator/MakeValueObjectTest.php b/tests/Generator/MakeValueObjectTest.php index 92d8029..5b98ea7 100644 --- a/tests/Generator/MakeValueObjectTest.php +++ b/tests/Generator/MakeValueObjectTest.php @@ -9,13 +9,13 @@ Config::set('ddd.domain_path', $domainPath); Config::set('ddd.domain_namespace', $domainRoot); - $valueObjectName = Str::studly(fake()->word()); - $domain = Str::studly(fake()->word()); + $domain = 'Mission'; + $valueObjectName = 'ImpossibleValue'; $relativePath = implode('/', [ $domainPath, $domain, - config('ddd.namespaces.value_objects'), + config('ddd.namespaces.value_object'), "{$valueObjectName}.php", ]); @@ -27,7 +27,7 @@ expect(file_exists($expectedPath))->toBeFalse(); - Artisan::call("ddd:value {$domain} {$valueObjectName}"); + Artisan::call("ddd:value {$domain}:{$valueObjectName}"); expect(Artisan::output())->when( Feature::IncludeFilepathInGeneratorCommandOutput->exists(), @@ -39,23 +39,34 @@ $expectedNamespace = implode('\\', [ $domainRoot, $domain, - config('ddd.namespaces.value_objects'), + config('ddd.namespaces.value_object'), ]); expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); })->with('domainPaths'); +it('recognizes command aliases', function ($commandName) { + $this->artisan($commandName, [ + 'name' => 'InvoiceTotalValue', + '--domain' => 'Invoicing', + ])->assertExitCode(0); +})->with([ + 'ddd:value-object', + 'ddd:valueobject', + 'ddd:value', +]); + it('normalizes generated value object to pascal case', function ($given, $normalized) { $domain = Str::studly(fake()->word()); $expectedPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.value_objects'), + config('ddd.namespaces.value_object'), "{$normalized}.php", ])); - Artisan::call("ddd:value {$domain} {$given}"); + Artisan::call("ddd:value {$domain}:{$given}"); expect(file_exists($expectedPath))->toBeTrue(); })->with([ @@ -65,10 +76,3 @@ 'LargeNumber' => ['LargeNumber', 'LargeNumber'], 'large-number' => ['large-number', 'LargeNumber'], ]); - -it('shows meaningful hints when prompting for missing input', function () { - $this->artisan('ddd:value') - ->expectsQuestion('What is the domain?', 'Utility') - ->expectsQuestion('What should the value object be named?', 'Belt') - ->assertExitCode(0); -})->ifSupportsPromptForMissingInput(); diff --git a/tests/Generator/MakeViewModelTest.php b/tests/Generator/MakeViewModelTest.php index 2120023..dbaf6e9 100644 --- a/tests/Generator/MakeViewModelTest.php +++ b/tests/Generator/MakeViewModelTest.php @@ -3,19 +3,19 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Config; use Illuminate\Support\Str; -use Lunarstorm\LaravelDDD\Tests\Fixtures\Enums\Feature; it('can generate view models', function ($domainPath, $domainRoot) { Config::set('ddd.domain_path', $domainPath); Config::set('ddd.domain_namespace', $domainRoot); + Config::set('ddd.base_view_model', 'Domain\Shared\ViewModels\MyBaseViewModel'); - $viewModelName = Str::studly(fake()->word()); + $viewModelName = Str::studly(fake()->word().'ViewModel'); $domain = Str::studly(fake()->word()); $relativePath = implode('/', [ $domainPath, $domain, - config('ddd.namespaces.view_models'), + config('ddd.namespaces.view_model'), "{$viewModelName}.php", ]); @@ -27,47 +27,63 @@ expect(file_exists($expectedPath))->toBeFalse(); - Artisan::call("ddd:view-model {$domain} {$viewModelName}"); + Artisan::call("ddd:view-model {$domain}:{$viewModelName}"); - expect(Artisan::output())->when( - Feature::IncludeFilepathInGeneratorCommandOutput->exists(), - fn ($output) => $output->toContainFilepath($relativePath), - ); + expect(Artisan::output())->toContainFilepath($relativePath); expect(file_exists($expectedPath))->toBeTrue(); $expectedNamespace = implode('\\', [ $domainRoot, $domain, - config('ddd.namespaces.view_models'), + config('ddd.namespaces.view_model'), ]); - expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); + $fileContent = file_get_contents($expectedPath); + + expect($fileContent) + ->toContain( + "namespace {$expectedNamespace};", + "use Domain\Shared\ViewModels\MyBaseViewModel;", + "class {$viewModelName} extends MyBaseViewModel", + ); })->with('domainPaths'); +it('recognizes command aliases', function ($commandName) { + $this->artisan($commandName, [ + 'name' => 'ShowInvoiceViewModel', + '--domain' => 'Invoicing', + ])->assertExitCode(0); +})->with([ + 'ddd:view-model', + 'ddd:viewmodel', +]); + it('normalizes generated view model to pascal case', function ($given, $normalized) { $domain = Str::studly(fake()->word()); $expectedPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.view_models'), + config('ddd.namespaces.view_model'), "{$normalized}.php", ])); - Artisan::call("ddd:view-model {$domain} {$given}"); + Artisan::call("ddd:view-model {$domain}:{$given}"); expect(file_exists($expectedPath))->toBeTrue(); })->with('makeViewModelInputs'); -it('generates the base view model if needed', function () { - $className = Str::studly(fake()->word()); - $domain = Str::studly(fake()->word()); +it('generates the base view model if needed', function ($baseViewModel, $baseViewModelPath) { + $className = 'ShowInvoiceViewModel'; + $domain = 'Invoicing'; + + Config::set('ddd.base_view_model', $baseViewModel); $expectedPath = base_path(implode('/', [ config('ddd.domain_path'), $domain, - config('ddd.namespaces.view_models'), + config('ddd.namespaces.view_model'), "{$className}.php", ])); @@ -77,8 +93,7 @@ expect(file_exists($expectedPath))->toBeFalse(); - // This currently only tests for the default base model - $expectedBaseViewModelPath = base_path(config('ddd.domain_path').'/Shared/ViewModels/ViewModel.php'); + $expectedBaseViewModelPath = app()->basePath($baseViewModelPath); if (file_exists($expectedBaseViewModelPath)) { unlink($expectedBaseViewModelPath); @@ -86,14 +101,42 @@ expect(file_exists($expectedBaseViewModelPath))->toBeFalse(); - Artisan::call("ddd:view-model {$domain} {$className}"); + Artisan::call("ddd:view-model {$domain}:{$className}"); + + expect(Artisan::output())->toContain("Base view model {$baseViewModel} doesn't exist, generating"); expect(file_exists($expectedBaseViewModelPath))->toBeTrue(); -}); - -it('shows meaningful hints when prompting for missing input', function () { - $this->artisan('ddd:view-model') - ->expectsQuestion('What is the domain?', 'Utility') - ->expectsQuestion('What should the view model be named?', 'Belt') - ->assertExitCode(0); -})->ifSupportsPromptForMissingInput(); + + // Subsequent calls should not attempt to generate a base view model again + Artisan::call("ddd:view-model {$domain}:EditInvoiceViewModel"); + + expect(Artisan::output())->not->toContain("Base view model {$baseViewModel} doesn't exist, generating"); +})->with([ + "Domain\Shared\ViewModels\ViewModel" => ["Domain\Shared\ViewModels\ViewModel", 'src/Domain/Shared/ViewModels/ViewModel.php'], + "Domain\SomewhereElse\ViewModels\BaseViewModel" => ["Domain\SomewhereElse\ViewModels\BaseViewModel", 'src/Domain/SomewhereElse/ViewModels/BaseViewModel.php'], +]); + +it('does not attempt to generate base view models outside the domain layer', function ($baseViewModel) { + $className = 'ShowInvoiceViewModel'; + $domain = 'Invoicing'; + + Config::set('ddd.base_view_model', $baseViewModel); + + $expectedPath = base_path(implode('/', [ + config('ddd.domain_path'), + $domain, + config('ddd.namespaces.view_model'), + "{$className}.php", + ])); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + Artisan::call("ddd:view-model {$domain}:{$className}"); + + expect(Artisan::output())->not->toContain("Base view model {$baseViewModel} doesn't exist, generating"); +})->with([ + "Vendor\External\ViewModels\ViewModel" => ["Vendor\External\ViewModels\ViewModel"], + "Illuminate\Support\Str" => ["Illuminate\Support\Str"], +]); diff --git a/tests/Generator/PromptTest.php b/tests/Generator/PromptTest.php new file mode 100644 index 0000000..3dea905 --- /dev/null +++ b/tests/Generator/PromptTest.php @@ -0,0 +1,48 @@ +artisan('ddd:action') + ->expectsQuestion('What should the action be named?', 'DoThatThing') + ->expectsQuestion('What is the domain?', 'Utility') + ->assertExitCode(0); +}); + +it('[view model] prompts for missing input', function () { + $this->artisan('ddd:view-model') + ->expectsQuestion('What should the view model be named?', 'Belt') + ->expectsQuestion('What is the domain?', 'Utility') + ->assertExitCode(0); +}); + +it('[base view model] prompts for missing input', function () { + $this->artisan('ddd:base-view-model') + ->expectsQuestion('What is the domain?', 'Shared') + ->assertExitCode(0); +}); + +it('[model] prompts for missing input', function () { + $this->artisan('ddd:model') + ->expectsQuestion('What should the model be named?', 'Belt') + ->expectsQuestion('What is the domain?', 'Utility') + ->assertExitCode(0); +}); + +it('[base model] prompts for missing input', function () { + $this->artisan('ddd:base-model') + ->expectsQuestion('What is the domain?', 'Shared') + ->assertExitCode(0); +}); + +it('[value object] prompts for missing input', function () { + $this->artisan('ddd:value') + ->expectsQuestion('What should the value object be named?', 'Belt') + ->expectsQuestion('What is the domain?', 'Utility') + ->assertExitCode(0); +}); + +it('[data transfer object] prompts for missing input', function () { + $this->artisan('ddd:dto') + ->expectsQuestion('What should the data transfer object be named?', 'Belt') + ->expectsQuestion('What is the domain?', 'Utility') + ->assertExitCode(0); +}); diff --git a/tests/Pest.php b/tests/Pest.php index d9fde5f..1691c65 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,12 +13,7 @@ function skipOnLaravelVersionsBelow($minimumVersion) } } -function ifSupportsPromptForMissingInput() +function setConfigValues(array $values) { - return skipOnLaravelVersionsBelow('9.49.0'); -} - -function ifGeneratorCommandsOutputFilePath() -{ - return skipOnLaravelVersionsBelow('9.32.0'); + 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/DomainResolverTest.php b/tests/Support/DomainResolverTest.php new file mode 100644 index 0000000..37b63b9 --- /dev/null +++ b/tests/Support/DomainResolverTest.php @@ -0,0 +1,25 @@ +artisan('ddd:model', [ + 'name' => 'Invoice', + '--domain' => 'Invoicing', + ]); + + $this->artisan('ddd:dto', [ + 'name' => 'CustomerProfile', + '--domain' => 'Customer', + ]); + + $this->expectedDomains = [ + 'Customer', + 'Invoicing', + 'Shared', + ]; +}); + +it('can get the current domains', function () { + expect(DomainResolver::domainChoices())->toEqualCanonicalizing($this->expectedDomains); +}); diff --git a/tests/Support/DomainTest.php b/tests/Support/DomainTest.php index 5b20189..e263500 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,17 +33,17 @@ 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'], - ['Reporting.Internal', 'InvoiceReportFactory', 'Database\\Factories\\Reporting\\Internal\\InvoiceReportFactory', 'database/factories/Reporting/Internal/InvoiceReportFactory.php'], + ['Reporting', 'InvoiceReportFactory', 'Domain\\Reporting\\Database\\Factories\\InvoiceReportFactory', 'src/Domain/Reporting/Database/Factories/InvoiceReportFactory.php'], + ['Reporting.Internal', 'InvoiceReportFactory', 'Domain\\Reporting\\Internal\\Database\\Factories\\InvoiceReportFactory', 'src/Domain/Reporting/Internal/Database/Factories/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) + ->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,9 +73,19 @@ 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'], ['Reporting.Internal', 'SendInvoiceReport', 'Domain\\Reporting\\Internal\\Actions\\SendInvoiceReport', 'src/Domain/Reporting/Internal/Actions/SendInvoiceReport.php'], ]); + +it('can describe an anonymous domain object', function ($domainName, $objectType, $objectName, $expectedFQN, $expectedPath) { + expect((new Domain($domainName))->object($objectType, $objectName)) + ->name->toBe($objectName) + ->fullyQualifiedName->toBe($expectedFQN) + ->path->toBe(Path::normalize($expectedPath)); +})->with([ + ['Invoicing', 'rule', 'SomeRule', 'Domain\\Invoicing\\Rules\\SomeRule', 'src/Domain/Invoicing/Rules/SomeRule.php'], + ['Other', 'thing', 'Something', 'Domain\\Other\\Things\\Something', 'src/Domain/Other/Things/Something.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'], +]);