From c944c790c9f9569ca2f5b7f9338df027d6cadd77 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Tue, 22 Oct 2024 08:43:17 -0400 Subject: [PATCH] [1.2] ddd:model options, migrations, application layer (#69) * WIP experimentation * Fix styling * Refactor OverridesHandle to HandleHooks * Fix styling * WIP * WIP: Refactoring ddd:model, generate application layer objects, and more. * Fix styling * Replace HasFactory with HasDomainFactory. * Fix styling * Normalize paths for tests on Windows. * Keep phpstan happy. * Fix styling * Add header block. * Remove stray methods. * Update readme. * Further refinements and WIP. * Update change notes. * Fix phpstan issue. * Fix test. * Normalize paths for windows. * Support application layer paths outside app folder. * Add peer dependencies section. * Document the Application Layer. * Update wording. * Minor wording. * Support optimize commands in Laravel 11.27.1 * Update cache tests. * Ensure optimize:clear after each test. * Update internals and implement custom namespace resolving. * Apply namespace resolver to all types. * Refactor cache/optimize commands. * Minor changes. * Wrap command column with backticks. * Simplify ddd.application_layer to ddd.application * Fix styling * Update readme/config wordings. --------- Co-authored-by: JasperTey --- CHANGELOG.md | 38 +++ README.md | 218 +++++++++++------- composer.json | 3 +- config/ddd.php | 66 +++--- config/ddd.php.stub | 48 ++-- src/Commands/CacheCommand.php | 24 -- .../Concerns/ForwardsToDomainCommands.php | 62 +++++ src/Commands/Concerns/HandleHooks.php | 25 ++ .../Concerns/QualifiesDomainModels.php | 17 ++ .../Concerns/ResolvesDomainFromInput.php | 13 +- src/Commands/DomainControllerMakeCommand.php | 82 +++++++ src/Commands/DomainMiddlewareMakeCommand.php | 13 ++ src/Commands/DomainModelMakeCommand.php | 91 ++++---- src/Commands/DomainModelMakeLegacyCommand.php | 108 +++++++++ src/Commands/DomainRequestMakeCommand.php | 27 +++ src/Commands/DomainSeederMakeCommand.php | 13 ++ .../Migration/BaseMigrateMakeCommand.php | 42 ++++ .../Migration/DomainMigrateMakeCommand.php | 27 +++ ...arCommand.php => OptimizeClearCommand.php} | 11 +- src/Commands/OptimizeCommand.php | 34 +++ src/DomainManager.php | 53 +++++ src/Facades/DDD.php | 1 + src/LaravelDDDServiceProvider.php | 35 ++- src/Support/Domain.php | 48 +++- src/Support/DomainAutoloader.php | 4 +- src/Support/DomainMigration.php | 81 +++++++ src/Support/DomainResolver.php | 99 +++++++- src/Support/Path.php | 5 + src/ValueObjects/DomainCommandContext.php | 51 ++++ .../2024_10_14_215911_do_nothing.php | 24 ++ .../NamespaceResolverTest.php | 44 ++++ tests/Command/CacheTest.php | 70 +++++- tests/Command/ListTest.php | 5 + tests/Fixtures/Enums/Feature.php | 1 + ...{MakeActionTest.php => ActionMakeTest.php} | 0 ...aseModelTest.php => BaseModelMakeTest.php} | 0 ...odelTest.php => BaseViewModelMakeTest.php} | 0 tests/Generator/ControllerMakeTest.php | 177 ++++++++++++++ ...sferObjectTest.php => DtoMakeTestTest.php} | 0 tests/Generator/ExtendedCommandsTest.php | 4 + ...akeFactoryTest.php => FactoryMakeTest.php} | 0 tests/Generator/MigrationMakeTest.php | 84 +++++++ .../{MakeModelTest.php => Model/MakeTest.php} | 34 ++- .../Model/MakeWithControllerTest.php | 90 ++++++++ tests/Generator/Model/MakeWithOptionsTest.php | 109 +++++++++ tests/Generator/PromptTest.php | 1 + tests/Generator/RequestMakeTest.php | 54 +++++ ...ObjectTest.php => ValueObjectMakeTest.php} | 0 ...iewModelTest.php => ViewModelMakeTest.php} | 0 tests/Support/DomainResolverTest.php | 5 + tests/Support/DomainTest.php | 33 +++ tests/TestCase.php | 1 + 52 files changed, 1841 insertions(+), 234 deletions(-) delete mode 100644 src/Commands/CacheCommand.php create mode 100644 src/Commands/Concerns/ForwardsToDomainCommands.php create mode 100644 src/Commands/Concerns/HandleHooks.php create mode 100644 src/Commands/Concerns/QualifiesDomainModels.php create mode 100644 src/Commands/DomainControllerMakeCommand.php create mode 100644 src/Commands/DomainMiddlewareMakeCommand.php create mode 100644 src/Commands/DomainModelMakeLegacyCommand.php create mode 100644 src/Commands/DomainRequestMakeCommand.php create mode 100644 src/Commands/DomainSeederMakeCommand.php create mode 100644 src/Commands/Migration/BaseMigrateMakeCommand.php create mode 100644 src/Commands/Migration/DomainMigrateMakeCommand.php rename src/Commands/{CacheClearCommand.php => OptimizeClearCommand.php} (66%) create mode 100644 src/Commands/OptimizeCommand.php create mode 100644 src/Support/DomainMigration.php create mode 100644 src/ValueObjects/DomainCommandContext.php create mode 100644 tests/.skeleton/src/Domain/Invoicing/Database/Migrations/2024_10_14_215911_do_nothing.php create mode 100644 tests/ApplicationLayer/NamespaceResolverTest.php rename tests/Generator/{MakeActionTest.php => ActionMakeTest.php} (100%) rename tests/Generator/{MakeBaseModelTest.php => BaseModelMakeTest.php} (100%) rename tests/Generator/{MakeBaseViewModelTest.php => BaseViewModelMakeTest.php} (100%) create mode 100644 tests/Generator/ControllerMakeTest.php rename tests/Generator/{MakeDataTransferObjectTest.php => DtoMakeTestTest.php} (100%) rename tests/Generator/{MakeFactoryTest.php => FactoryMakeTest.php} (100%) create mode 100644 tests/Generator/MigrationMakeTest.php rename tests/Generator/{MakeModelTest.php => Model/MakeTest.php} (83%) create mode 100644 tests/Generator/Model/MakeWithControllerTest.php create mode 100644 tests/Generator/Model/MakeWithOptionsTest.php create mode 100644 tests/Generator/RequestMakeTest.php rename tests/Generator/{MakeValueObjectTest.php => ValueObjectMakeTest.php} (100%) rename tests/Generator/{MakeViewModelTest.php => ViewModelMakeTest.php} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2926890..c2fab9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to `laravel-ddd` will be documented in this file. +## [Unreleased] +### Added +- Experimental: Ability to configure the Application Layer, to generate domain objects that don't typically belong inside the domain layer. + ```php + // In config/ddd.php + 'application' => [ + 'path' => 'app/Modules', + 'namespace' => 'App\Modules', + 'objects' => [ + 'controller', + 'request', + 'middleware', + ], + ], + ``` +- Added `ddd:controller` to generate domain-specific controllers in the application layer. +- Added `ddd:request` to generate domain-spefic requests in the application layer. +- Added `ddd:middleware` to generate domain-specific middleware in the application layer. +- Added `ddd:migration` to generate domain migrations. +- Added `ddd:seeder` to generate domain seeders. +- Migration folders across domains will be registered and scanned when running `php artisan migrate`, in addition to the standard application `database/migrations` path. + +### Changed +- `ddd:model` now internally extends Laravel's native `make:model` and inherits all standard options: + - `--migration|-m` + - `--factory|-f` + - `--seed|-s` + - `--controller --resource --requests|-crR` + - `--policy` + - `-mfsc` + - `--all|-a` + - `--pivot|-p` +- `ddd:cache` is now `ddd:optimize` (`ddd:cache` is still available as an alias). +- For Laravel 11.27.1+, the framework's `optimize` and `optimize:clear` commands will automatically invoke `ddd:optimize` and `ddd:clear` respectively. + +### Deprecated +- Domain base models are no longer required by default, and `config('ddd.base_model')` is now `null` by default. + ## [1.1.2] - 2024-09-02 ### Fixed - During domain factory autoloading, ensure that `guessFactoryNamesUsing` returns a string when a domain factory is resolved. diff --git a/README.md b/README.md index fd1c833..beb7412 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,26 @@ You may initialize the package using the `ddd:install` artisan command. This wil php artisan ddd:install ``` +### Peer Dependencies +The following additional packages are suggested (but not required) while working with this package. + +Data Transfer Objects: [spatie/laravel-data](https://github.com/spatie/laravel-data) +```bash +composer require spatie/laravel-data +``` + +Actions: [lorisleiva/laravel-actions](https://github.com/lorisleiva/laravel-actions) +```bash +composer require lorisleiva/laravel-actions +``` +The default DTO and Action stubs of this package reference classes from these packages. If this doesn't apply to your application, you may customize the stubs accordingly. + ### Deployment -In production, run `ddd:cache` during the deployment process to [optimize autoloading](#autoloading-in-production). +In production, run `ddd:optimize` during the deployment process to [optimize autoloading](#autoloading-in-production). ```bash -php artisan ddd:cache +php artisan ddd:optimize ``` +Since Laravel 11.27.1, `php artisan optimize` automatically invokes `ddd:optimize`. If you already run `optimize` in production, a separate `ddd:optimize` is no longer necessary. ### Version Compatibility Laravel | LaravelDDD | | @@ -53,55 +68,40 @@ 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 Invoicing:Invoice - -# Generate a domain model with factory -php artisan ddd:model Invoicing:Invoice -f -php artisan ddd:model Invoicing:Invoice --factory - -# Generate a domain factory -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 Invoicing:LineItemPayload - -# Generates a value object -php artisan ddd:value Shared:DollarAmount - -# Generates a view model -php artisan ddd:view-model Invoicing:ShowInvoiceViewModel - -# Generates an action -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: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 - -# Laravel 11+ only -php artisan ddd:class Invoicing:Support/InvoiceBuilder -php artisan ddd:enum Customer:CustomerType -php artisan ddd:interface Customer:Contracts/Invoiceable -php artisan ddd:trait Customer:Concerns/HasInvoices -``` +The following generators are currently available: +| Command | Description | Usage | +|---|---|---| +| `ddd:model` | Generate a domain model | `php artisan ddd:model Invoicing:Invoice`

Options:
`--migration\|-m`
`--factory\|-f`
`--seed\|-s`
`--controller --resource --requests\|-crR`
`--policy`
`-mfsc`
`--all\|-a`
`--pivot\|-p`
| +| `ddd:factory` | Generate a domain factory | `php artisan ddd:factory Invoicing:InvoiceFactory` | +| `ddd:dto` | Generate a data transfer object | `php artisan ddd:dto Invoicing:LineItemPayload` | +| `ddd:value` | Generate a value object | `php artisan ddd:value Shared:DollarAmount` | +| `ddd:view-model` | Generate a view model | `php artisan ddd:view-model Invoicing:ShowInvoiceViewModel` | +| `ddd:action` | Generate an action | `php artisan ddd:action Invoicing:SendInvoiceToCustomer` | +| `ddd:cast` | Generate a cast | `php artisan ddd:cast Invoicing:MoneyCast` | +| `ddd:channel` | Generate a channel | `php artisan ddd:channel Invoicing:InvoiceChannel` | +| `ddd:command` | Generate a command | `php artisan ddd:command Invoicing:InvoiceDeliver` | +| `ddd:controller` | Generate a controller | `php artisan ddd:controller Invoicing:InvoiceController`

Options: inherits options from *make:controller* | +| `ddd:event` | Generate an event | `php artisan ddd:event Invoicing:PaymentWasReceived` | +| `ddd:exception` | Generate an exception | `php artisan ddd:exception Invoicing:InvoiceNotFoundException` | +| `ddd:job` | Generate a job | `php artisan ddd:job Invoicing:GenerateInvoicePdf` | +| `ddd:listener` | Generate a listener | `php artisan ddd:listener Invoicing:HandlePaymentReceived` | +| `ddd:mail` | Generate a mail | `php artisan ddd:mail Invoicing:OverduePaymentReminderEmail` | +| `ddd:middleware` | Generate a middleware | `php artisan ddd:middleware Invoicing:VerifiedCustomerMiddleware` | +| `ddd:migration` | Generate a migration | `php artisan ddd:migration Invoicing:CreateInvoicesTable` | +| `ddd:notification` | Generate a notification | `php artisan ddd:notification Invoicing:YourPaymentWasReceived` | +| `ddd:observer` | Generate an observer | `php artisan ddd:observer Invoicing:InvoiceObserver` | +| `ddd:policy` | Generate a policy | `php artisan ddd:policy Invoicing:InvoicePolicy` | +| `ddd:provider` | Generate a provider | `php artisan ddd:provider Invoicing:InvoiceServiceProvider` | +| `ddd:resource` | Generate a resource | `php artisan ddd:resource Invoicing:InvoiceResource` | +| `ddd:rule` | Generate a rule | `php artisan ddd:rule Invoicing:ValidPaymentMethod` | +| `ddd:request` | Generate a form request | `php artisan ddd:request Invoicing:StoreInvoiceRequest` | +| `ddd:scope` | Generate a scope | `php artisan ddd:scope Invoicing:ArchivedInvoicesScope` | +| `ddd:seeder` | Generate a seeder | `php artisan ddd:seeder Invoicing:InvoiceSeeder` | +| `ddd:class` | Generate a class (Laravel 11+) | `php artisan ddd:class Invoicing:Support/InvoiceBuilder` | +| `ddd:enum` | Generate an enum (Laravel 11+) | `php artisan ddd:enum Customer:CustomerType` | +| `ddd:interface` | Generate an interface (Laravel 11+) | `php artisan ddd:interface Customer:Contracts/Invoiceable` | +| `ddd:trait` | Generate a trait (Laravel 11+) | `php artisan ddd:trait Customer:Concerns/HasInvoices` | + Generated objects will be placed in the appropriate domain namespace as specified by `ddd.namespaces.*` in the [config file](#config-file). ### Other Commands @@ -110,13 +110,47 @@ Generated objects will be placed in the appropriate domain namespace as specifie php artisan ddd:list # Cache domain manifests (used for autoloading) -php artisan ddd:cache +php artisan ddd:optimize # Clear the domain cache php artisan ddd:clear ``` ## Advanced Usage +### Application Layer (since 1.2) +Some objects interact with the domain layer, but are not part of the domain layer themselves. By default, these include: `controller`, `request`, `middleware`. You may customize the path, namespace, and which `ddd:*` objects belong in the application layer. +```php +// In config/ddd.php +'application' => [ + 'path' => 'app/Modules', + 'namespace' => 'App\Modules', + 'objects' => [ + 'controller', + 'request', + 'middleware', + ], +], +``` +The configuration above will result in the following: +```bash +ddd:model Invoicing:Invoice --controller --resource --requests +``` +Output: +``` +├─ app +| └─ Modules +│ └─ Invoicing +│ ├─ Controllers +│ │ └─ InvoiceController.php +│ └─ Requests +│ ├─ StoreInvoiceRequest.php +│ └─ UpdateInvoiceRequest.php +├─ src/Domain + └─ Invoicing + └─ Models + └─ Invoice.php +``` + ### Nested Objects For any `ddd:*` generator command, nested objects can be specified with forward slashes. ```bash @@ -194,6 +228,7 @@ Autoloading behaviour can be configured with the `ddd.autoload` configuration op 'commands' => true, 'policies' => true, 'factories' => true, + 'migrations' => true, ], ``` ### Service Providers @@ -210,14 +245,18 @@ When `ddd.autoload.factories` is enabled, the package will register a custom fac 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. +### Migrations +When `ddd.autoload.migrations` is enabled, paths within the domain layer matching the configured `ddd.namespaces.migration` namespace will be auto-registered as a database migration path and recognized by `php artisan migrate`. + ### Ignoring Paths During Autoloading -To specify folders or paths that should be skipped during autoloading discovery, add them to the `ddd.autoload_ignore` configuration option. By default, the `Tests` and `Migrations` folders are ignored. +To specify folders or paths that should be skipped during autoloading class discovery, add them to the `ddd.autoload_ignore` configuration option. By default, the `Tests` and `Migrations` folders are ignored. ```php 'autoload_ignore' => [ 'Tests', 'Database/Migrations', ], ``` +Note that ignoring folders only applies to class-based autoloading: Service Providers, Console Commands, Policies, and Factories. Paths specified here are relative to the root of each domain. e.g., `src/Domain/Invoicing/{path-to-ignore}`. If more advanced filtering is needed, a callback can be registered using `DDD::filterAutoloadPathsUsing(callback $filter)` in your AppServiceProvider's boot method: ```php @@ -240,13 +279,16 @@ You may disable autoloading by setting the respective autoload options to `false // 'commands' => true, // 'policies' => true, // 'factories' => true, +// 'migrations' => 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. +In production, you should cache the autoload manifests using the `ddd:optimize` 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. + +> **Note**: Since Laravel 11.27.1, the framework's `optimize` and `optimize:clear` commands will automatically invoke `ddd:optimize` and `ddd:clear` respectively. @@ -278,12 +320,34 @@ return [ /* |-------------------------------------------------------------------------- - | Domain Object Namespaces + | Application Layer |-------------------------------------------------------------------------- | - | This value contains the default namespaces of generated domain - | objects relative to the domain namespace of which the object - | belongs to. + | Configure objects that belong in the application layer. + | + | e.g., App\Modules\Invoicing\Controllers\* + | App\Modules\Invoicing\Requests\* + | + */ + 'application' => [ + 'path' => 'app/Modules', + 'namespace' => 'App\Modules', + + // Specify which ddd:* objects belong in the application layer + 'objects' => [ + 'controller', + 'request', + 'middleware', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Generator Object Namespaces + |-------------------------------------------------------------------------- + | + | This array maps the default relative namespaces of generated objects + | relative to their domain's root namespace. | | e.g., Domain\Invoicing\Models\* | Domain\Invoicing\Data\* @@ -302,6 +366,7 @@ return [ 'class' => '', 'channel' => 'Channels', 'command' => 'Commands', + 'controller' => 'Controllers', 'enum' => 'Enums', 'event' => 'Events', 'exception' => 'Exceptions', @@ -310,13 +375,17 @@ return [ 'job' => 'Jobs', 'listener' => 'Listeners', 'mail' => 'Mail', + 'middleware' => 'Middleware', + 'migration' => 'Database\Migrations', 'notification' => 'Notifications', 'observer' => 'Observers', 'policy' => 'Policies', 'provider' => 'Providers', 'resource' => 'Resources', + 'request' => 'Requests', 'rule' => 'Rules', 'scope' => 'Scopes', + 'seeder' => 'Database\Seeders', 'trait' => '', ], @@ -325,12 +394,11 @@ return [ | 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. + | The base model class which generated domain models should extend. If + | set to null, the generated models will extend Laravel's default. | */ - 'base_model' => 'Domain\Shared\Models\BaseModel', + 'base_model' => null, /* |-------------------------------------------------------------------------- @@ -374,34 +442,16 @@ return [ | Autoloading |-------------------------------------------------------------------------- | - | Configure whether domain providers, commands, policies, and factories - | should be auto-discovered and registered. + | Configure whether domain providers, commands, policies, factories, + | and migrations 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, + 'migrations' => true, ], /* @@ -415,7 +465,7 @@ return [ | e.g., src/Domain/Invoicing/ | | If more advanced filtering is needed, a callback can be registered - | using the `DDD::filterAutoloadPathsUsing(callback $filter)` in + | using `DDD::filterAutoloadPathsUsing(callback $filter)` in | the AppServiceProvider's boot method. | */ diff --git a/composer.json b/composer.json index 243d503..c7fa76b 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "pestphp/pest-plugin-laravel": "^2.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", - "phpstan/phpstan-phpunit": "^1.0" + "phpstan/phpstan-phpunit": "^1.0", + "spatie/laravel-data": "^4.10" }, "autoload": { "psr-4": { diff --git a/config/ddd.php b/config/ddd.php index 66d8901..dfa4eda 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -24,12 +24,34 @@ /* |-------------------------------------------------------------------------- - | Domain Object Namespaces + | Application Layer |-------------------------------------------------------------------------- | - | This value contains the default namespaces of generated domain - | objects relative to the domain namespace of which the object - | belongs to. + | Configure objects that belong in the application layer. + | + | e.g., App\Modules\Invoicing\Controllers\* + | App\Modules\Invoicing\Requests\* + | + */ + 'application' => [ + 'path' => 'app/Modules', + 'namespace' => 'App\Modules', + + // Specify which ddd:* objects belong in the application layer + 'objects' => [ + 'controller', + 'request', + 'middleware', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Generator Object Namespaces + |-------------------------------------------------------------------------- + | + | This array maps the default relative namespaces of generated objects + | relative to their domain's root namespace. | | e.g., Domain\Invoicing\Models\* | Domain\Invoicing\Data\* @@ -48,6 +70,7 @@ 'class' => '', 'channel' => 'Channels', 'command' => 'Commands', + 'controller' => 'Controllers', 'enum' => 'Enums', 'event' => 'Events', 'exception' => 'Exceptions', @@ -56,13 +79,17 @@ 'job' => 'Jobs', 'listener' => 'Listeners', 'mail' => 'Mail', + 'middleware' => 'Middleware', + 'migration' => 'Database\Migrations', 'notification' => 'Notifications', 'observer' => 'Observers', 'policy' => 'Policies', 'provider' => 'Providers', 'resource' => 'Resources', + 'request' => 'Requests', 'rule' => 'Rules', 'scope' => 'Scopes', + 'seeder' => 'Database\Seeders', 'trait' => '', ], @@ -71,12 +98,11 @@ | 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. + | The base model class which generated domain models should extend. If + | set to null, the generated models will extend Laravel's default. | */ - 'base_model' => 'Domain\Shared\Models\BaseModel', + 'base_model' => null, /* |-------------------------------------------------------------------------- @@ -120,34 +146,16 @@ | Autoloading |-------------------------------------------------------------------------- | - | Configure whether domain providers, commands, policies, and factories - | should be auto-discovered and registered. + | Configure whether domain providers, commands, policies, factories, + | and migrations 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, + 'migrations' => true, ], /* diff --git a/config/ddd.php.stub b/config/ddd.php.stub index 6c10645..612b1d8 100644 --- a/config/ddd.php.stub +++ b/config/ddd.php.stub @@ -22,6 +22,29 @@ return [ */ 'domain_namespace' => {{domain_namespace}}, + /* + |-------------------------------------------------------------------------- + | Application Layer + |-------------------------------------------------------------------------- + | + | Configure objects that belong in the application layer. + | + | e.g., App\Modules\Invoicing\Controllers\* + | App\Modules\Invoicing\Requests\* + | + */ + 'application' => [ + 'path' => 'app/Modules', + 'namespace' => 'App\Modules', + + // Specify which ddd:* objects belong in the application layer + 'objects' => [ + 'controller', + 'request', + 'middleware', + ], + ], + /* |-------------------------------------------------------------------------- | Domain Object Namespaces @@ -48,6 +71,7 @@ return [ 'class' => '', 'channel' => 'Channels', 'command' => 'Commands', + 'controller' => 'Controllers', 'enum' => 'Enums', 'event' => 'Events', 'exception' => 'Exceptions', @@ -56,13 +80,17 @@ return [ 'job' => 'Jobs', 'listener' => 'Listeners', 'mail' => 'Mail', + 'middleware' => 'Middleware', + 'migration' => 'Database\Migrations', 'notification' => 'Notifications', 'observer' => 'Observers', 'policy' => 'Policies', 'provider' => 'Providers', 'resource' => 'Resources', + 'request' => 'Requests', 'rule' => 'Rules', 'scope' => 'Scopes', + 'seeder' => 'Database\Seeders', 'trait' => '', ], @@ -125,29 +153,11 @@ return [ | */ 'autoload' => [ - /** - * When enabled, any class within the domain layer extending `Illuminate\Support\ServiceProvider` - * will be auto-registered as a service provider - */ 'providers' => true, - - /** - * When enabled, any class within the domain layer extending `Illuminate\Console\Command` - * will be auto-registered as a command when running in console. - */ 'commands' => true, - - /** - * When enabled, the package will register a custom policy discovery callback to resolve policy names - * for domain models, and fallback to Laravel's default for all other cases. - */ 'policies' => true, - - /** - * When enabled, the package will register a custom factory discovery callback to resolve factory names - * for domain models, and fallback to Laravel's default for all other cases. - */ 'factories' => true, + 'migrations' => true, ], /* diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php deleted file mode 100644 index 4d4fe68..0000000 --- a/src/Commands/CacheCommand.php +++ /dev/null @@ -1,24 +0,0 @@ -components->info('Domain providers cached successfully.'); - - DomainAutoloader::cacheCommands(); - - $this->components->info('Domain commands cached successfully.'); - } -} diff --git a/src/Commands/Concerns/ForwardsToDomainCommands.php b/src/Commands/Concerns/ForwardsToDomainCommands.php new file mode 100644 index 0000000..6586bd7 --- /dev/null +++ b/src/Commands/Concerns/ForwardsToDomainCommands.php @@ -0,0 +1,62 @@ +getNameInput(), '/') + ? Str::beforeLast($this->getNameInput(), '/') + : null; + + $nameWithSubfolder = $subfolder ? "{$subfolder}/{$arguments['name']}" : $arguments['name']; + + return match ($command) { + 'make:request' => $this->runCommand('ddd:request', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->domain->dotName, + ], $this->output), + + 'make:model' => $this->runCommand('ddd:model', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->domain->dotName, + ], $this->output), + + 'make:factory' => $this->runCommand('ddd:factory', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->domain->dotName, + ], $this->output), + + 'make:policy' => $this->runCommand('ddd:policy', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->domain->dotName, + ], $this->output), + + 'make:migration' => $this->runCommand('ddd:migration', [ + ...$arguments, + '--domain' => $this->domain->dotName, + ], $this->output), + + 'make:seeder' => $this->runCommand('ddd:seeder', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->domain->dotName, + ], $this->output), + + 'make:controller' => $this->runCommand('ddd:controller', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->domain->dotName, + ], $this->output), + + default => $this->runCommand($command, $arguments, $this->output), + }; + } +} diff --git a/src/Commands/Concerns/HandleHooks.php b/src/Commands/Concerns/HandleHooks.php new file mode 100644 index 0000000..9a1ed27 --- /dev/null +++ b/src/Commands/Concerns/HandleHooks.php @@ -0,0 +1,25 @@ +beforeHandle(); + + parent::handle(); + + $this->afterHandle(); + } +} diff --git a/src/Commands/Concerns/QualifiesDomainModels.php b/src/Commands/Concerns/QualifiesDomainModels.php new file mode 100644 index 0000000..8e5af0a --- /dev/null +++ b/src/Commands/Concerns/QualifiesDomainModels.php @@ -0,0 +1,17 @@ +domain) { + $domainModel = $domain->model($model); + + return $domainModel->fullyQualifiedName; + } + + return parent::qualifyModel($model); + } +} diff --git a/src/Commands/Concerns/ResolvesDomainFromInput.php b/src/Commands/Concerns/ResolvesDomainFromInput.php index 9f323b6..9192aee 100644 --- a/src/Commands/Concerns/ResolvesDomainFromInput.php +++ b/src/Commands/Concerns/ResolvesDomainFromInput.php @@ -10,7 +10,9 @@ trait ResolvesDomainFromInput { - use CanPromptForDomain; + use CanPromptForDomain, + HandleHooks, + QualifiesDomainModels; protected $nameIsAbsolute = false; @@ -26,7 +28,9 @@ protected function getOptions() protected function rootNamespace() { - return Str::finish(DomainResolver::domainRootNamespace(), '\\'); + $type = $this->guessObjectType(); + + return Str::finish(DomainResolver::resolveRootNamespace($type), '\\'); } protected function guessObjectType(): string @@ -36,6 +40,7 @@ protected function guessObjectType(): string 'ddd:base-model' => 'model', 'ddd:value' => 'value_object', 'ddd:dto' => 'data_transfer_object', + 'ddd:migration' => 'migration', default => str($this->name)->after(':')->snake()->toString(), }; } @@ -66,7 +71,7 @@ protected function getPath($name) return parent::getPath($name); } - public function handle() + protected function beforeHandle() { $nameInput = $this->getNameInput(); @@ -106,6 +111,6 @@ public function handle() $this->input->setArgument('name', $nameInput); - parent::handle(); + app('ddd')->captureCommandContext($this, $this->domain, $this->guessObjectType()); } } diff --git a/src/Commands/DomainControllerMakeCommand.php b/src/Commands/DomainControllerMakeCommand.php new file mode 100644 index 0000000..aa2a373 --- /dev/null +++ b/src/Commands/DomainControllerMakeCommand.php @@ -0,0 +1,82 @@ +parseModel($this->option('model')); + + if ( + ! app()->runningUnitTests() + && ! class_exists($modelClass) + && confirm("A {$modelClass} model does not exist. Do you want to generate it?", default: true) + ) { + $this->call('make:model', ['name' => $modelClass]); + } + + $replace = $this->buildFormRequestReplacements($replace, $modelClass); + + return array_merge($replace, [ + 'DummyFullModelClass' => $modelClass, + '{{ namespacedModel }}' => $modelClass, + '{{namespacedModel}}' => $modelClass, + 'DummyModelClass' => class_basename($modelClass), + '{{ model }}' => class_basename($modelClass), + '{{model}}' => class_basename($modelClass), + 'DummyModelVariable' => lcfirst(class_basename($modelClass)), + '{{ modelVariable }}' => lcfirst(class_basename($modelClass)), + '{{modelVariable}}' => lcfirst(class_basename($modelClass)), + ]); + } + + protected function buildFormRequestReplacements(array $replace, $modelClass) + { + [$namespace, $storeRequestClass, $updateRequestClass] = [ + 'Illuminate\\Http', + 'Request', + 'Request', + ]; + + if ($this->option('requests')) { + $namespace = $this->domain->namespaceFor('request', $this->getNameInput()); + + [$storeRequestClass, $updateRequestClass] = $this->generateFormRequests( + $modelClass, + $storeRequestClass, + $updateRequestClass + ); + } + + $namespacedRequests = $namespace.'\\'.$storeRequestClass.';'; + + if ($storeRequestClass !== $updateRequestClass) { + $namespacedRequests .= PHP_EOL.'use '.$namespace.'\\'.$updateRequestClass.';'; + } + + return array_merge($replace, [ + '{{ storeRequest }}' => $storeRequestClass, + '{{storeRequest}}' => $storeRequestClass, + '{{ updateRequest }}' => $updateRequestClass, + '{{updateRequest}}' => $updateRequestClass, + '{{ namespacedStoreRequest }}' => $namespace.'\\'.$storeRequestClass, + '{{namespacedStoreRequest}}' => $namespace.'\\'.$storeRequestClass, + '{{ namespacedUpdateRequest }}' => $namespace.'\\'.$updateRequestClass, + '{{namespacedUpdateRequest}}' => $namespace.'\\'.$updateRequestClass, + '{{ namespacedRequests }}' => $namespacedRequests, + '{{namespacedRequests}}' => $namespacedRequests, + ]); + } +} diff --git a/src/Commands/DomainMiddlewareMakeCommand.php b/src/Commands/DomainMiddlewareMakeCommand.php new file mode 100644 index 0000000..2c6b2af --- /dev/null +++ b/src/Commands/DomainMiddlewareMakeCommand.php @@ -0,0 +1,13 @@ +argument('name')); } - protected function getStub() + public function handle() { - return $this->resolveStubPath('model.php.stub'); - } + $this->beforeHandle(); - protected function preparePlaceholders(): array - { - $baseClass = config('ddd.base_model'); - $baseClassName = class_basename($baseClass); + $this->createBaseModelIfNeeded(); - return [ - 'extends' => filled($baseClass) ? " extends {$baseClassName}" : '', - 'baseClassImport' => filled($baseClass) ? "use {$baseClass};" : '', - ]; + parent::handle(); + + $this->afterHandle(); } - public function handle() + protected function buildClass($name) { - $this->createBaseModelIfNeeded(); + $stub = parent::buildClass($name); - parent::handle(); + $replacements = [ + 'use Illuminate\Database\Eloquent\Factories\HasFactory;' => "use Lunarstorm\LaravelDDD\Factories\HasDomainFactory as HasFactory;", + ]; - if ($this->option('factory')) { - $this->createFactory(); + if ($baseModel = $this->getBaseModel()) { + $baseModelClass = class_basename($baseModel); + + $replacements = array_merge($replacements, [ + 'extends Model' => "extends {$baseModelClass}", + 'use Illuminate\Database\Eloquent\Model;' => "use {$baseModel};", + ]); } + + $stub = str_replace( + array_keys($replacements), + array_values($replacements), + $stub + ); + + $stub = $this->sortImports($stub); + + return $stub; } protected function createBaseModelIfNeeded() { - if (! $this->shouldCreateModel()) { + if (! $this->shouldCreateBaseModel()) { return; } @@ -74,10 +79,19 @@ protected function createBaseModelIfNeeded() ]); } - protected function shouldCreateModel(): bool + protected function getBaseModel(): ?string + { + return config('ddd.base_model', null); + } + + protected function shouldCreateBaseModel(): bool { $baseModel = config('ddd.base_model'); + if (is_null($baseModel)) { + return false; + } + // If the class exists, we don't need to create it. if (class_exists($baseModel)) { return false; @@ -96,13 +110,4 @@ protected function shouldCreateModel(): bool return true; } - - protected function createFactory() - { - $this->call(DomainFactoryMakeCommand::class, [ - 'name' => $this->getNameInput().'Factory', - '--domain' => $this->domain->dotName, - '--model' => $this->qualifyClass($this->getNameInput()), - ]); - } } diff --git a/src/Commands/DomainModelMakeLegacyCommand.php b/src/Commands/DomainModelMakeLegacyCommand.php new file mode 100644 index 0000000..37c8164 --- /dev/null +++ b/src/Commands/DomainModelMakeLegacyCommand.php @@ -0,0 +1,108 @@ +resolveStubPath('model.php.stub'); + } + + protected function preparePlaceholders(): array + { + $baseClass = config('ddd.base_model'); + $baseClassName = class_basename($baseClass); + + return [ + 'extends' => filled($baseClass) ? " extends {$baseClassName}" : '', + 'baseClassImport' => filled($baseClass) ? "use {$baseClass};" : '', + ]; + } + + public function handle() + { + $this->createBaseModelIfNeeded(); + + parent::handle(); + + if ($this->option('factory')) { + $this->createFactory(); + } + } + + protected function createBaseModelIfNeeded() + { + if (! $this->shouldCreateBaseModel()) { + return; + } + + $baseModel = config('ddd.base_model'); + + $this->warn("Base model {$baseModel} doesn't exist, generating..."); + + $domain = DomainResolver::guessDomainFromClass($baseModel); + + $name = Str::after($baseModel, $domain); + + $this->call(DomainBaseModelMakeCommand::class, [ + '--domain' => $domain, + 'name' => $name, + ]); + } + + protected function shouldCreateBaseModel(): bool + { + $baseModel = config('ddd.base_model'); + + // If the class exists, we don't need to create it. + if (class_exists($baseModel)) { + return false; + } + + // If the class is outside of the domain layer, we won't attempt to create it. + if (! DomainResolver::isDomainClass($baseModel)) { + return false; + } + + // At this point the class is probably a domain object, but we should + // check if the expected path exists. + if (file_exists(app()->basePath(DomainResolver::guessPathFromClass($baseModel)))) { + return false; + } + + return true; + } + + protected function createFactory() + { + $this->call(DomainFactoryMakeCommand::class, [ + 'name' => $this->getNameInput().'Factory', + '--domain' => $this->domain->dotName, + '--model' => $this->qualifyClass($this->getNameInput()), + ]); + } +} diff --git a/src/Commands/DomainRequestMakeCommand.php b/src/Commands/DomainRequestMakeCommand.php new file mode 100644 index 0000000..a6a1ddf --- /dev/null +++ b/src/Commands/DomainRequestMakeCommand.php @@ -0,0 +1,27 @@ +guessObjectType(); + + return Str::finish(DomainResolver::resolveRootNamespace($type), '\\'); + } +} diff --git a/src/Commands/DomainSeederMakeCommand.php b/src/Commands/DomainSeederMakeCommand.php new file mode 100644 index 0000000..6ee413d --- /dev/null +++ b/src/Commands/DomainSeederMakeCommand.php @@ -0,0 +1,13 @@ +argument('name')); + + return $name; + } + + protected function qualifyModel(string $model) {} + + protected function getDefaultNamespace($rootNamespace) {} + + protected function getPath($name) {} +} diff --git a/src/Commands/Migration/DomainMigrateMakeCommand.php b/src/Commands/Migration/DomainMigrateMakeCommand.php new file mode 100644 index 0000000..e0dcb73 --- /dev/null +++ b/src/Commands/Migration/DomainMigrateMakeCommand.php @@ -0,0 +1,27 @@ +domain) { + return $this->laravel->basePath($this->domain->migrationPath); + } + + return $this->laravel->databasePath().DIRECTORY_SEPARATOR.'migrations'; + } +} diff --git a/src/Commands/CacheClearCommand.php b/src/Commands/OptimizeClearCommand.php similarity index 66% rename from src/Commands/CacheClearCommand.php rename to src/Commands/OptimizeClearCommand.php index 7e4c465..3ad99c8 100644 --- a/src/Commands/CacheClearCommand.php +++ b/src/Commands/OptimizeClearCommand.php @@ -5,12 +5,21 @@ use Illuminate\Console\Command; use Lunarstorm\LaravelDDD\Support\DomainCache; -class CacheClearCommand extends Command +class OptimizeClearCommand extends Command { protected $name = 'ddd:clear'; protected $description = 'Clear cached domain autoloaded objects.'; + protected function configure() + { + $this->setAliases([ + 'ddd:optimize:clear', + ]); + + parent::configure(); + } + public function handle() { DomainCache::clear(); diff --git a/src/Commands/OptimizeCommand.php b/src/Commands/OptimizeCommand.php new file mode 100644 index 0000000..e73462e --- /dev/null +++ b/src/Commands/OptimizeCommand.php @@ -0,0 +1,34 @@ +setAliases([ + 'ddd:cache', + ]); + + parent::configure(); + } + + public function handle() + { + $this->components->info('Caching DDD providers, commands, migration paths.'); + + $this->components->task('domain providers', fn () => DomainAutoloader::cacheProviders()); + $this->components->task('domain commands', fn () => DomainAutoloader::cacheCommands()); + $this->components->task('domain migration paths', fn () => DomainMigration::cachePaths()); + + $this->newLine(); + } +} diff --git a/src/DomainManager.php b/src/DomainManager.php index 2de6b0f..13d465a 100755 --- a/src/DomainManager.php +++ b/src/DomainManager.php @@ -2,6 +2,10 @@ namespace Lunarstorm\LaravelDDD; +use Illuminate\Console\Command; +use Lunarstorm\LaravelDDD\Support\Domain; +use Lunarstorm\LaravelDDD\ValueObjects\DomainCommandContext; + class DomainManager { /** @@ -11,9 +15,28 @@ class DomainManager */ protected $autoloadFilter; + /** + * The application layer filter callback. + * + * @var callable|null + */ + protected $applicationLayerFilter; + + /** + * The application layer object resolver callback. + * + * @var callable|null + */ + protected $namespaceResolver; + + protected ?DomainCommandContext $commandContext; + public function __construct() { $this->autoloadFilter = null; + $this->applicationLayerFilter = null; + $this->namespaceResolver = null; + $this->commandContext = null; } public function filterAutoloadPathsUsing(callable $filter): void @@ -25,4 +48,34 @@ public function getAutoloadFilter(): ?callable { return $this->autoloadFilter; } + + public function filterApplicationLayerUsing(callable $filter): void + { + $this->applicationLayerFilter = $filter; + } + + public function getApplicationLayerFilter(): ?callable + { + return $this->applicationLayerFilter; + } + + public function resolveNamespaceUsing(callable $resolver): void + { + $this->namespaceResolver = $resolver; + } + + public function getNamespaceResolver(): ?callable + { + return $this->namespaceResolver; + } + + public function captureCommandContext(Command $command, ?Domain $domain, ?string $type): void + { + $this->commandContext = DomainCommandContext::fromCommand($command, $domain, $type); + } + + public function getCommandContext(): ?DomainCommandContext + { + return $this->commandContext; + } } diff --git a/src/Facades/DDD.php b/src/Facades/DDD.php index 5164cda..a5aba45 100644 --- a/src/Facades/DDD.php +++ b/src/Facades/DDD.php @@ -8,6 +8,7 @@ * @see \Lunarstorm\LaravelDDD\DomainManager * * @method static void filterAutoloadPathsUsing(callable $filter) + * @method static void resolveNamespaceUsing(callable $resolver) */ class DDD extends Facade { diff --git a/src/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php index 6555823..106ec30 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -3,6 +3,7 @@ namespace Lunarstorm\LaravelDDD; use Lunarstorm\LaravelDDD\Support\DomainAutoloader; +use Lunarstorm\LaravelDDD\Support\DomainMigration; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -27,8 +28,8 @@ public function configurePackage(Package $package): void ->hasCommands([ Commands\InstallCommand::class, Commands\UpgradeCommand::class, - Commands\CacheCommand::class, - Commands\CacheClearCommand::class, + Commands\OptimizeCommand::class, + Commands\OptimizeClearCommand::class, Commands\DomainListCommand::class, Commands\DomainModelMakeCommand::class, Commands\DomainFactoryMakeCommand::class, @@ -41,18 +42,23 @@ public function configurePackage(Package $package): void Commands\DomainCastMakeCommand::class, Commands\DomainChannelMakeCommand::class, Commands\DomainConsoleMakeCommand::class, + Commands\DomainControllerMakeCommand::class, Commands\DomainEventMakeCommand::class, Commands\DomainExceptionMakeCommand::class, Commands\DomainJobMakeCommand::class, Commands\DomainListenerMakeCommand::class, Commands\DomainMailMakeCommand::class, + Commands\DomainMiddlewareMakeCommand::class, Commands\DomainNotificationMakeCommand::class, Commands\DomainObserverMakeCommand::class, Commands\DomainPolicyMakeCommand::class, Commands\DomainProviderMakeCommand::class, Commands\DomainResourceMakeCommand::class, + Commands\DomainRequestMakeCommand::class, Commands\DomainRuleMakeCommand::class, Commands\DomainScopeMakeCommand::class, + Commands\DomainSeederMakeCommand::class, + Commands\Migration\DomainMigrateMakeCommand::class, ]); if (app()->version() >= 11) { @@ -63,15 +69,40 @@ public function configurePackage(Package $package): void } } + protected function registerMigrations() + { + $this->app->singleton(Commands\Migration\DomainMigrateMakeCommand::class, function ($app) { + // Once we have the migration creator registered, we will create the command + // and inject the creator. The creator is responsible for the actual file + // creation of the migrations, and may be extended by these developers. + $creator = $app['migration.creator']; + $composer = $app['composer']; + + return new Commands\Migration\DomainMigrateMakeCommand($creator, $composer); + }); + + $this->loadMigrationsFrom(DomainMigration::paths()); + } + public function packageBooted() { $this->publishes([ $this->package->basePath('/../stubs') => resource_path("stubs/{$this->package->shortName()}"), ], "{$this->package->shortName()}-stubs"); + + if ($this->app->runningInConsole() && method_exists($this, 'optimizes')) { + $this->optimizes( + optimize: 'ddd:optimize', + clear: 'ddd:clear', + key: 'ddd cache', + ); + } } public function packageRegistered() { (new DomainAutoloader)->autoload(); + + $this->registerMigrations(); } } diff --git a/src/Support/Domain.php b/src/Support/Domain.php index 062aedc..225fe61 100644 --- a/src/Support/Domain.php +++ b/src/Support/Domain.php @@ -11,6 +11,8 @@ class Domain public readonly string $path; + public readonly string $migrationPath; + public readonly string $domain; public readonly ?string $subdomain; @@ -57,6 +59,8 @@ public function __construct(string $domain, ?string $subdomain = null) $this->namespace = DomainNamespaces::from($this->domain, $this->subdomain); $this->path = Path::join(DomainResolver::domainPath(), $this->domainWithSubdomain); + + $this->migrationPath = Path::join($this->path, config('ddd.namespaces.migration', 'Database/Migrations')); } protected function getDomainBasePath() @@ -79,14 +83,29 @@ public function path(?string $path = null): string return Path::join($this->path, $path); } + public function pathInApplicationLayer(?string $path = null): string + { + if (is_null($path)) { + return $this->path; + } + + $path = str($path) + ->replace(DomainResolver::applicationLayerRootNamespace(), '') + ->replace(['\\', '/'], DIRECTORY_SEPARATOR) + ->append('.php') + ->toString(); + + return Path::join(DomainResolver::applicationLayerPath(), $path); + } + public function relativePath(string $path = ''): string { return collect([$this->domain, $path])->filter()->implode(DIRECTORY_SEPARATOR); } - public function namespaceFor(string $type): string + public function namespaceFor(string $type, ?string $name = null): string { - return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type); + return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type, $name); } public function guessNamespaceFromName(string $name): string @@ -102,20 +121,37 @@ public function guessNamespaceFromName(string $name): string public function object(string $type, string $name, bool $absolute = false): DomainObject { - $namespace = match (true) { + $resolvedNamespace = null; + + if (DomainResolver::isApplicationLayer($type)) { + $resolver = app('ddd')->getNamespaceResolver(); + + $resolvedNamespace = is_callable($resolver) + ? $resolver($this->domainWithSubdomain, $type, app('ddd')->getCommandContext()) + : null; + } + + $namespace = $resolvedNamespace ?? match (true) { $absolute => $this->namespace->root, str($name)->startsWith('\\') => $this->guessNamespaceFromName($name), default => $this->namespaceFor($type), }; - $baseName = str($name)->replace($namespace, '')->trim('\\')->toString(); + $baseName = str($name)->replace($namespace, '') + ->replace(['\\', '/'], '\\') + ->trim('\\') + ->toString(); + + $fullyQualifiedName = $namespace.'\\'.$baseName; return new DomainObject( name: $baseName, domain: $this->domain, namespace: $namespace, - fullyQualifiedName: $namespace.'\\'.$baseName, - path: $this->path($namespace.'\\'.$baseName), + fullyQualifiedName: $fullyQualifiedName, + path: DomainResolver::isApplicationLayer($type) + ? $this->pathInApplicationLayer($fullyQualifiedName) + : $this->path($fullyQualifiedName), type: $type ); } diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php index beae39e..9e812d9 100644 --- a/src/Support/DomainAutoloader.php +++ b/src/Support/DomainAutoloader.php @@ -154,7 +154,9 @@ protected static function discoverProviders(): array } $paths = static::normalizePaths( - $configValue === true ? app()->basePath(DomainResolver::domainPath()) : $configValue + $configValue === true + ? app()->basePath(DomainResolver::domainPath()) + : $configValue ); if (empty($paths)) { diff --git a/src/Support/DomainMigration.php b/src/Support/DomainMigration.php new file mode 100644 index 0000000..9f3a939 --- /dev/null +++ b/src/Support/DomainMigration.php @@ -0,0 +1,81 @@ +filter(fn ($path) => is_dir($path)) + ->toArray(); + } + + public static function discoverPaths(): array + { + $configValue = config('ddd.autoload.migrations', true); + + if ($configValue === false) { + return []; + } + + $paths = static::normalizePaths([ + app()->basePath(DomainResolver::domainPath()), + ]); + + if (empty($paths)) { + return []; + } + + $finder = static::finder($paths); + + return Lody::filesFromFinder($finder) + ->map(fn ($file) => Path::normalize($file->getPath())) + ->unique() + ->values() + ->toArray(); + } + + protected static function finder(array $paths) + { + $filter = function (SplFileInfo $file) { + $configuredMigrationFolder = static::domainMigrationFolder(); + + $relativePath = Path::normalize($file->getRelativePath()); + + return Str::endsWith($relativePath, $configuredMigrationFolder); + }; + + return Finder::create() + ->files() + ->in($paths) + ->filter($filter); + } +} diff --git a/src/Support/DomainResolver.php b/src/Support/DomainResolver.php index 9d52db8..0d9110c 100644 --- a/src/Support/DomainResolver.php +++ b/src/Support/DomainResolver.php @@ -35,6 +35,22 @@ public static function domainRootNamespace(): ?string return config('ddd.domain_namespace'); } + /** + * Get the current configured application layer path. + */ + public static function applicationLayerPath(): ?string + { + return config('ddd.application.path'); + } + + /** + * Get the current configured root application layer namespace. + */ + public static function applicationLayerRootNamespace(): ?string + { + return config('ddd.application.namespace'); + } + /** * Resolve the relative domain object namespace. * @@ -45,19 +61,66 @@ 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 + /** + * Determine whether a given object type is part of the application layer. + */ + public static function isApplicationLayer(string $type): bool + { + $filter = app('ddd')->getApplicationLayerFilter() ?? function (string $type) { + $applicationObjects = config('ddd.application.objects', ['controller', 'request']); + + return in_array($type, $applicationObjects); + }; + + return $filter($type); + } + + /** + * Resolve the root namespace for a given domain object type. + * + * @param string $type The domain object type. + */ + public static function resolveRootNamespace(string $type): ?string { - $namespace = collect([ - static::domainRootNamespace(), - $domain, - static::getRelativeObjectNamespace($type), - ])->filter()->implode('\\'); - - if ($object) { - $namespace .= "\\{$object}"; + return static::isApplicationLayer($type) + ? static::applicationLayerRootNamespace() + : static::domainRootNamespace(); + } + + /** + * Get the fully qualified namespace for a domain object. + * + * @param string $domain The domain name. + * @param string $type The domain object type. + * @param string|null $name The domain object name. + */ + public static function getDomainObjectNamespace(string $domain, string $type, ?string $name = null): string + { + $customResolver = app('ddd')->getNamespaceResolver(); + + $resolved = is_callable($customResolver) + ? $customResolver($domain, $type, app('ddd')->getCommandContext()) + : null; + + if (! is_null($resolved)) { + return $resolved; } - return $namespace; + $resolver = function (string $domain, string $type, ?string $name) { + $namespace = collect([ + static::resolveRootNamespace($type), + $domain, + static::getRelativeObjectNamespace($type), + ])->filter()->implode('\\'); + + if ($name) { + $namespace .= "\\{$name}"; + } + + return $namespace; + }; + + return $resolver($domain, $type, $name); } /** @@ -95,6 +158,22 @@ public static function guessPathFromClass(string $class): ?string return Path::join(...[static::domainPath(), "{$classWithoutDomainRoot}.php"]); } + /** + * Attempt to resolve the folder of a given domain class. + */ + public static function guessFolderFromClass(string $class): ?string + { + $path = static::guessPathFromClass($class); + + if (! $path) { + return null; + } + + $filenamePortion = basename($path); + + return Str::beforeLast($path, $filenamePortion); + } + /** * Determine whether a class is an object within the domain layer. * diff --git a/src/Support/Path.php b/src/Support/Path.php index 931230c..7fbdb86 100644 --- a/src/Support/Path.php +++ b/src/Support/Path.php @@ -26,4 +26,9 @@ public static function filePathToNamespace(string $path, string $namespacePath, $path ); } + + public static function normalizeNamespace(string $namespace): string + { + return str_replace(['\\', '/'], '\\', $namespace); + } } diff --git a/src/ValueObjects/DomainCommandContext.php b/src/ValueObjects/DomainCommandContext.php new file mode 100644 index 0000000..692ceed --- /dev/null +++ b/src/ValueObjects/DomainCommandContext.php @@ -0,0 +1,51 @@ +getName(), + domain: $domain?->domainWithSubdomain, + type: $type, + resource: $command->argument('name'), + arguments: $command->arguments(), + options: $command->options(), + ); + } + + public function hasOption(string $key): bool + { + return array_key_exists($key, $this->options); + } + + public function option(string $key): mixed + { + return data_get($this->options, $key); + } + + public function hasArgument(string $key): bool + { + return array_key_exists($key, $this->arguments); + } + + public function argument(string $key): mixed + { + return data_get($this->arguments, $key); + } +} diff --git a/tests/.skeleton/src/Domain/Invoicing/Database/Migrations/2024_10_14_215911_do_nothing.php b/tests/.skeleton/src/Domain/Invoicing/Database/Migrations/2024_10_14_215911_do_nothing.php new file mode 100644 index 0000000..88fa2f3 --- /dev/null +++ b/tests/.skeleton/src/Domain/Invoicing/Database/Migrations/2024_10_14_215911_do_nothing.php @@ -0,0 +1,24 @@ +setupTestApplication(); +}); + +it('can register a custom namespace resolver', function () { + Config::set('ddd.application', [ + 'path' => 'src/App', + 'namespace' => 'App', + ]); + + DDD::resolveNamespaceUsing(function (string $domain, string $type, ?DomainCommandContext $context): ?string { + if ($type == 'controller' && $context->option('api')) { + return "App\\Api\\Controllers\\{$domain}"; + } + + return null; + }); + + Artisan::call('ddd:controller', [ + 'name' => 'PaymentApiController', + '--domain' => 'Invoicing', + '--api' => true, + ]); + + $output = Artisan::output(); + + expect($output) + ->toContainFilepath('src/App/Api/Controllers/Invoicing/PaymentApiController.php'); + + $expectedPath = base_path('src/App/Api/Controllers/Invoicing/PaymentApiController.php'); + + expect(file_get_contents($expectedPath)) + ->toContain("namespace App\Api\Controllers\Invoicing;"); +}); diff --git a/tests/Command/CacheTest.php b/tests/Command/CacheTest.php index 81a3587..f7aec3f 100644 --- a/tests/Command/CacheTest.php +++ b/tests/Command/CacheTest.php @@ -2,21 +2,31 @@ use Illuminate\Support\Facades\Artisan; use Lunarstorm\LaravelDDD\Support\DomainCache; +use Lunarstorm\LaravelDDD\Tests\Fixtures\Enums\Feature; beforeEach(function () { $this->setupTestApplication(); + + config(['cache.default' => 'file']); + DomainCache::clear(); }); -it('can cache discovered domain providers and commands', function () { - expect(DomainCache::get('domain-providers'))->toBeNull(); +afterEach(function () { + $this->artisan('optimize:clear')->execute(); +}); +it('can cache discovered domain providers, commands, migrations', function () { + expect(DomainCache::get('domain-providers'))->toBeNull(); expect(DomainCache::get('domain-commands'))->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->toBeNull(); $this ->artisan('ddd:cache') - ->expectsOutputToContain('Domain providers cached successfully.') - ->expectsOutputToContain('Domain commands cached successfully.') + ->expectsOutputToContain('Caching DDD providers, commands, migration paths.') + ->expectsOutputToContain('domain providers') + ->expectsOutputToContain('domain commands') + ->expectsOutputToContain('domain migration paths') ->execute(); expect(DomainCache::get('domain-providers')) @@ -24,6 +34,10 @@ expect(DomainCache::get('domain-commands')) ->toContain('Domain\Invoicing\Commands\InvoiceDeliver'); + + $paths = collect(DomainCache::get('domain-migration-paths'))->join("\n"); + + expect($paths)->toContainFilepath('src/Domain/Invoicing/Database/Migrations'); }); it('can clear the cache', function () { @@ -31,6 +45,7 @@ expect(DomainCache::get('domain-providers'))->not->toBeNull(); expect(DomainCache::get('domain-commands'))->not->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->not->toBeNull(); $this ->artisan('ddd:clear') @@ -39,26 +54,63 @@ expect(DomainCache::get('domain-providers'))->toBeNull(); expect(DomainCache::get('domain-commands'))->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->toBeNull(); }); it('will not be cleared by laravel cache clearing', function () { - config(['cache.default' => 'file']); - expect(DomainCache::get('domain-providers'))->toBeNull(); expect(DomainCache::get('domain-commands'))->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->toBeNull(); $this->artisan('ddd:cache')->execute(); expect(DomainCache::get('domain-providers'))->not->toBeNull(); expect(DomainCache::get('domain-commands'))->not->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->not->toBeNull(); $this->artisan('cache:clear')->execute(); expect(DomainCache::get('domain-providers'))->not->toBeNull(); expect(DomainCache::get('domain-commands'))->not->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->not->toBeNull(); - $this->artisan('optimize:clear')->execute(); + if (Feature::LaravelPackageOptimizeCommands->missing()) { + $this->artisan('optimize:clear')->execute(); - expect(DomainCache::get('domain-providers'))->not->toBeNull(); - expect(DomainCache::get('domain-commands'))->not->toBeNull(); + expect(DomainCache::get('domain-providers'))->not->toBeNull(); + expect(DomainCache::get('domain-commands'))->not->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->not->toBeNull(); + } }); + +describe('laravel optimize', function () { + test('optimize will include ddd:cache', function () { + config(['cache.default' => 'file']); + + expect(DomainCache::get('domain-providers'))->toBeNull(); + expect(DomainCache::get('domain-commands'))->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->toBeNull(); + + $this->artisan('optimize')->execute(); + + expect(DomainCache::get('domain-providers'))->not->toBeNull(); + expect(DomainCache::get('domain-commands'))->not->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->not->toBeNull(); + }); + + test('optimize:clear will clear ddd cache', function () { + config(['cache.default' => 'file']); + + $this->artisan('ddd:cache')->execute(); + + expect(DomainCache::get('domain-providers'))->not->toBeNull(); + expect(DomainCache::get('domain-commands'))->not->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->not->toBeNull(); + + $this->artisan('optimize:clear')->execute(); + + expect(DomainCache::get('domain-providers'))->toBeNull(); + expect(DomainCache::get('domain-commands'))->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->toBeNull(); + }); +})->skipOnLaravelVersionsBelow(Feature::LaravelPackageOptimizeCommands->value); diff --git a/tests/Command/ListTest.php b/tests/Command/ListTest.php index 36afcee..6a1443b 100644 --- a/tests/Command/ListTest.php +++ b/tests/Command/ListTest.php @@ -13,6 +13,11 @@ '--domain' => 'Customer', ]); + $this->artisan('ddd:value', [ + 'name' => 'Subtotal', + '--domain' => 'Shared', + ]); + $this->expectedDomains = [ 'Customer', 'Invoicing', diff --git a/tests/Fixtures/Enums/Feature.php b/tests/Fixtures/Enums/Feature.php index 21c2aa1..9b82fbb 100644 --- a/tests/Fixtures/Enums/Feature.php +++ b/tests/Fixtures/Enums/Feature.php @@ -7,6 +7,7 @@ enum Feature: string case PromptForMissingInput = '9.49.0'; case IncludeFilepathInGeneratorCommandOutput = '9.32.0'; case LaravelPromptsPackage = '10.17'; + case LaravelPackageOptimizeCommands = '11.27.1'; public function exists(): bool { diff --git a/tests/Generator/MakeActionTest.php b/tests/Generator/ActionMakeTest.php similarity index 100% rename from tests/Generator/MakeActionTest.php rename to tests/Generator/ActionMakeTest.php diff --git a/tests/Generator/MakeBaseModelTest.php b/tests/Generator/BaseModelMakeTest.php similarity index 100% rename from tests/Generator/MakeBaseModelTest.php rename to tests/Generator/BaseModelMakeTest.php diff --git a/tests/Generator/MakeBaseViewModelTest.php b/tests/Generator/BaseViewModelMakeTest.php similarity index 100% rename from tests/Generator/MakeBaseViewModelTest.php rename to tests/Generator/BaseViewModelMakeTest.php diff --git a/tests/Generator/ControllerMakeTest.php b/tests/Generator/ControllerMakeTest.php new file mode 100644 index 0000000..5aae081 --- /dev/null +++ b/tests/Generator/ControllerMakeTest.php @@ -0,0 +1,177 @@ + 'app/Modules', + 'namespace' => 'App\Modules', + 'objects' => ['controller', 'request'], + ]); + + $this->setupTestApplication(); +}); + +it('can generate domain controller', function ($domainName, $controllerName, $relativePath, $expectedNamespace) { + $expectedPath = base_path($relativePath); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + expect(file_exists($expectedPath))->toBeFalse(); + + Artisan::call("ddd:controller {$domainName}:{$controllerName}"); + + expect($output = Artisan::output())->when( + Feature::IncludeFilepathInGeneratorCommandOutput->exists(), + fn ($output) => $output->toContainFilepath($relativePath), + ); + + expect(file_exists($expectedPath))->toBeTrue(); + + expect(file_get_contents($expectedPath)) + ->toContain("namespace {$expectedNamespace};"); +})->with([ + 'Invoicing:InvoiceController' => [ + 'Invoicing', + 'InvoiceController', + 'app/Modules/Invoicing/Controllers/InvoiceController.php', + 'App\Modules\Invoicing\Controllers', + ], + + 'Reporting.Internal:ReportSubmissionController' => [ + 'Reporting.Internal', + 'ReportSubmissionController', + 'app/Modules/Reporting/Internal/Controllers/ReportSubmissionController.php', + 'App\Modules\Reporting\Internal\Controllers', + ], +]); + +it('can generate domain resource controller from model', function ($domainName, $controllerName, $relativePath, $modelName, $modelClass) { + $expectedPath = base_path($relativePath); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + expect(file_exists($expectedPath))->toBeFalse(); + + Artisan::call('ddd:controller', [ + 'name' => $controllerName, + '--domain' => $domainName, + '--model' => $modelName, + ]); + + expect(file_exists($expectedPath))->toBeTrue(); + + $modelVariable = lcfirst(class_basename($modelClass)); + + expect(file_get_contents($expectedPath)) + ->toContain("use {$modelClass};") + ->toContain("{$modelName} \${$modelVariable})"); +})->with([ + 'Invoicing:InvoiceController --model=Invoice' => [ + 'Invoicing', + 'InvoiceController', + 'app/Modules/Invoicing/Controllers/InvoiceController.php', + 'Invoice', + 'Domain\Invoicing\Models\Invoice', + ], + + 'Invoicing:Payment/InvoicePaymentController --model=InvoicePayment' => [ + 'Invoicing', + 'Payment/InvoicePaymentController', + 'app/Modules/Invoicing/Controllers/Payment/InvoicePaymentController.php', + 'InvoicePayment', + 'Domain\Invoicing\Models\InvoicePayment', + ], + + 'Reporting.Internal:Archived/ReportArchiveController --model=ReportArchive' => [ + 'Reporting.Internal', + 'Archived/ReportArchiveController', + 'app/Modules/Reporting/Internal/Controllers/Archived/ReportArchiveController.php', + 'ReportArchive', + 'Domain\Reporting\Internal\Models\ReportArchive', + ], +]); + +it('can generate domain controller with requests', function ($domainName, $controllerName, $controllerPath, $modelName, $modelClass, $generatedPaths) { + $generatedPaths = [ + $controllerPath, + ...$generatedPaths, + ]; + + foreach ($generatedPaths as $path) { + $path = base_path($path); + + if (file_exists($path)) { + unlink($path); + } + } + + Artisan::call('ddd:controller', [ + 'name' => $controllerName, + '--domain' => $domainName, + '--model' => $modelName, + '--requests' => true, + ]); + + $output = Artisan::output(); + + foreach ($generatedPaths as $path) { + if (Feature::IncludeFilepathInGeneratorCommandOutput->exists()) { + expect($output)->toContainFilepath($path); + } + + expect(file_exists(base_path($path)))->toBeTrue("Expecting {$path} to exist"); + } + + $modelVariable = lcfirst(class_basename($modelClass)); + + expect(file_get_contents(base_path($controllerPath))) + ->toContain("use {$modelClass};") + ->toContain("store(Store{$modelName}Request \$request)") + ->toContain("update(Update{$modelName}Request \$request, {$modelName} \${$modelVariable})"); +})->with([ + 'Invoicing:InvoiceController --model=Invoice' => [ + 'Invoicing', + 'InvoiceController', + 'app/Modules/Invoicing/Controllers/InvoiceController.php', + 'Invoice', + 'Domain\Invoicing\Models\Invoice', + [ + 'app/Modules/Invoicing/Requests/StoreInvoiceRequest.php', + 'app/Modules/Invoicing/Requests/UpdateInvoiceRequest.php', + ], + ], + + 'Invoicing:Payment/InvoicePaymentController --model=InvoicePayment' => [ + 'Invoicing', + 'Payment/InvoicePaymentController', + 'app/Modules/Invoicing/Controllers/Payment/InvoicePaymentController.php', + 'InvoicePayment', + 'Domain\Invoicing\Models\InvoicePayment', + [ + 'app/Modules/Invoicing/Requests/Payment/StoreInvoicePaymentRequest.php', + 'app/Modules/Invoicing/Requests/Payment/UpdateInvoicePaymentRequest.php', + ], + ], + + 'Reporting.Internal:Archived/ReportArchiveController --model=ReportArchive' => [ + 'Reporting.Internal', + 'Archived/ReportArchiveController', + 'app/Modules/Reporting/Internal/Controllers/Archived/ReportArchiveController.php', + 'ReportArchive', + 'Domain\Reporting\Internal\Models\ReportArchive', + [ + 'app/Modules/Reporting/Internal/Requests/Archived/StoreReportArchiveRequest.php', + 'app/Modules/Reporting/Internal/Requests/Archived/UpdateReportArchiveRequest.php', + ], + ], +]); diff --git a/tests/Generator/MakeDataTransferObjectTest.php b/tests/Generator/DtoMakeTestTest.php similarity index 100% rename from tests/Generator/MakeDataTransferObjectTest.php rename to tests/Generator/DtoMakeTestTest.php diff --git a/tests/Generator/ExtendedCommandsTest.php b/tests/Generator/ExtendedCommandsTest.php index 4c0819f..52d8959 100644 --- a/tests/Generator/ExtendedCommandsTest.php +++ b/tests/Generator/ExtendedCommandsTest.php @@ -38,18 +38,22 @@ 'cast' => ['cast', 'SomeCast'], 'channel' => ['channel', 'SomeChannel'], 'command' => ['command', 'SomeCommand'], + 'controller' => ['controller', 'SomeController'], 'event' => ['event', 'SomeEvent'], 'exception' => ['exception', 'SomeException'], 'job' => ['job', 'SomeJob'], 'listener' => ['listener', 'SomeListener'], 'mail' => ['mail', 'SomeMail'], + 'middleware' => ['middleware', 'SomeMiddleware'], 'notification' => ['notification', 'SomeNotification'], 'observer' => ['observer', 'SomeObserver'], 'policy' => ['policy', 'SomePolicy'], 'provider' => ['provider', 'SomeProvider'], 'resource' => ['resource', 'SomeResource'], + 'request' => ['request', 'SomeRequest'], 'rule' => ['rule', 'SomeRule'], 'scope' => ['scope', 'SomeScope'], + 'seeder' => ['seeder', 'SomeSeeder'], 'class' => ['class', 'SomeClass'], 'enum' => ['enum', 'SomeEnum'], 'interface' => ['interface', 'SomeInterface'], diff --git a/tests/Generator/MakeFactoryTest.php b/tests/Generator/FactoryMakeTest.php similarity index 100% rename from tests/Generator/MakeFactoryTest.php rename to tests/Generator/FactoryMakeTest.php diff --git a/tests/Generator/MigrationMakeTest.php b/tests/Generator/MigrationMakeTest.php new file mode 100644 index 0000000..c32c614 --- /dev/null +++ b/tests/Generator/MigrationMakeTest.php @@ -0,0 +1,84 @@ + true, + ]); + + DomainCache::clear(); +}); + +it('can generate domain migrations', function ($domainPath, $domainRoot) { + Config::set('ddd.domain_path', $domainPath); + Config::set('ddd.domain_namespace', $domainRoot); + + $domain = 'Invoicing'; + + $relativePath = implode('/', [ + $domainPath, + $domain, + config('ddd.namespaces.migration'), + ]); + + $migrationFolder = base_path(Path::normalize($relativePath)); + + $filesBefore = glob("{$migrationFolder}/*"); + + expect(count($filesBefore))->toBe(0); + + Artisan::call("ddd:migration {$domain}:CreateInvoicesTable"); + + expect($output = Artisan::output())->when( + Feature::IncludeFilepathInGeneratorCommandOutput->exists(), + fn ($output) => $output + ->toContainFilepath($relativePath) + ->toContain('_create_invoices_table.php'), + ); + + $filesAfter = glob("{$migrationFolder}/*"); + + $createdMigrationFile = Arr::last($filesAfter); + + expect($createdMigrationFile)->toEndWith('_create_invoices_table.php'); + + expect(file_get_contents($createdMigrationFile)) + ->toContain('return new class extends Migration'); +})->with('domainPaths'); + +it('discovers domain migration folders', function ($domainPath, $domainRoot) { + Config::set('ddd.domain_path', $domainPath); + Config::set('ddd.domain_namespace', $domainRoot); + + $discoveredPaths = DomainMigration::discoverPaths(); + + expect($discoveredPaths)->toHaveCount(0); + + Artisan::call('ddd:migration Invoicing:'.uniqid('migration')); + Artisan::call('ddd:migration Shared:'.uniqid('migration')); + Artisan::call('ddd:migration Reporting:'.uniqid('migration')); + Artisan::call('ddd:migration Reporting:'.uniqid('migration')); + Artisan::call('ddd:migration Reporting:'.uniqid('migration')); + + $discoveredPaths = DomainMigration::discoverPaths(); + + expect($discoveredPaths)->toHaveCount(3); + + $expectedFolderPatterns = [ + Path::normalize('Invoicing/Database/Migrations'), + Path::normalize('Shared/Database/Migrations'), + Path::normalize('Reporting/Database/Migrations'), + ]; + + foreach ($discoveredPaths as $path) { + expect(str($path)->contains($expectedFolderPatterns)) + ->toBeTrue('Expecting path to contain one of the expected folder patterns'); + } +})->with('domainPaths'); diff --git a/tests/Generator/MakeModelTest.php b/tests/Generator/Model/MakeTest.php similarity index 83% rename from tests/Generator/MakeModelTest.php rename to tests/Generator/Model/MakeTest.php index 8772f20..4b841a5 100644 --- a/tests/Generator/MakeModelTest.php +++ b/tests/Generator/Model/MakeTest.php @@ -42,7 +42,8 @@ config('ddd.namespaces.model'), ]); - expect(file_get_contents($expectedModelPath))->toContain("namespace {$expectedNamespace};"); + expect(file_get_contents($expectedModelPath)) + ->toContain("namespace {$expectedNamespace};"); })->with('domainPaths'); it('can generate a domain model with factory', function ($domainPath, $domainRoot, $domainName, $subdomain) { @@ -179,3 +180,34 @@ ['Illuminate\Database\Eloquent\Model'], ['Lunarstorm\LaravelDDD\Models\DomainModel'], ]); + +it('extends custom base models when applicable', function ($baseModelClass, $baseModelName) { + Config::set('ddd.base_model', $baseModelClass); + + $domain = 'Fruits'; + $modelName = 'Lemon'; + + $expectedModelPath = base_path(implode('/', [ + config('ddd.domain_path'), + $domain, + config('ddd.namespaces.model'), + "{$modelName}.php", + ])); + + if (file_exists($expectedModelPath)) { + unlink($expectedModelPath); + } + + Artisan::call("ddd:model {$domain}:{$modelName}"); + + expect(file_exists($expectedModelPath))->toBeTrue(); + + expect(file_get_contents($expectedModelPath)) + ->toContain("use {$baseModelClass};") + ->toContain("extends {$baseModelName}"); +})->with([ + ['Domain\Shared\Models\BaseModel', 'BaseModel'], + ['Lunarstorm\LaravelDDD\Models\DomainModel', 'DomainModel'], + ['Illuminate\Database\Eloquent\NonExistentModel', 'NonExistentModel'], + ['OtherVendor\OtherPackage\Models\NonExistentModel', 'NonExistentModel'], +]); diff --git a/tests/Generator/Model/MakeWithControllerTest.php b/tests/Generator/Model/MakeWithControllerTest.php new file mode 100644 index 0000000..629933f --- /dev/null +++ b/tests/Generator/Model/MakeWithControllerTest.php @@ -0,0 +1,90 @@ +setupTestApplication(); +}); + +it('can generate domain model with controller', function ($domainName, $modelName, $controllerName, $generatedPaths) { + $domain = new Domain($domainName); + + foreach ($generatedPaths as $path) { + $path = base_path($path); + + if (file_exists($path)) { + unlink($path); + } + } + + $command = [ + 'ddd:model', + [ + 'name' => $modelName, + '--domain' => $domain->dotName, + '--controller' => true, + ], + ]; + + Artisan::call(...$command); + + $output = Artisan::output(); + + foreach ($generatedPaths as $path) { + if (Feature::IncludeFilepathInGeneratorCommandOutput->exists()) { + expect($output)->toContainFilepath($path); + } + + expect(file_exists(base_path($path)))->toBeTrue("Expecting {$path} to exist"); + } +})->with([ + 'Invoicing:Record' => [ + 'Invoicing', + 'Record', + 'RecordController', + [ + 'src/Domain/Invoicing/Models/Record.php', + 'app/Modules/Invoicing/Controllers/RecordController.php', + ], + ], + + 'Invoicing:RecordEntry' => [ + 'Invoicing', + 'RecordEntry', + 'RecordEntryController', + [ + 'src/Domain/Invoicing/Models/RecordEntry.php', + 'app/Modules/Invoicing/Controllers/RecordEntryController.php', + ], + ], + + 'Reporting.Internal:ReportSubmission' => [ + 'Reporting.Internal', + 'ReportSubmission', + 'ReportSubmissionController', + [ + 'src/Domain/Reporting/Internal/Models/ReportSubmission.php', + 'app/Modules/Reporting/Internal/Controllers/ReportSubmissionController.php', + ], + ], + + // '--controller --api' => [ + // ['--controller' => true, '--api' => true], + // 'RecordController', + // 'app/Http/Controllers/Invoicing/RecordController.php', + // ], + + // '--controller --requests' => [ + // ['--controller' => true, '--requests' => true], + // 'RecordController', + // 'app/Http/Controllers/Invoicing/RecordController.php', + // ], +]); diff --git a/tests/Generator/Model/MakeWithOptionsTest.php b/tests/Generator/Model/MakeWithOptionsTest.php new file mode 100644 index 0000000..09cb201 --- /dev/null +++ b/tests/Generator/Model/MakeWithOptionsTest.php @@ -0,0 +1,109 @@ +model($modelName); + +// $domainFactory = $domain->factory($factoryName); + +// $expectedModelPath = base_path($domainModel->path); + +// if (file_exists($expectedModelPath)) { +// unlink($expectedModelPath); +// } + +// $expectedFactoryPath = base_path($domainFactory->path); + +// if (file_exists($expectedFactoryPath)) { +// unlink($expectedFactoryPath); +// } + +// Artisan::call('ddd:model', [ +// 'name' => $modelName, +// '--domain' => $domain->dotName, +// '--factory' => true, +// ]); + +// $output = Artisan::output(); + +// 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->fullyQualifiedName};") +// ->toContain("protected \$model = {$modelName}::class;"); +// }); + +it('can generate domain model with options', function ($options, $objectType, $objectName, $expectedObjectPath) { + $domainName = 'Invoicing'; + $modelName = 'Record'; + + $domain = new Domain($domainName); + + $domainModel = $domain->model($modelName); + + $expectedModelPath = base_path($domainModel->path); + + if (file_exists($expectedModelPath)) { + unlink($expectedModelPath); + } + + if (file_exists($expectedObjectPath)) { + unlink($expectedObjectPath); + } + + $command = [ + 'ddd:model', [ + 'name' => $modelName, + '--domain' => $domain->dotName, + ...$options, + ], + ]; + + $this->artisan(...$command) + ->expectsOutputToContain(Path::normalize($domainModel->path)) + ->assertExitCode(0); + + $path = base_path($expectedObjectPath); + + expect(file_exists($path))->toBeTrue("Expecting {$objectType} to be generated at {$path}"); +})->with([ + '--factory' => [ + ['--factory' => true], + 'factory', + 'RecordFactory', + 'src/Domain/Invoicing/Database/Factories/RecordFactory.php', + ], + + '--seed' => [ + ['--seed' => true], + 'seeder', + 'RecordSeeder', + 'src/Domain/Invoicing/Database/Seeders/RecordSeeder.php', + ], + + '--policy' => [ + ['--policy' => true], + 'policy', + 'RecordPolicy', + 'src/Domain/Invoicing/Policies/RecordPolicy.php', + ], +]); diff --git a/tests/Generator/PromptTest.php b/tests/Generator/PromptTest.php index 3dea905..53bb086 100644 --- a/tests/Generator/PromptTest.php +++ b/tests/Generator/PromptTest.php @@ -23,6 +23,7 @@ it('[model] prompts for missing input', function () { $this->artisan('ddd:model') ->expectsQuestion('What should the model be named?', 'Belt') + ->expectsQuestion('Would you like any of the following?', []) ->expectsQuestion('What is the domain?', 'Utility') ->assertExitCode(0); }); diff --git a/tests/Generator/RequestMakeTest.php b/tests/Generator/RequestMakeTest.php new file mode 100644 index 0000000..cc24949 --- /dev/null +++ b/tests/Generator/RequestMakeTest.php @@ -0,0 +1,54 @@ + 'app/Modules', + 'namespace' => 'App\Modules', + 'objects' => ['controller', 'request'], + ]); + + $this->setupTestApplication(); +}); + +it('can generate domain request', function ($domainName, $requestName, $relativePath, $expectedNamespace) { + $expectedPath = base_path($relativePath); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + expect(file_exists($expectedPath))->toBeFalse(); + + Artisan::call("ddd:request {$domainName}:{$requestName}"); + + expect($output = Artisan::output())->when( + Feature::IncludeFilepathInGeneratorCommandOutput->exists(), + fn ($output) => $output->toContainFilepath($relativePath), + ); + + expect(file_exists($expectedPath))->toBeTrue(); + + expect(file_get_contents($expectedPath)) + ->toContain("namespace {$expectedNamespace};"); +})->with([ + 'Invoicing:StoreInvoiceRequest' => [ + 'Invoicing', + 'StoreInvoiceRequest', + 'app/Modules/Invoicing/Requests/StoreInvoiceRequest.php', + 'App\Modules\Invoicing\Requests', + ], + + 'Reporting.Internal:UpdateReportRequest' => [ + 'Reporting.Internal', + 'UpdateReportRequest', + 'app/Modules/Reporting/Internal/Requests/UpdateReportRequest.php', + 'App\Modules\Reporting\Internal\Requests', + ], +]); diff --git a/tests/Generator/MakeValueObjectTest.php b/tests/Generator/ValueObjectMakeTest.php similarity index 100% rename from tests/Generator/MakeValueObjectTest.php rename to tests/Generator/ValueObjectMakeTest.php diff --git a/tests/Generator/MakeViewModelTest.php b/tests/Generator/ViewModelMakeTest.php similarity index 100% rename from tests/Generator/MakeViewModelTest.php rename to tests/Generator/ViewModelMakeTest.php diff --git a/tests/Support/DomainResolverTest.php b/tests/Support/DomainResolverTest.php index 37b63b9..846c815 100644 --- a/tests/Support/DomainResolverTest.php +++ b/tests/Support/DomainResolverTest.php @@ -13,6 +13,11 @@ '--domain' => 'Customer', ]); + $this->artisan('ddd:value', [ + 'name' => 'Subtotal', + '--domain' => 'Shared', + ]); + $this->expectedDomains = [ 'Customer', 'Invoicing', diff --git a/tests/Support/DomainTest.php b/tests/Support/DomainTest.php index e263500..8a701ae 100644 --- a/tests/Support/DomainTest.php +++ b/tests/Support/DomainTest.php @@ -1,5 +1,6 @@ 'app/Modules', + 'namespace' => 'App\Modules', + 'objects' => ['controller', 'request'], + ]); + }); + + it('can describe objects in the application layer', 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', 'controller', 'InvoiceController', 'App\\Modules\\Invoicing\\Controllers\\InvoiceController', 'app/Modules/Invoicing/Controllers/InvoiceController.php'], + ['Invoicing', 'controller', 'Nested\\InvoiceController', 'App\\Modules\\Invoicing\\Controllers\\Nested\\InvoiceController', 'app/Modules/Invoicing/Controllers/Nested/InvoiceController.php'], + ['Invoicing', 'request', 'StoreInvoiceRequest', 'App\\Modules\\Invoicing\\Requests\\StoreInvoiceRequest', 'app/Modules/Invoicing/Requests/StoreInvoiceRequest.php'], + ['Invoicing', 'request', 'Nested\\StoreInvoiceRequest', 'App\\Modules\\Invoicing\\Requests\\Nested\\StoreInvoiceRequest', 'app/Modules/Invoicing/Requests/Nested/StoreInvoiceRequest.php'], + ]); +}); + +it('normalizes slashes in nested objects', function ($nameInput, $normalized) { + expect((new Domain('Invoicing'))->object('class', $nameInput)) + ->name->toBe($normalized); +})->with([ + ['Nested\\Thing', 'Nested\\Thing'], + ['Nested/Thing', 'Nested\\Thing'], + ['Nested/Thing/Deeply', 'Nested\\Thing\\Deeply'], + ['Nested\\Thing/Deeply', 'Nested\\Thing\\Deeply'], +]); diff --git a/tests/TestCase.php b/tests/TestCase.php index fb69cab..b04ef5a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -151,6 +151,7 @@ protected function cleanSlate() File::deleteDirectory(base_path('Custom')); File::deleteDirectory(base_path('src/Domain')); File::deleteDirectory(base_path('src/Domains')); + File::deleteDirectory(base_path('src/App')); File::deleteDirectory(app_path('Models')); File::deleteDirectory(base_path('bootstrap/cache/ddd'));