From 08a6ebbd1ad1efa5958f7a7d9ad1faf139cea6a3 Mon Sep 17 00:00:00 2001 From: Jasper Tey Date: Sat, 23 Nov 2024 00:38:34 -0500 Subject: [PATCH] [1.2] Official Release (#77) * [1.2] ddd:model options, migrations, application layer (#69) * [1.2] Refactoring stubs (#71) * [1.2] Custom Layers (#76) --- .github/workflows/run-tests.yml | 2 +- CHANGELOG.md | 53 +++ README.md | 393 +++++++++++++----- UPGRADING.md | 19 +- composer.json | 21 +- config/ddd.php | 82 ++-- config/ddd.php.stub | 100 ++--- routes/testing.php | 24 ++ src/Commands/CacheCommand.php | 24 -- src/Commands/Concerns/CanPromptForDomain.php | 35 -- .../Concerns/ForwardsToDomainCommands.php | 62 +++ src/Commands/Concerns/HandleHooks.php | 25 ++ src/Commands/Concerns/HasDomainStubs.php | 70 ++++ .../Concerns/HasGeneratorBlueprint.php | 10 + src/Commands/Concerns/InteractsWithStubs.php | 32 ++ .../Concerns/QualifiesDomainModels.php | 17 + .../Concerns/ResolvesDomainFromInput.php | 103 ++--- src/Commands/Concerns/UpdatesComposer.php | 54 +++ src/Commands/ConfigCommand.php | 392 +++++++++++++++++ src/Commands/DomainActionMakeCommand.php | 6 +- src/Commands/DomainBaseModelMakeCommand.php | 5 +- .../DomainBaseViewModelMakeCommand.php | 5 +- src/Commands/DomainCastMakeCommand.php | 4 +- src/Commands/DomainChannelMakeCommand.php | 4 +- src/Commands/DomainClassMakeCommand.php | 4 +- src/Commands/DomainConsoleMakeCommand.php | 4 +- src/Commands/DomainControllerMakeCommand.php | 102 +++++ src/Commands/DomainDtoMakeCommand.php | 6 +- src/Commands/DomainEnumMakeCommand.php | 4 +- src/Commands/DomainEventMakeCommand.php | 4 +- src/Commands/DomainExceptionMakeCommand.php | 4 +- src/Commands/DomainFactoryMakeCommand.php | 59 +-- src/Commands/DomainGeneratorCommand.php | 43 +- src/Commands/DomainInterfaceMakeCommand.php | 4 +- src/Commands/DomainJobMakeCommand.php | 4 +- src/Commands/DomainListCommand.php | 8 +- src/Commands/DomainListenerMakeCommand.php | 4 +- src/Commands/DomainMailMakeCommand.php | 4 +- src/Commands/DomainMiddlewareMakeCommand.php | 15 + src/Commands/DomainModelMakeCommand.php | 113 +++-- .../DomainNotificationMakeCommand.php | 4 +- src/Commands/DomainObserverMakeCommand.php | 4 +- src/Commands/DomainPolicyMakeCommand.php | 4 +- src/Commands/DomainProviderMakeCommand.php | 4 +- src/Commands/DomainRequestMakeCommand.php | 22 + src/Commands/DomainResourceMakeCommand.php | 4 +- src/Commands/DomainRuleMakeCommand.php | 4 +- src/Commands/DomainScopeMakeCommand.php | 4 +- src/Commands/DomainSeederMakeCommand.php | 15 + src/Commands/DomainTraitMakeCommand.php | 4 +- src/Commands/DomainValueObjectMakeCommand.php | 6 +- src/Commands/DomainViewModelMakeCommand.php | 11 +- src/Commands/InstallCommand.php | 57 +-- .../Migration/BaseMigrateMakeCommand.php | 42 ++ .../Migration/DomainMigrateMakeCommand.php | 27 ++ ...arCommand.php => OptimizeClearCommand.php} | 11 +- src/Commands/OptimizeCommand.php | 32 ++ src/Commands/PublishCommand.php | 63 +++ src/Commands/StubCommand.php | 163 ++++++++ src/Commands/UpgradeCommand.php | 60 ++- src/ComposerManager.php | 179 ++++++++ src/ConfigManager.php | 156 +++++++ src/DomainManager.php | 77 ++++ src/Enums/LayerType.php | 10 + src/Facades/Autoload.php | 26 ++ src/Facades/DDD.php | 7 + src/Factories/DomainFactory.php | 3 +- src/LaravelDDDServiceProvider.php | 102 ++++- src/StubManager.php | 102 +++++ src/Support/AutoloadManager.php | 360 ++++++++++++++++ .../Concerns/InteractsWithComposer.php | 30 ++ src/Support/Domain.php | 64 ++- src/Support/DomainAutoloader.php | 214 ---------- src/Support/DomainMigration.php | 81 ++++ src/Support/DomainResolver.php | 119 +++++- src/Support/GeneratorBlueprint.php | 150 +++++++ src/Support/Layer.php | 76 ++++ src/Support/Path.php | 13 + src/ValueObjects/CommandContext.php | 32 ++ src/ValueObjects/ObjectSchema.php | 13 + stubs/{action.php.stub => action.stub} | 0 .../{base-model.php.stub => base-model.stub} | 0 ...ew-model.php.stub => base-view-model.stub} | 0 stubs/{dto.php.stub => dto.stub} | 0 stubs/{factory.php.stub => factory.stub} | 0 stubs/model.php.stub | 13 - ...alue-object.php.stub => value-object.stub} | 0 .../{view-model.php.stub => view-model.stub} | 0 .../app/Http/Controllers/Controller.php | 8 + tests/.skeleton/composer.json | 16 +- tests/.skeleton/config/ddd.php | 185 +++++++++ .../Application/Commands/ApplicationSync.php | 24 ++ .../2024_10_14_215912_application_setup.php | 24 ++ .../src/Application/Models/Login.php | 23 + .../src/Application/Policies/LoginPolicy.php | 47 +++ .../Providers/ApplicationServiceProvider.php | 29 ++ .../2024_10_14_215911_do_nothing.php | 24 ++ .../Providers/InvoiceServiceProvider.php | 4 +- .../src/Infrastructure/Commands/LogPrune.php | 24 ++ .../src/Infrastructure/Models/AppSession.php | 23 + .../Policies/AppSessionPolicy.php | 47 +++ .../InfrastructureServiceProvider.php | 27 ++ .../src/Infrastructure/Support/Clipboard.php | 18 + tests/.skeleton/stubs/ddd/dummy.stub | 0 tests/Autoload/CommandTest.php | 115 +++-- tests/Autoload/FactoryTest.php | 62 ++- tests/Autoload/IgnoreTest.php | 107 +++-- tests/Autoload/PolicyTest.php | 80 +++- tests/Autoload/ProviderTest.php | 124 ++++-- tests/BootsTestApplication.php | 5 + tests/Command/CacheTest.php | 64 --- tests/Command/ConfigTest.php | 209 ++++++++++ tests/Command/InstallTest.php | 23 +- tests/Command/ListTest.php | 13 +- tests/Command/OptimizeTest.php | 122 ++++++ tests/Command/PublishTest.php | 116 ++++++ tests/Command/StubTest.php | 182 ++++++++ tests/Command/UpgradeTest.php | 15 +- tests/Command/resources/composer.sample.json | 26 ++ tests/Config/ManagerTest.php | 55 +++ tests/Config/resources/config.sparse.php | 23 + tests/Datasets/GeneratorSchemas.php | 27 ++ tests/Datasets/resources/config.0.10.0.php | 132 +++--- tests/Expectations.php | 4 + tests/Factory/DomainFactoryTest.php | 18 +- tests/Fixtures/Enums/Feature.php | 3 + ...{MakeActionTest.php => ActionMakeTest.php} | 2 +- ...aseModelTest.php => BaseModelMakeTest.php} | 0 ...odelTest.php => BaseViewModelMakeTest.php} | 0 tests/Generator/ControllerMakeTest.php | 293 +++++++++++++ tests/Generator/CustomLayerTest.php | 92 ++++ ...TransferObjectTest.php => DtoMakeTest.php} | 0 ...dCommandsTest.php => ExtendedMakeTest.php} | 6 +- ...akeFactoryTest.php => FactoryMakeTest.php} | 0 tests/Generator/MigrationMakeTest.php | 84 ++++ .../{MakeModelTest.php => Model/MakeTest.php} | 90 +++- .../Model/MakeWithControllerTest.php | 84 ++++ tests/Generator/Model/MakeWithOptionsTest.php | 109 +++++ tests/Generator/PromptTest.php | 1 + tests/Generator/RequestMakeTest.php | 53 +++ ...ObjectTest.php => ValueObjectMakeTest.php} | 0 ...iewModelTest.php => ViewModelMakeTest.php} | 0 tests/Pest.php | 11 +- tests/Setup/PublishTest.php | 7 +- tests/Support/AutoloaderTest.php | 45 +- tests/Support/BlueprintTest.php | 123 ++++++ tests/Support/DomainResolverTest.php | 5 + tests/Support/DomainTest.php | 54 +++ tests/Support/ResolveLayerTest.php | 44 ++ .../Support/ResolveObjectSchemaUsingTest.php | 53 +++ tests/TestCase.php | 192 +++++++-- 151 files changed, 6479 insertions(+), 1209 deletions(-) create mode 100644 routes/testing.php delete mode 100644 src/Commands/CacheCommand.php delete mode 100644 src/Commands/Concerns/CanPromptForDomain.php create mode 100644 src/Commands/Concerns/ForwardsToDomainCommands.php create mode 100644 src/Commands/Concerns/HandleHooks.php create mode 100644 src/Commands/Concerns/HasDomainStubs.php create mode 100644 src/Commands/Concerns/HasGeneratorBlueprint.php create mode 100644 src/Commands/Concerns/InteractsWithStubs.php create mode 100644 src/Commands/Concerns/QualifiesDomainModels.php create mode 100644 src/Commands/Concerns/UpdatesComposer.php create mode 100644 src/Commands/ConfigCommand.php create mode 100644 src/Commands/DomainControllerMakeCommand.php create mode 100644 src/Commands/DomainMiddlewareMakeCommand.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/Commands/PublishCommand.php create mode 100644 src/Commands/StubCommand.php create mode 100755 src/ComposerManager.php create mode 100755 src/ConfigManager.php create mode 100644 src/Enums/LayerType.php create mode 100644 src/Facades/Autoload.php create mode 100755 src/StubManager.php create mode 100644 src/Support/AutoloadManager.php create mode 100644 src/Support/Concerns/InteractsWithComposer.php delete mode 100644 src/Support/DomainAutoloader.php create mode 100644 src/Support/DomainMigration.php create mode 100644 src/Support/GeneratorBlueprint.php create mode 100644 src/Support/Layer.php create mode 100644 src/ValueObjects/CommandContext.php create mode 100644 src/ValueObjects/ObjectSchema.php rename stubs/{action.php.stub => action.stub} (100%) rename stubs/{base-model.php.stub => base-model.stub} (100%) rename stubs/{base-view-model.php.stub => base-view-model.stub} (100%) rename stubs/{dto.php.stub => dto.stub} (100%) rename stubs/{factory.php.stub => factory.stub} (100%) delete mode 100644 stubs/model.php.stub rename stubs/{value-object.php.stub => value-object.stub} (100%) rename stubs/{view-model.php.stub => view-model.stub} (100%) create mode 100644 tests/.skeleton/app/Http/Controllers/Controller.php create mode 100644 tests/.skeleton/config/ddd.php create mode 100644 tests/.skeleton/src/Application/Commands/ApplicationSync.php create mode 100644 tests/.skeleton/src/Application/Database/Migrations/2024_10_14_215912_application_setup.php create mode 100644 tests/.skeleton/src/Application/Models/Login.php create mode 100644 tests/.skeleton/src/Application/Policies/LoginPolicy.php create mode 100644 tests/.skeleton/src/Application/Providers/ApplicationServiceProvider.php create mode 100644 tests/.skeleton/src/Domain/Invoicing/Database/Migrations/2024_10_14_215911_do_nothing.php create mode 100644 tests/.skeleton/src/Infrastructure/Commands/LogPrune.php create mode 100644 tests/.skeleton/src/Infrastructure/Models/AppSession.php create mode 100644 tests/.skeleton/src/Infrastructure/Policies/AppSessionPolicy.php create mode 100644 tests/.skeleton/src/Infrastructure/Providers/InfrastructureServiceProvider.php create mode 100644 tests/.skeleton/src/Infrastructure/Support/Clipboard.php create mode 100644 tests/.skeleton/stubs/ddd/dummy.stub create mode 100644 tests/BootsTestApplication.php delete mode 100644 tests/Command/CacheTest.php create mode 100644 tests/Command/ConfigTest.php create mode 100644 tests/Command/OptimizeTest.php create mode 100644 tests/Command/PublishTest.php create mode 100644 tests/Command/StubTest.php create mode 100644 tests/Command/resources/composer.sample.json create mode 100644 tests/Config/ManagerTest.php create mode 100644 tests/Config/resources/config.sparse.php create mode 100644 tests/Datasets/GeneratorSchemas.php rename tests/Generator/{MakeActionTest.php => ActionMakeTest.php} (96%) rename tests/Generator/{MakeBaseModelTest.php => BaseModelMakeTest.php} (100%) rename tests/Generator/{MakeBaseViewModelTest.php => BaseViewModelMakeTest.php} (100%) create mode 100644 tests/Generator/ControllerMakeTest.php create mode 100644 tests/Generator/CustomLayerTest.php rename tests/Generator/{MakeDataTransferObjectTest.php => DtoMakeTest.php} (100%) rename tests/Generator/{ExtendedCommandsTest.php => ExtendedMakeTest.php} (86%) rename tests/Generator/{MakeFactoryTest.php => FactoryMakeTest.php} (100%) create mode 100644 tests/Generator/MigrationMakeTest.php rename tests/Generator/{MakeModelTest.php => Model/MakeTest.php} (69%) 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%) create mode 100644 tests/Support/BlueprintTest.php create mode 100644 tests/Support/ResolveLayerTest.php create mode 100644 tests/Support/ResolveObjectSchemaUsingTest.php diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5698f95..e904482 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -54,4 +54,4 @@ jobs: run: composer show -D - name: Execute tests - run: vendor/bin/pest + run: vendor/bin/pest --ci diff --git a/CHANGELOG.md b/CHANGELOG.md index e296e6d..80cc295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ All notable changes to `laravel-ddd` will be documented in this file. +## [Unreleased] +### Breaking +- Stubs are now published to `base_path('stubs/ddd')` instead of `resource_path('stubs/ddd')`. In other words, they are now co-located alongside the framework's published stubs, within a ddd subfolder. +- Published stubs now use `.stub` extension instead of `.php.stub` (following Laravel's convention). +- If you are using published stubs from pre 1.2, you will need to refactor your stubs accordingly. + +### Added +- Support for the Application Layer, to generate domain-specific objects that don't belong directly in the domain layer: + ```php + // In config/ddd.php + 'application_path' => 'app/Modules', + 'application_namespace' => 'App\Modules', + 'application_objects' => [ + 'controller', + 'request', + 'middleware', + ], + ``` +- Support for Custom Layers, additional top-level namespaces of your choosing, such as `Infrastructure`, `Integrations`, etc.: + ```php + // In config/ddd.php + 'layers' => [ + 'Infrastructure' => 'src/Infrastructure', + ], + ``` +- Added config utility command `ddd:config` to help manage the package's configuration over time. +- Added stub utility command `ddd:stub` to publish one or more stubs selectively. +- Added `ddd:controller` to generate domain-specific controllers. +- Added `ddd:request` to generate domain-spefic requests. +- Added `ddd:middleware` to generate domain-specific middleware. +- Added `ddd:migration` to generate domain migrations. +- Added `ddd:seeder` to generate domain seeders. +- Added `ddd:stub` to manage stubs. +- Migration folders across domains will be registered and scanned when running `php artisan migrate`, in addition to the standard application `database/migrations` path. +- Ability to customize generator object naming conventions with your own logic using `DDD::resolveObjectSchemaUsing()`. + +### Changed +- `ddd:model` now 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). +- Since Laravel 11.27.1, the framework's `optimize` and `optimize:clear` commands will automatically invoke `ddd:optimize` (`ddd:cache`) and `ddd:clear` respectively. + +### Deprecated +- Domain base models are no longer required by default, and `config('ddd.base_model')` is now `null` by default. +- Stubs are no longer published via `php artisan vendor:publish --tag="ddd-stubs"`. Instead, use `php artisan ddd:stub` to manage them. + ## [1.1.3] - 2024-11-05 ### Chore - Allow `laravel/prompts` dependency to use latest version when possible. diff --git a/README.md b/README.md index fd1c833..11905a1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Domain Driven Design toolkit for Laravel +# Domain Driven Design Toolkit for Laravel [![Latest Version on Packagist](https://img.shields.io/packagist/v/lunarstorm/laravel-ddd.svg?style=flat-square)](https://packagist.org/packages/lunarstorm/laravel-ddd) [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/lunarstorm/laravel-ddd/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/lunarstorm/laravel-ddd/actions?query=workflow%3Arun-tests+branch%3Amain) [![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) in Laravel applications. One of the pain points when adopting DDD is the inability to use Laravel's native `make` commands to generate domain objects since they are typically stored outside the `App\*` namespace. This package aims to fill the gaps by providing equivalent commands such as `ddd:model`, `ddd:dto`, `ddd:view-model` and many more. +Laravel-DDD is a toolkit to support domain driven design (DDD) in Laravel applications. One of the pain points when adopting DDD is the inability to use Laravel's native `make` commands to generate objects outside the `App\*` namespace. This package aims to fill the gaps by providing equivalent commands such as `ddd:model`, `ddd:dto`, `ddd:view-model` and many more. ## Installation You can install the package via composer: @@ -19,11 +19,19 @@ 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) +- Actions: [lorisleiva/laravel-actions](https://github.com/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 [publish and customize the stubs](#customizing-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. In previous versions of this package, this command was named `ddd:cache`, which will continue to work as an alias. ### Version Compatibility Laravel | LaravelDDD | | @@ -32,7 +40,7 @@ php artisan ddd:cache 10.25.x | 1.x | 11.x | 1.x | -See **[UPGRADING](UPGRADING.md)** for more details about upgrading from 0.x. +See **[UPGRADING](UPGRADING.md)** for more details about upgrading across different versions. @@ -53,56 +61,71 @@ php artisan ddd:{object} {name} ## Available Commands ### Generators -The following generators are currently available, shown using short-hand syntax: +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). + +### Config Utility (Since 1.2) +A configuration utility was introduced in 1.2 to help manage the package's configuration over time. ```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 +php artisan ddd:config +``` +Output: +``` + ┌ Laravel-DDD Config Utility ──────────────────────────────────┐ + │ › ● Run the configuration wizard │ + │ ○ Update and merge ddd.php with latest package version │ + │ ○ Detect domain namespace from composer.json │ + │ ○ Sync composer.json from ddd.php │ + │ ○ Exit │ + └──────────────────────────────────────────────────────────────┘ +``` +These config tasks are also invokeable directly using arguments: +```bash +# Run the configuration wizard +php artisan ddd:config wizard + +# Update and merge ddd.php with latest package version +php artisan ddd:config update + +# Detect domain namespace from composer.json +php artisan ddd:config detect + +# Sync composer.json from ddd.php +php artisan ddd:config composer ``` -Generated objects will be placed in the appropriate domain namespace as specified by `ddd.namespaces.*` in the [config file](#config-file). ### Other Commands ```bash @@ -110,13 +133,74 @@ 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', +'application_namespace' => 'App\Modules', +'application_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 +``` + +### Custom Layers (since 1.2) +Often times, additional top-level namespaces are needed to hold shared components, helpers, and things that are not domain-specific. A common example is the `Infrastructure` layer. You may configure these additional layers in the `ddd.layers` array. +```php +// In config/ddd.php +'layers' => [ + 'Infrastructure' => 'src/Infrastructure', +], +``` +The configuration above will result in the following: +```bash +ddd:model Invoicing:Invoice +ddd:trait Infrastructure:Concerns/HasExpiryDate +``` +Output: +``` +├─ src/Domain +| └─ Invoicing +| └─ Models +| └─ Invoice.php +├─ src/Infrastructure + └─ Concerns + └─ HasExpiryDate.php +``` +After defining new layers in `ddd.php`, make sure the corresponding namespaces are also registered in your `composer.json` file. You may use the `ddd:config` helper command to handle this for you. +```bash +# Sync composer.json with ddd.php +php artisan ddd:config composer +``` + ### Nested Objects For any `ddd:*` generator command, nested objects can be specified with forward slashes. ```bash @@ -143,6 +227,18 @@ php artisan ddd:interface Invoicing:Models/Concerns/HasLineItems # -> Domain\Invoicing\Models\Concerns\HasLineItems ``` +### Subdomains (nested domains) +Subdomains can be specified with dot notation wherever a domain option is accepted. +```bash +# 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) +``` + ### Overriding Configured Namespaces at Runtime If for some reason you need to generate a domain object under a namespace different to what is configured in `ddd.namespaces.*`, you may do so using an absolute name starting with `/`. This will generate the object from the root of the domain. @@ -164,26 +260,95 @@ php artisan ddd:exception Invoicing:/Models/Exceptions/InvoiceNotFoundException # -> Domain\Invoicing\Models\Exceptions\InvoiceNotFoundException ``` -### Subdomains (nested domains) -Subdomains can be specified with dot notation wherever a domain option is accepted. +### Custom Object Resolution +If you require advanced customization of generated object naming conventions, you may register a custom resolver using `DDD::resolveObjectSchemaUsing()` in your AppServiceProvider's boot method: +```php +use Lunarstorm\LaravelDDD\Facades\DDD; +use Lunarstorm\LaravelDDD\ValueObjects\CommandContext; +use Lunarstorm\LaravelDDD\ValueObjects\ObjectSchema; + +DDD::resolveObjectSchemaUsing(function (string $domainName, string $nameInput, string $type, CommandContext $command): ?ObjectSchema { + if ($type === 'controller' && $command->option('api')) { + return new ObjectSchema( + name: $name = str($nameInput)->replaceEnd('Controller', '')->finish('ApiController')->toString(), + namespace: "App\\Api\\Controllers\\{$domainName}", + fullyQualifiedName: "App\\Api\\Controllers\\{$domainName}\\{$name}", + path: "src/App/Api/Controllers/{$domainName}/{$name}.php", + ); + } + + // Return null to fall back to the default + return null; +}); +``` +The example above will result in the following: ```bash -# Domain/Reporting/Internal/ViewModels/MonthlyInvoicesReportViewModel -php artisan ddd:view-model Reporting.Internal:MonthlyInvoicesReportViewModel +php artisan ddd:controller Invoicing:PaymentController --api +# Controller [src/App/Api/Controllers/Invoicing/PaymentApiController.php] created successfully. +``` -# Domain/Reporting/Customer/ViewModels/MonthlyInvoicesReportViewModel -php artisan ddd:view-model Reporting.Customer:MonthlyInvoicesReportViewModel + -# (supported by all commands where a domain option is accepted) +## Customizing Stubs +This package ships with a few ddd-specific stubs, while the rest are pulled from the framework. For a quick reference of available stubs and their source, you may use the `ddd:stub --list` command: +```bash +php artisan ddd:stub --list ``` -## Customization -This package ships with opinionated (but sensible) configuration defaults. You may customize by publishing the [config file](#config-file) and generator stubs as needed: +### Stub Priority +When generating objects using `ddd:*`, stubs are prioritized as follows: +- Try `stubs/ddd/*.stub` (customized for `ddd:*` only) +- Try `stubs/*.stub` (shared by both `make:*` and `ddd:*`) +- Fallback to the package or framework default +### Publishing Stubs +To publish stubs interactively, you may use the `ddd:stub` command: ```bash -php artisan vendor:publish --tag="ddd-config" -php artisan vendor:publish --tag="ddd-stubs" +php artisan ddd:stub +``` +``` + ┌ What do you want to do? ─────────────────────────────────────┐ + │ › ● Choose stubs to publish │ + │ ○ Publish all stubs │ + └──────────────────────────────────────────────────────────────┘ + + ┌ Which stub should be published? ─────────────────────────────┐ + │ policy │ + ├──────────────────────────────────────────────────────────────┤ + │ › ◼ policy.plain.stub │ + │ ◻ policy.stub │ + └────────────────────────────────────────────────── 1 selected ┘ + Use the space bar to select options. +``` +You may also use shortcuts to skip the interactive steps: +```bash +# Publish all stubs +php artisan ddd:stub --all + +# Publish one or more stubs specified as arguments (see ddd:stub --list) +php artisan ddd:stub model +php artisan ddd:stub model dto action +php artisan ddd:stub controller controller.plain controller.api + +# Options: + +# Publish and overwrite only the files that have already been published +php artisan ddd:stub ... --existing + +# Overwrite any existing files +php artisan ddd:stub ... --force +``` +To publish multiple stubs with common prefixes at once, use `*` or `.` as a wildcard ending to indicate "stubs that starts with": +```bash +php artisan ddd:stub listener. +``` +Output: +```bash +Publishing /stubs/ddd/listener.typed.queued.stub +Publishing /stubs/ddd/listener.queued.stub +Publishing /stubs/ddd/listener.typed.stub +Publishing /stubs/ddd/listener.stub ``` -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. @@ -194,6 +359,7 @@ Autoloading behaviour can be configured with the `ddd.autoload` configuration op 'commands' => true, 'policies' => true, 'factories' => true, + 'migrations' => true, ], ``` ### Service Providers @@ -210,14 +376,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 +410,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. @@ -258,38 +431,56 @@ return [ /* |-------------------------------------------------------------------------- - | Domain Path + | Domain Layer |-------------------------------------------------------------------------- | - | The path to the domain folder relative to the application root. + | The path and namespace of the domain layer. | */ 'domain_path' => 'src/Domain', + 'domain_namespace' => 'Domain', /* |-------------------------------------------------------------------------- - | Domain Namespace + | Application Layer |-------------------------------------------------------------------------- | - | The root domain namespace. + | The path and namespace of the application layer, and the objects + | that should be recognized as part of the application layer. | */ - 'domain_namespace' => 'Domain', + 'application_path' => 'app/Modules', + 'application_namespace' => 'App\Modules', + 'application_objects' => [ + 'controller', + 'request', + 'middleware', + ], /* |-------------------------------------------------------------------------- - | Domain Object Namespaces + | Custom Layers |-------------------------------------------------------------------------- | - | This value contains the default namespaces of generated domain - | objects relative to the domain namespace of which the object - | belongs to. + | Additional top-level namespaces and paths that should be recognized as + | layers when generating ddd:* objects. | - | e.g., Domain\Invoicing\Models\* - | Domain\Invoicing\Data\* - | Domain\Invoicing\ViewModels\* - | Domain\Invoicing\ValueObjects\* - | Domain\Invoicing\Actions\* + | e.g., 'Infrastructure' => 'src/Infrastructure', + | + */ + 'layers' => [ + 'Infrastructure' => 'src/Infrastructure', + // 'Integrations' => 'src/Integrations', + // 'Support' => 'src/Support', + ], + + /* + |-------------------------------------------------------------------------- + | Object Namespaces + |-------------------------------------------------------------------------- + | + | This value contains the default namespaces of ddd:* generated + | objects relative to the layer of which the object belongs to. | */ 'namespaces' => [ @@ -302,6 +493,7 @@ return [ 'class' => '', 'channel' => 'Channels', 'command' => 'Commands', + 'controller' => 'Controllers', 'enum' => 'Enums', 'event' => 'Events', 'exception' => 'Exceptions', @@ -310,13 +502,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 +521,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 +569,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 +592,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/UPGRADING.md b/UPGRADING.md index b9177b4..f7f6b04 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,6 +1,21 @@ # Upgrading - - ## From 0.x to 1.x + +## From 1.1.x to 1.2.x +### Breaking +- Stubs are now published to `base_path('stubs/ddd')` instead of `resource_path('stubs/ddd')`. In other words, they are now co-located alongside the framework's published stubs, within a ddd subfolder. +- Published stubs now use `.stub` extension instead of `.php.stub` (following Laravel's convention). +- If you are using published stubs from pre 1.2, you will need to refactor your stubs accordingly. + +### Update Config +- Support for Application Layer and Custom Layers was added, introducing changes to the config file. +- Run `php artisan ddd:config update` to rebuild your application's published `ddd.php` config to align with the package's latest copy. +- The update utility will attempt to respect your existing customizations, but you should still review and verify manually. + +### Publishing Stubs +- Old way (removed): `php artisan vendor:publish --tag="ddd-stubs"` +- New way: `php artisan ddd:stub` (see [Customizing Stubs](README.md#customizing-stubs) in README for more details). + +## From 0.x to 1.x - Minimum required Laravel version is 10.25. - The ddd generator [command syntax](README.md#usage) in 1.x. Generator commands no longer receive a domain argument. For example, 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). diff --git a/composer.json b/composer.json index 2bfcb1b..882a0f7 100644 --- a/composer.json +++ b/composer.json @@ -20,20 +20,26 @@ "require": { "php": "^8.1|^8.2|^8.3", "illuminate/contracts": "^10.25|^11.0", - "laravel/prompts": "^0.1.16|^0.3.1", + "laravel/pint": "^1.18", + "laravel/prompts": "^0.1.16|^0.2|^0.3.1", "lorisleiva/lody": "^0.5.0", - "spatie/laravel-package-tools": "^1.13.0" + "spatie/laravel-package-tools": "^1.13.0", + "symfony/var-exporter": "^6|^7.1" }, "require-dev": { "larastan/larastan": "^2.0.1", - "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" + "phpstan/phpstan-phpunit": "^1.0", + "spatie/laravel-data": "^4.11.1" + }, + "suggest": { + "spatie/laravel-data": "Recommended for Data Transfer Objects.", + "lorisleiva/laravel-actions": "Recommended for Actions." }, "autoload": { "psr-4": { @@ -47,7 +53,9 @@ "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" + "Domain\\": "vendor/orchestra/testbench-core/laravel/src/Domain", + "Application\\": "vendor/orchestra/testbench-core/laravel/src/Application", + "Infrastructure\\": "vendor/orchestra/testbench-core/laravel/src/Infrastructure" } }, "scripts": { @@ -55,6 +63,7 @@ "analyse": "vendor/bin/phpstan analyse", "test": "@composer dump-autoload && vendor/bin/pest", "test-coverage": "@composer dump-autoload && vendor/bin/pest --coverage", + "purge-skeleton": "vendor/bin/testbench package:purge-skeleton", "format": "vendor/bin/pint", "lint": "vendor/bin/pint" }, @@ -71,7 +80,7 @@ "Lunarstorm\\LaravelDDD\\LaravelDDDServiceProvider" ], "aliases": { - "LaravelDDD": "Lunarstorm\\LaravelDDD\\Facades\\LaravelDDD" + "DDD": "Lunarstorm\\LaravelDDD\\Facades\\DDD" } } }, diff --git a/config/ddd.php b/config/ddd.php index 66d8901..20321d8 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -4,38 +4,56 @@ /* |-------------------------------------------------------------------------- - | Domain Path + | Domain Layer |-------------------------------------------------------------------------- | - | The path to the domain folder relative to the application root. + | The path and namespace of the domain layer. | */ 'domain_path' => 'src/Domain', + 'domain_namespace' => 'Domain', /* |-------------------------------------------------------------------------- - | Domain Namespace + | Application Layer |-------------------------------------------------------------------------- | - | The root domain namespace. + | The path and namespace of the application layer, and the objects + | that should be recognized as part of the application layer. | */ - 'domain_namespace' => 'Domain', + 'application_path' => 'app/Modules', + 'application_namespace' => 'App\Modules', + 'application_objects' => [ + 'controller', + 'request', + 'middleware', + ], /* |-------------------------------------------------------------------------- - | Domain Object Namespaces + | Custom Layers |-------------------------------------------------------------------------- | - | This value contains the default namespaces of generated domain - | objects relative to the domain namespace of which the object - | belongs to. + | Additional top-level namespaces and paths that should be recognized as + | layers when generating ddd:* objects. | - | e.g., Domain\Invoicing\Models\* - | Domain\Invoicing\Data\* - | Domain\Invoicing\ViewModels\* - | Domain\Invoicing\ValueObjects\* - | Domain\Invoicing\Actions\* + | e.g., 'Infrastructure' => 'src/Infrastructure', + | + */ + 'layers' => [ + 'Infrastructure' => 'src/Infrastructure', + // 'Integrations' => 'src/Integrations', + // 'Support' => 'src/Support', + ], + + /* + |-------------------------------------------------------------------------- + | Object Namespaces + |-------------------------------------------------------------------------- + | + | This value contains the default namespaces of ddd:* generated + | objects relative to the layer of which the object belongs to. | */ 'namespaces' => [ @@ -48,6 +66,7 @@ 'class' => '', 'channel' => 'Channels', 'command' => 'Commands', + 'controller' => 'Controllers', 'enum' => 'Enums', 'event' => 'Events', 'exception' => 'Exceptions', @@ -56,13 +75,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 +94,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 +142,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..911a351 100644 --- a/config/ddd.php.stub +++ b/config/ddd.php.stub @@ -4,67 +4,52 @@ return [ /* |-------------------------------------------------------------------------- - | Domain Path + | Domain Layer |-------------------------------------------------------------------------- | - | The path to the domain folder relative to the application root. + | The path and namespace of the domain layer. | */ 'domain_path' => {{domain_path}}, + 'domain_namespace' => {{domain_namespace}}, /* |-------------------------------------------------------------------------- - | Domain Namespace + | Application Layer |-------------------------------------------------------------------------- | - | The root domain namespace. + | The path and namespace of the application layer, and the objects + | that should be recognized as part of the application layer. | */ - 'domain_namespace' => {{domain_namespace}}, + 'application_path' => {{application_path}}, + 'application_namespace' => {{application_namespace}}, + 'application_objects' => {{application_objects}}, /* |-------------------------------------------------------------------------- - | Domain Object Namespaces + | Custom Layers |-------------------------------------------------------------------------- | - | This value contains the default namespaces of generated domain - | objects relative to the domain namespace of which the object - | belongs to. + | Additional top-level namespaces and paths that should be recognized as + | layers when generating ddd:* objects. | - | e.g., Domain\Invoicing\Models\* - | Domain\Invoicing\Data\* - | Domain\Invoicing\ViewModels\* - | Domain\Invoicing\ValueObjects\* - | Domain\Invoicing\Actions\* + | e.g., 'Infrastructure' => 'src/Infrastructure', | */ - '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', - 'class' => '', - 'channel' => 'Channels', - 'command' => 'Commands', - 'enum' => 'Enums', - 'event' => 'Events', - 'exception' => 'Exceptions', - 'factory' => 'Database\Factories', - 'interface' => '', - 'job' => 'Jobs', - 'listener' => 'Listeners', - 'mail' => 'Mail', - 'notification' => 'Notifications', - 'observer' => 'Observers', - 'policy' => 'Policies', - 'provider' => 'Providers', - 'resource' => 'Resources', - 'rule' => 'Rules', - 'scope' => 'Scopes', - 'trait' => '', - ], + 'layers' => {{layers}}, + + + /* + |-------------------------------------------------------------------------- + | Object Namespaces + |-------------------------------------------------------------------------- + | + | This value contains the default namespaces of ddd:* generated + | objects relative to the layer of which the object belongs to. + | + */ + 'namespaces' => {{namespaces}}, /* |-------------------------------------------------------------------------- @@ -124,31 +109,7 @@ return [ | 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, - ], + 'autoload' => {{autoload}}, /* |-------------------------------------------------------------------------- @@ -165,10 +126,7 @@ return [ | the AppServiceProvider's boot method. | */ - 'autoload_ignore' => [ - 'Tests', - 'Database/Migrations', - ], + 'autoload_ignore' => {{autoload_ignore}}, /* |-------------------------------------------------------------------------- @@ -179,5 +137,5 @@ return [ | autoloading. | */ - 'cache_directory' => 'bootstrap/cache/ddd', + 'cache_directory' => {{cache_directory}}, ]; diff --git a/routes/testing.php b/routes/testing.php new file mode 100644 index 0000000..667d8d9 --- /dev/null +++ b/routes/testing.php @@ -0,0 +1,24 @@ +middleware(['web']) + ->as('ddd.') + ->group(function () { + Route::get('/', function () { + return response('home'); + })->name('home'); + + Route::get('/config', function () { + return response(config('ddd')); + })->name('config'); + + Route::get('/autoload', function () { + return response([ + 'providers' => Autoload::getRegisteredProviders(), + 'commands' => Autoload::getRegisteredCommands(), + ]); + })->name('autoload'); + }); 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/CanPromptForDomain.php b/src/Commands/Concerns/CanPromptForDomain.php deleted file mode 100644 index f4d95e9..0000000 --- a/src/Commands/Concerns/CanPromptForDomain.php +++ /dev/null @@ -1,35 +0,0 @@ -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/ForwardsToDomainCommands.php b/src/Commands/Concerns/ForwardsToDomainCommands.php new file mode 100644 index 0000000..2b753f9 --- /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->blueprint->domain->dotName, + ], $this->output), + + 'make:model' => $this->runCommand('ddd:model', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->blueprint->domain->dotName, + ], $this->output), + + 'make:factory' => $this->runCommand('ddd:factory', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->blueprint->domain->dotName, + ], $this->output), + + 'make:policy' => $this->runCommand('ddd:policy', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->blueprint->domain->dotName, + ], $this->output), + + 'make:migration' => $this->runCommand('ddd:migration', [ + ...$arguments, + '--domain' => $this->blueprint->domain->dotName, + ], $this->output), + + 'make:seeder' => $this->runCommand('ddd:seeder', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->blueprint->domain->dotName, + ], $this->output), + + 'make:controller' => $this->runCommand('ddd:controller', [ + ...$arguments, + 'name' => $nameWithSubfolder, + '--domain' => $this->blueprint->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/HasDomainStubs.php b/src/Commands/Concerns/HasDomainStubs.php new file mode 100644 index 0000000..591511d --- /dev/null +++ b/src/Commands/Concerns/HasDomainStubs.php @@ -0,0 +1,70 @@ +resolvePublishedDddStub($stub)) { + $stub = $publishedStub; + } + + $this->usingPublishedStub(str($stub)->startsWith(app()->basePath('stubs'))); + + return $stub; + } + + protected function resolvePublishedDddStub($path) + { + $stubFilename = str($path) + ->basename() + ->ltrim('/\\') + ->toString(); + + // Check if there is a user-published stub + if (file_exists($publishedPath = app()->basePath('stubs/ddd/'.$stubFilename))) { + return $publishedPath; + } + + // Also check for legacy stub extensions + if (file_exists($legacyPublishedPath = Str::replaceLast('.stub', '.php.stub', $publishedPath))) { + return $legacyPublishedPath; + } + + return null; + } + + protected function resolveDddStubPath($path) + { + $path = str($path) + ->basename() + ->ltrim('/\\') + ->toString(); + + if ($publishedPath = $this->resolvePublishedDddStub($path)) { + return $publishedPath; + } + + return DDD::packagePath('stubs/'.$path); + } +} diff --git a/src/Commands/Concerns/HasGeneratorBlueprint.php b/src/Commands/Concerns/HasGeneratorBlueprint.php new file mode 100644 index 0000000..1cf2a36 --- /dev/null +++ b/src/Commands/Concerns/HasGeneratorBlueprint.php @@ -0,0 +1,10 @@ +preparePlaceholders(); + + foreach ($placeholders as $placeholder => $value) { + $stub = $this->fillPlaceholder($stub, $placeholder, $value ?? ''); + } + + return $stub; + } + + protected function buildClass($name) + { + return $this->applyPlaceholders(parent::buildClass($name)); + } +} diff --git a/src/Commands/Concerns/QualifiesDomainModels.php b/src/Commands/Concerns/QualifiesDomainModels.php new file mode 100644 index 0000000..13100ad --- /dev/null +++ b/src/Commands/Concerns/QualifiesDomainModels.php @@ -0,0 +1,17 @@ +blueprint->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..efe35a0 100644 --- a/src/Commands/Concerns/ResolvesDomainFromInput.php +++ b/src/Commands/Concerns/ResolvesDomainFromInput.php @@ -5,17 +5,19 @@ use Illuminate\Support\Str; use Lunarstorm\LaravelDDD\Support\Domain; use Lunarstorm\LaravelDDD\Support\DomainResolver; -use Lunarstorm\LaravelDDD\Support\Path; +use Lunarstorm\LaravelDDD\Support\GeneratorBlueprint; use Symfony\Component\Console\Input\InputOption; +use function Laravel\Prompts\suggest; + trait ResolvesDomainFromInput { - use CanPromptForDomain; + use HandleHooks, + HasGeneratorBlueprint, + QualifiesDomainModels; protected $nameIsAbsolute = false; - protected ?Domain $domain = null; - protected function getOptions() { return [ @@ -26,47 +28,53 @@ protected function getOptions() protected function rootNamespace() { - return Str::finish(DomainResolver::domainRootNamespace(), '\\'); + return $this->blueprint->rootNamespace(); } - protected function guessObjectType(): string + protected function getDefaultNamespace($rootNamespace) { - return match ($this->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(), - }; + return $this->blueprint + ? $this->blueprint->getDefaultNamespace($rootNamespace) + : parent::getDefaultNamespace($rootNamespace); } - protected function getDefaultNamespace($rootNamespace) + protected function getPath($name) { - if ($this->domain) { - return $this->nameIsAbsolute - ? $this->domain->namespace->root - : $this->domain->namespaceFor($this->guessObjectType()); - } + return $this->blueprint + ? $this->blueprint->getPath($name) + : parent::getPath($name); + } - return parent::getDefaultNamespace($rootNamespace); + protected function qualifyClass($name) + { + return $this->blueprint->qualifyClass($name); } - protected function getPath($name) + protected function promptForDomainName(): string { - if ($this->domain) { - return Path::normalize($this->laravel->basePath( - $this->domain->object( - type: $this->guessObjectType(), - name: $name, - absolute: $this->nameIsAbsolute - )->path - )); + $choices = collect(DomainResolver::domainChoices()) + ->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 parent::getPath($name); + return $domainName; } - public function handle() + protected function beforeHandle() { $nameInput = $this->getNameInput(); @@ -79,33 +87,26 @@ public function handle() $nameInput = Str::after($nameInput, ':'); } - $this->domain = match (true) { + $domainName = match (true) { // Domain was specified explicitly via option (priority) - filled($this->option('domain')) => new Domain($this->option('domain')), + filled($this->option('domain')) => $this->option('domain'), // Domain was specified as a prefix in the name - filled($domainExtractedFromName) => new Domain($domainExtractedFromName), + filled($domainExtractedFromName) => $domainExtractedFromName, - default => null, + default => $this->promptForDomainName(), }; - // If the domain is not set, prompt for it - if (! $this->domain) { - $this->domain = new Domain($this->promptForDomainName()); - } - - // Now that the domain part is handled, - // we will deal with the name portion. - - // Normalize slash and dot separators - $nameInput = Str::replace(['.', '\\', '/'], '/', $nameInput); - - if ($this->nameIsAbsolute = Str::startsWith($nameInput, ['/'])) { - // $nameInput = Str::after($nameInput, '/'); - } + $this->blueprint = new GeneratorBlueprint( + commandName: $this->getName(), + nameInput: $nameInput, + domainName: $domainName, + arguments: $this->arguments(), + options: $this->options(), + ); - $this->input->setArgument('name', $nameInput); + $this->input->setArgument('name', $this->blueprint->nameInput); - parent::handle(); + $this->input->setOption('domain', $this->blueprint->domainName); } } diff --git a/src/Commands/Concerns/UpdatesComposer.php b/src/Commands/Concerns/UpdatesComposer.php new file mode 100644 index 0000000..6f094de --- /dev/null +++ b/src/Commands/Concerns/UpdatesComposer.php @@ -0,0 +1,54 @@ +rtrim('/\\') + ->finish('\\') + ->toString(); + + $this->comment("Registering `{$namespace}`:`{$path}` in composer.json..."); + + $this->fillComposerValue(['autoload', 'psr-4', $namespace], $path); + + $this->composerReload(); + + return $this; + } + + protected function composerReload() + { + $composer = $this->hasOption('composer') ? $this->option('composer') : 'global'; + + if ($composer !== 'global') { + $command = ['php', $composer, 'dump-autoload']; + } else { + $command = ['composer', 'dump-autoload']; + } + + (new Process($command, base_path(), ['COMPOSER_MEMORY_LIMIT' => '-1'])) + ->setTimeout(null) + ->run(function ($type, $output) { + $this->output->write($output); + }); + + return $this; + } +} diff --git a/src/Commands/ConfigCommand.php b/src/Commands/ConfigCommand.php new file mode 100644 index 0000000..e4acee0 --- /dev/null +++ b/src/Commands/ConfigCommand.php @@ -0,0 +1,392 @@ +composer = DDD::composer()->usingOutput($this->output); + + $action = str($this->argument('action'))->trim()->lower()->toString(); + + if (! $action && $this->option('layer')) { + $action = 'layers'; + } + + return match ($action) { + 'wizard' => $this->wizard(), + 'update' => $this->update(), + 'detect' => $this->detect(), + 'composer' => $this->syncComposer(), + 'layers' => $this->layers(), + default => $this->home(), + }; + } + + protected function home(): int + { + $action = select('Laravel-DDD Config Utility', [ + 'wizard' => 'Run the configuration wizard', + 'update' => 'Update and merge ddd.php with latest package version', + 'detect' => 'Detect domain namespace from composer.json', + 'composer' => 'Sync composer.json from ddd.php', + 'exit' => 'Exit', + ], scroll: 10); + + return match ($action) { + 'wizard' => $this->wizard(), + 'update' => $this->update(), + 'detect' => $this->detect(), + 'composer' => $this->syncComposer(), + 'exit' => $this->exit(), + default => $this->exit(), + }; + } + + protected function layers() + { + $layers = $this->option('layer'); + + if ($layers = $this->option('layer')) { + foreach ($layers as $layer) { + $parts = explode(':', $layer); + + $this->composer->registerPsr4Autoload( + namespace: data_get($parts, 0), + path: data_get($parts, 1) + ); + } + + $this->composer->saveAndReload(); + } + + $this->info('Configuration updated.'); + + return self::SUCCESS; + } + + public static function hasRequiredVersionOfLaravelPrompts(): bool + { + return function_exists('\Laravel\Prompts\form') + && method_exists(\Laravel\Prompts\FormBuilder::class, 'addIf'); + } + + protected function wizard(): int + { + if (! static::hasRequiredVersionOfLaravelPrompts()) { + $this->error('This command is not supported with your currently installed version of Laravel Prompts.'); + + return self::FAILURE; + } + + $namespaces = collect($this->composer->getPsr4Namespaces()); + + $layers = $namespaces->map(fn ($path, $namespace) => new Layer($namespace, $path)); + $laravelAppLayer = $layers->first(fn (Layer $layer) => str($layer->namespace)->exactly('App')); + $possibleDomainLayers = $layers->filter(fn (Layer $layer) => str($layer->namespace)->startsWith('Domain')); + $possibleApplicationLayers = $layers->filter(fn (Layer $layer) => str($layer->namespace)->startsWith('App')); + + $domainLayer = $possibleDomainLayers->first(); + $applicationLayer = $possibleApplicationLayers->first(); + + $detected = collect([ + 'domain_path' => $domainLayer?->path, + 'domain_namespace' => $domainLayer?->namespace, + 'application_path' => $applicationLayer?->path, + 'application_namespace' => $applicationLayer?->namespace, + ]); + + $config = $detected->merge(Config::get('ddd')); + + info('Detected DDD configuration:'); + + table( + headers: ['Key', 'Value'], + rows: $detected->dot()->map(fn ($value, $key) => [$key, $value])->all() + ); + + $choices = [ + 'domain_path' => [ + 'src/Domain' => 'src/Domain', + 'src/Domains' => 'src/Domains', + ...[ + $config->get('domain_path') => $config->get('domain_path'), + ], + ...$possibleDomainLayers->mapWithKeys( + fn (Layer $layer) => [$layer->path => $layer->path] + ), + ], + 'domain_namespace' => [ + 'Domain' => 'Domain', + 'Domains' => 'Domains', + ...[ + $config->get('domain_namespace') => $config->get('domain_namespace'), + ], + ...$possibleDomainLayers->mapWithKeys( + fn (Layer $layer) => [$layer->namespace => $layer->namespace] + ), + ], + 'application_path' => [ + 'app/Modules' => 'app/Modules', + 'src/Modules' => 'src/Modules', + 'Modules' => 'Modules', + 'src/Application' => 'src/Application', + 'Application' => 'Application', + ...[ + data_get($config, 'application_path') => data_get($config, 'application_path'), + ], + ...$possibleApplicationLayers->mapWithKeys( + fn (Layer $layer) => [$layer->path => $layer->path] + ), + ], + 'application_namespace' => [ + 'App\Modules' => 'App\Modules', + 'Application' => 'Application', + 'Modules' => 'Modules', + ...[ + data_get($config, 'application_namespace') => data_get($config, 'application_namespace'), + ], + ...$possibleApplicationLayers->mapWithKeys( + fn (Layer $layer) => [$layer->namespace => $layer->namespace] + ), + ], + 'layers' => [ + 'src/Infrastructure' => 'src/Infrastructure', + 'src/Integrations' => 'src/Integrations', + 'src/Support' => 'src/Support', + ], + ]; + + $form = form() + ->add( + function ($responses) use ($choices, $detected, $config) { + return suggest( + label: 'Domain Path', + options: $choices['domain_path'], + default: $detected->get('domain_path') ?: $config->get('domain_path'), + hint: 'The path to the domain layer relative to the base path.', + required: true, + ); + }, + name: 'domain_path' + ) + ->add( + function ($responses) use ($choices, $config) { + return suggest( + label: 'Domain Namespace', + options: $choices['domain_namespace'], + default: class_basename($responses['domain_path']) ?: $config->get('domain_namespace'), + required: true, + hint: 'The root domain namespace.', + ); + }, + name: 'domain_namespace' + ) + ->add( + function ($responses) use ($choices) { + return suggest( + label: 'Path to Application Layer', + options: $choices['application_path'], + hint: "For objects that don't belong in the domain layer (controllers, form requests, etc.)", + placeholder: 'Leave blank to skip and use defaults', + scroll: 10, + ); + }, + name: 'application_path' + ) + ->addIf( + fn ($responses) => filled($responses['application_path']), + function ($responses) use ($choices, $laravelAppLayer) { + $applicationPath = $responses['application_path']; + $laravelAppPath = $laravelAppLayer->path; + + $namespace = match (true) { + str($applicationPath)->exactly($laravelAppPath) => $laravelAppLayer->namespace, + str($applicationPath)->startsWith("{$laravelAppPath}/") => str($applicationPath)->studly()->toString(), + default => str($applicationPath)->classBasename()->studly()->toString(), + }; + + return suggest( + label: 'Application Layer Namespace', + options: $choices['application_namespace'], + default: $namespace, + hint: 'The root application namespace.', + placeholder: 'Leave blank to use defaults', + ); + }, + name: 'application_namespace' + ) + ->add( + function ($responses) use ($choices) { + return multiselect( + label: 'Custom Layers (Optional)', + options: $choices['layers'], + hint: 'Layers can be customized in the ddd.php config file at any time.', + ); + }, + name: 'layers' + ); + + $responses = $form->submit(); + + $this->info('Building configuration...'); + + foreach ($responses as $key => $value) { + $responses[$key] = $value ?: $config->get($key); + } + + DDD::config()->fill($responses)->save(); + + $this->info('Configuration updated: '.config_path('ddd.php')); + + return self::SUCCESS; + } + + protected function detect(): int + { + $search = ['Domain', 'Domains']; + + $detected = []; + + foreach ($search as $namespace) { + if ($path = $this->composer->getAutoloadPath($namespace)) { + $detected['domain_path'] = $path; + $detected['domain_namespace'] = $namespace; + break; + } + } + + $this->info('Detected configuration:'); + + table( + headers: ['Config', 'Value'], + rows: collect($detected) + ->map(fn ($value, $key) => [$key, $value]) + ->all() + ); + + if (confirm('Update configuration with these values?', true)) { + DDD::config()->fill($detected)->save(); + + $this->info('Configuration updated: '.config_path('ddd.php')); + } + + return self::SUCCESS; + } + + protected function update(): int + { + $config = DDD::config(); + + $confirmed = confirm('Are you sure you want to update ddd.php and merge with latest copy from the package?'); + + if (! $confirmed) { + $this->info('Configuration update aborted.'); + + return self::SUCCESS; + } + + $this->info('Merging ddd.php...'); + + $config->syncWithLatest()->save(); + + $this->info('Configuration updated: '.config_path('ddd.php')); + $this->warn('Note: Some values may require manual adjustment.'); + + return self::SUCCESS; + } + + protected function syncComposer(): int + { + $namespaces = [ + config('ddd.domain_namespace', 'Domain') => config('ddd.domain_path', 'src/Domain'), + config('ddd.application_namespace', 'App\\Modules') => config('ddd.application_path', 'app/Modules'), + ...collect(config('ddd.layers', [])) + ->all(), + ]; + + $this->info('Syncing composer.json from ddd.php...'); + + $results = []; + + $added = 0; + + foreach ($namespaces as $namespace => $path) { + if ($this->composer->hasPsr4Autoload($namespace)) { + $results[] = [$namespace, $path, 'Already Registered']; + + continue; + } + + $rootNamespace = Str::before($namespace, '\\'); + + if ($this->composer->hasPsr4Autoload($rootNamespace)) { + $results[] = [$namespace, $path, 'Skipped']; + + continue; + } + + $this->composer->registerPsr4Autoload($rootNamespace, $path); + + $results[] = [$namespace, $path, 'Added']; + + $added++; + } + + if ($added > 0) { + $this->composer->saveAndReload(); + } + + table( + headers: ['Namespace', 'Path', 'Status'], + rows: $results + ); + + return self::SUCCESS; + } + + protected function exit(): int + { + $this->info('Goodbye!'); + + return self::SUCCESS; + } +} diff --git a/src/Commands/DomainActionMakeCommand.php b/src/Commands/DomainActionMakeCommand.php index c1a14f2..3218457 100644 --- a/src/Commands/DomainActionMakeCommand.php +++ b/src/Commands/DomainActionMakeCommand.php @@ -2,8 +2,12 @@ namespace Lunarstorm\LaravelDDD\Commands; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; + class DomainActionMakeCommand extends DomainGeneratorCommand { + use HasDomainStubs; + protected $name = 'ddd:action'; /** @@ -17,7 +21,7 @@ class DomainActionMakeCommand extends DomainGeneratorCommand protected function getStub() { - return $this->resolveStubPath('action.php.stub'); + return $this->resolveDddStubPath('action.stub'); } protected function preparePlaceholders(): array diff --git a/src/Commands/DomainBaseModelMakeCommand.php b/src/Commands/DomainBaseModelMakeCommand.php index 559cd59..1dac48f 100644 --- a/src/Commands/DomainBaseModelMakeCommand.php +++ b/src/Commands/DomainBaseModelMakeCommand.php @@ -2,10 +2,13 @@ namespace Lunarstorm\LaravelDDD\Commands; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Symfony\Component\Console\Input\InputArgument; class DomainBaseModelMakeCommand extends DomainGeneratorCommand { + use HasDomainStubs; + protected $name = 'ddd:base-model'; /** @@ -31,7 +34,7 @@ protected function getArguments() protected function getStub() { - return $this->resolveStubPath('base-model.php.stub'); + return $this->resolveDddStubPath('base-model.stub'); } protected function getRelativeDomainNamespace(): string diff --git a/src/Commands/DomainBaseViewModelMakeCommand.php b/src/Commands/DomainBaseViewModelMakeCommand.php index afac4f7..3436b12 100644 --- a/src/Commands/DomainBaseViewModelMakeCommand.php +++ b/src/Commands/DomainBaseViewModelMakeCommand.php @@ -2,10 +2,13 @@ namespace Lunarstorm\LaravelDDD\Commands; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Symfony\Component\Console\Input\InputArgument; class DomainBaseViewModelMakeCommand extends DomainGeneratorCommand { + use HasDomainStubs; + protected $name = 'ddd:base-view-model'; /** @@ -31,7 +34,7 @@ protected function getArguments() protected function getStub() { - return $this->resolveStubPath('base-view-model.php.stub'); + return $this->resolveDddStubPath('base-view-model.stub'); } protected function getRelativeDomainNamespace(): string diff --git a/src/Commands/DomainCastMakeCommand.php b/src/Commands/DomainCastMakeCommand.php index 30531e4..da230a0 100644 --- a/src/Commands/DomainCastMakeCommand.php +++ b/src/Commands/DomainCastMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\CastMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainCastMakeCommand extends CastMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:cast'; } diff --git a/src/Commands/DomainChannelMakeCommand.php b/src/Commands/DomainChannelMakeCommand.php index f3e5ba5..3bee9b9 100644 --- a/src/Commands/DomainChannelMakeCommand.php +++ b/src/Commands/DomainChannelMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ChannelMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainChannelMakeCommand extends ChannelMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:channel'; } diff --git a/src/Commands/DomainClassMakeCommand.php b/src/Commands/DomainClassMakeCommand.php index 242788b..a657407 100644 --- a/src/Commands/DomainClassMakeCommand.php +++ b/src/Commands/DomainClassMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ClassMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainClassMakeCommand extends ClassMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:class'; } diff --git a/src/Commands/DomainConsoleMakeCommand.php b/src/Commands/DomainConsoleMakeCommand.php index 6496db9..f78cd39 100644 --- a/src/Commands/DomainConsoleMakeCommand.php +++ b/src/Commands/DomainConsoleMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ConsoleMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainConsoleMakeCommand extends ConsoleMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:command'; } diff --git a/src/Commands/DomainControllerMakeCommand.php b/src/Commands/DomainControllerMakeCommand.php new file mode 100644 index 0000000..4271a2d --- /dev/null +++ b/src/Commands/DomainControllerMakeCommand.php @@ -0,0 +1,102 @@ +option('requests')) { + $namespace = $this->blueprint->getNamespaceFor('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, + ]); + } + + protected function buildClass($name) + { + $stub = parent::buildClass($name); + + if ($this->isUsingPublishedStub()) { + return $stub; + } + + // Handle Laravel 10 side effect + if (str($stub)->contains($invalidUse = "use {$this->getNamespace($name)}\Http\Controllers\Controller;\n")) { + $laravel10Replacements = [ + ' extends Controller' => '', + $invalidUse => '', + ]; + + $stub = str_replace( + array_keys($laravel10Replacements), + array_values($laravel10Replacements), + $stub + ); + } + + $replace = []; + + $appRootNamespace = $this->laravel->getNamespace(); + $pathToAppBaseController = Path::normalize(app()->path('Http/Controllers/Controller.php')); + + $baseControllerExists = $this->files->exists($pathToAppBaseController); + + if ($baseControllerExists) { + $controllerClass = class_basename($name); + $fullyQualifiedBaseController = "{$appRootNamespace}Http\Controllers\Controller"; + $namespaceLine = "namespace {$this->getNamespace($name)};"; + $replace["{$namespaceLine}\n"] = "{$namespaceLine}\n\nuse {$fullyQualifiedBaseController};"; + $replace["class {$controllerClass}\n"] = "class {$controllerClass} extends Controller\n"; + } + + $stub = str_replace( + array_keys($replace), + array_values($replace), + $stub + ); + + return $this->sortImports($stub); + } +} diff --git a/src/Commands/DomainDtoMakeCommand.php b/src/Commands/DomainDtoMakeCommand.php index ac4ef3f..b8612cf 100644 --- a/src/Commands/DomainDtoMakeCommand.php +++ b/src/Commands/DomainDtoMakeCommand.php @@ -2,8 +2,12 @@ namespace Lunarstorm\LaravelDDD\Commands; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; + class DomainDtoMakeCommand extends DomainGeneratorCommand { + use HasDomainStubs; + protected $name = 'ddd:dto'; /** @@ -28,7 +32,7 @@ protected function configure() protected function getStub() { - return $this->resolveStubPath('dto.php.stub'); + return $this->resolveDddStubPath('dto.stub'); } protected function getRelativeDomainNamespace(): string diff --git a/src/Commands/DomainEnumMakeCommand.php b/src/Commands/DomainEnumMakeCommand.php index d3110b7..3348186 100644 --- a/src/Commands/DomainEnumMakeCommand.php +++ b/src/Commands/DomainEnumMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\EnumMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainEnumMakeCommand extends EnumMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:enum'; } diff --git a/src/Commands/DomainEventMakeCommand.php b/src/Commands/DomainEventMakeCommand.php index de4a11b..fcba974 100644 --- a/src/Commands/DomainEventMakeCommand.php +++ b/src/Commands/DomainEventMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\EventMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainEventMakeCommand extends EventMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:event'; } diff --git a/src/Commands/DomainExceptionMakeCommand.php b/src/Commands/DomainExceptionMakeCommand.php index c9871e7..f794dc1 100644 --- a/src/Commands/DomainExceptionMakeCommand.php +++ b/src/Commands/DomainExceptionMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ExceptionMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainExceptionMakeCommand extends ExceptionMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:exception'; } diff --git a/src/Commands/DomainFactoryMakeCommand.php b/src/Commands/DomainFactoryMakeCommand.php index 6a1a104..646d036 100644 --- a/src/Commands/DomainFactoryMakeCommand.php +++ b/src/Commands/DomainFactoryMakeCommand.php @@ -2,55 +2,32 @@ namespace Lunarstorm\LaravelDDD\Commands; -use Symfony\Component\Console\Input\InputOption; +use Illuminate\Database\Console\Factories\FactoryMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; +use Lunarstorm\LaravelDDD\Commands\Concerns\InteractsWithStubs; +use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; -class DomainFactoryMakeCommand extends DomainGeneratorCommand +class DomainFactoryMakeCommand extends FactoryMakeCommand { - protected $name = 'ddd:factory'; - - /** - * The console command description. - * - * @var string - */ - protected $description = 'Generate a domain model factory'; + use HasDomainStubs, + InteractsWithStubs, + ResolvesDomainFromInput; - protected $type = 'Factory'; - - protected function getOptions() - { - return [ - ...parent::getOptions(), - ['model', 'm', InputOption::VALUE_OPTIONAL, 'The name of the model'], - ]; - } + protected $name = 'ddd:factory'; protected function getStub() { - return $this->resolveStubPath('factory.php.stub'); + return $this->resolveDddStubPath('factory.stub'); } - protected function getPath($name) + protected function getNamespace($name) { - if (! str_ends_with($name, 'Factory')) { - $name .= 'Factory'; - } - - return parent::getPath($name); - } - - protected function getFactoryName() - { - $name = $this->getNameInput(); - - return str_ends_with($name, 'Factory') - ? substr($name, 0, -7) - : $name; + return $this->blueprint->getNamespaceFor('factory'); } protected function preparePlaceholders(): array { - $domain = $this->domain; + $domain = $this->blueprint->domain; $name = $this->getNameInput(); @@ -60,16 +37,10 @@ protected function preparePlaceholders(): array $domainFactory = $domain->factory($name); - // dump('preparing placeholders', [ - // 'name' => $name, - // 'modelName' => $modelName, - // 'domainFactory' => $domainFactory, - // ]); - return [ 'namespacedModel' => $domainModel->fullyQualifiedName, 'model' => class_basename($domainModel->fullyQualifiedName), - 'factory' => $this->getFactoryName(), + 'factory' => $domainFactory->name, 'namespace' => $domainFactory->namespace, ]; } @@ -80,6 +51,6 @@ protected function guessModelName($name) $name = substr($name, 0, -7); } - return $this->domain->model($name)->name; + return $this->blueprint->domain->model(class_basename($name))->name; } } diff --git a/src/Commands/DomainGeneratorCommand.php b/src/Commands/DomainGeneratorCommand.php index 6421409..9d5dcd2 100644 --- a/src/Commands/DomainGeneratorCommand.php +++ b/src/Commands/DomainGeneratorCommand.php @@ -4,57 +4,22 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; +use Lunarstorm\LaravelDDD\Commands\Concerns\InteractsWithStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; use Lunarstorm\LaravelDDD\Support\DomainResolver; abstract class DomainGeneratorCommand extends GeneratorCommand { - use ResolvesDomainFromInput; + use InteractsWithStubs, + ResolvesDomainFromInput; protected function getRelativeDomainNamespace(): string { - return DomainResolver::getRelativeObjectNamespace($this->guessObjectType()); + return DomainResolver::getRelativeObjectNamespace($this->blueprint->type); } protected function getNameInput() { return Str::studly($this->argument('name')); } - - protected function resolveStubPath($path) - { - $path = ltrim($path, '/\\'); - - $publishedPath = resource_path('stubs/ddd/'.$path); - - return file_exists($publishedPath) - ? $publishedPath - : __DIR__.DIRECTORY_SEPARATOR.'../../stubs'.DIRECTORY_SEPARATOR.$path; - } - - protected function fillPlaceholder($stub, $placeholder, $value) - { - return str_replace(["{{$placeholder}}", "{{ $placeholder }}"], $value, $stub); - } - - protected function preparePlaceholders(): array - { - return []; - } - - protected function applyPlaceholders($stub) - { - $placeholders = $this->preparePlaceholders(); - - foreach ($placeholders as $placeholder => $value) { - $stub = $this->fillPlaceholder($stub, $placeholder, $value ?? ''); - } - - return $stub; - } - - protected function buildClass($name) - { - return $this->applyPlaceholders(parent::buildClass($name)); - } } diff --git a/src/Commands/DomainInterfaceMakeCommand.php b/src/Commands/DomainInterfaceMakeCommand.php index 3fbccef..82735d6 100644 --- a/src/Commands/DomainInterfaceMakeCommand.php +++ b/src/Commands/DomainInterfaceMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\InterfaceMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainInterfaceMakeCommand extends InterfaceMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:interface'; } diff --git a/src/Commands/DomainJobMakeCommand.php b/src/Commands/DomainJobMakeCommand.php index 7fc5e37..fd9afbd 100644 --- a/src/Commands/DomainJobMakeCommand.php +++ b/src/Commands/DomainJobMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\JobMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainJobMakeCommand extends JobMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:job'; } diff --git a/src/Commands/DomainListCommand.php b/src/Commands/DomainListCommand.php index 2cd3a2f..1014b8a 100644 --- a/src/Commands/DomainListCommand.php +++ b/src/Commands/DomainListCommand.php @@ -7,6 +7,8 @@ use Lunarstorm\LaravelDDD\Support\DomainResolver; use Lunarstorm\LaravelDDD\Support\Path; +use function Laravel\Prompts\table; + class DomainListCommand extends Command { protected $name = 'ddd:list'; @@ -23,13 +25,13 @@ public function handle() return [ $domain->domain, - $domain->namespace->root, - Path::normalize($domain->path), + $domain->layer->namespace, + Path::normalize($domain->layer->path), ]; }) ->toArray(); - $this->table($headings, $table); + table($headings, $table); $countDomains = count($table); diff --git a/src/Commands/DomainListenerMakeCommand.php b/src/Commands/DomainListenerMakeCommand.php index 9726d20..0072172 100644 --- a/src/Commands/DomainListenerMakeCommand.php +++ b/src/Commands/DomainListenerMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ListenerMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainListenerMakeCommand extends ListenerMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:listener'; } diff --git a/src/Commands/DomainMailMakeCommand.php b/src/Commands/DomainMailMakeCommand.php index 15f9508..ef6d0e8 100644 --- a/src/Commands/DomainMailMakeCommand.php +++ b/src/Commands/DomainMailMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\MailMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainMailMakeCommand extends MailMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:mail'; } diff --git a/src/Commands/DomainMiddlewareMakeCommand.php b/src/Commands/DomainMiddlewareMakeCommand.php new file mode 100644 index 0000000..9a2a041 --- /dev/null +++ b/src/Commands/DomainMiddlewareMakeCommand.php @@ -0,0 +1,15 @@ +argument('name')); } - protected function getStub() + public function handle() { - return $this->resolveStubPath('model.php.stub'); + $this->beforeHandle(); + + $this->createBaseModelIfNeeded(); + + parent::handle(); + + $this->afterHandle(); } - protected function preparePlaceholders(): array + protected function buildFactoryReplacements() { - $baseClass = config('ddd.base_model'); - $baseClassName = class_basename($baseClass); + $replacements = parent::buildFactoryReplacements(); - return [ - 'extends' => filled($baseClass) ? " extends {$baseClassName}" : '', - 'baseClassImport' => filled($baseClass) ? "use {$baseClass};" : '', - ]; + if ($this->option('factory')) { + $factoryNamespace = Str::start($this->blueprint->getFactoryFor($this->getNameInput())->fullyQualifiedName, '\\'); + + $factoryCode = << */ + use HasFactory; + EOT; + + $replacements['{{ factory }}'] = $factoryCode; + $replacements['{{ factoryImport }}'] = 'use Lunarstorm\LaravelDDD\Factories\HasDomainFactory as HasFactory;'; + } + + return $replacements; } - public function handle() + protected function buildClass($name) { - $this->createBaseModelIfNeeded(); + $stub = parent::buildClass($name); - parent::handle(); + if ($this->isUsingPublishedStub()) { + return $stub; + } - if ($this->option('factory')) { - $this->createFactory(); + $replace = []; + + if ($baseModel = $this->getBaseModel()) { + $baseModelClass = class_basename($baseModel); + + $replace = array_merge($replace, [ + 'extends Model' => "extends {$baseModelClass}", + 'use Illuminate\Database\Eloquent\Model;' => "use {$baseModel};", + ]); } + + $stub = str_replace( + array_keys($replace), + array_values($replace), + $stub + ); + + return $this->sortImports($stub); } protected function createBaseModelIfNeeded() { - if (! $this->shouldCreateModel()) { + if (! $this->shouldCreateBaseModel()) { return; } @@ -66,7 +92,10 @@ protected function createBaseModelIfNeeded() $domain = DomainResolver::guessDomainFromClass($baseModel); - $name = Str::after($baseModel, $domain); + $name = str($baseModel) + ->after($domain) + ->replace(['\\', '/'], '/') + ->toString(); $this->call(DomainBaseModelMakeCommand::class, [ '--domain' => $domain, @@ -74,10 +103,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 +134,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/DomainNotificationMakeCommand.php b/src/Commands/DomainNotificationMakeCommand.php index 04de8ab..099cd9a 100644 --- a/src/Commands/DomainNotificationMakeCommand.php +++ b/src/Commands/DomainNotificationMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\NotificationMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainNotificationMakeCommand extends NotificationMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:notification'; } diff --git a/src/Commands/DomainObserverMakeCommand.php b/src/Commands/DomainObserverMakeCommand.php index 9668230..9351b53 100644 --- a/src/Commands/DomainObserverMakeCommand.php +++ b/src/Commands/DomainObserverMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ObserverMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainObserverMakeCommand extends ObserverMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:observer'; } diff --git a/src/Commands/DomainPolicyMakeCommand.php b/src/Commands/DomainPolicyMakeCommand.php index bbf57f3..180223f 100644 --- a/src/Commands/DomainPolicyMakeCommand.php +++ b/src/Commands/DomainPolicyMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\PolicyMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainPolicyMakeCommand extends PolicyMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:policy'; } diff --git a/src/Commands/DomainProviderMakeCommand.php b/src/Commands/DomainProviderMakeCommand.php index dcc2c8a..8ee4dcc 100644 --- a/src/Commands/DomainProviderMakeCommand.php +++ b/src/Commands/DomainProviderMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ProviderMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainProviderMakeCommand extends ProviderMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:provider'; } diff --git a/src/Commands/DomainRequestMakeCommand.php b/src/Commands/DomainRequestMakeCommand.php new file mode 100644 index 0000000..30f7b88 --- /dev/null +++ b/src/Commands/DomainRequestMakeCommand.php @@ -0,0 +1,22 @@ +blueprint->type), '\\'); + } +} diff --git a/src/Commands/DomainResourceMakeCommand.php b/src/Commands/DomainResourceMakeCommand.php index 36b3715..4e79208 100644 --- a/src/Commands/DomainResourceMakeCommand.php +++ b/src/Commands/DomainResourceMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ResourceMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainResourceMakeCommand extends ResourceMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:resource'; } diff --git a/src/Commands/DomainRuleMakeCommand.php b/src/Commands/DomainRuleMakeCommand.php index 50c6083..f82aec1 100644 --- a/src/Commands/DomainRuleMakeCommand.php +++ b/src/Commands/DomainRuleMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\RuleMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainRuleMakeCommand extends RuleMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:rule'; } diff --git a/src/Commands/DomainScopeMakeCommand.php b/src/Commands/DomainScopeMakeCommand.php index 9dfe7d7..43e2fd0 100644 --- a/src/Commands/DomainScopeMakeCommand.php +++ b/src/Commands/DomainScopeMakeCommand.php @@ -3,11 +3,13 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Foundation\Console\ScopeMakeCommand; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Commands\Concerns\ResolvesDomainFromInput; class DomainScopeMakeCommand extends ScopeMakeCommand { - use ResolvesDomainFromInput; + use HasDomainStubs, + ResolvesDomainFromInput; protected $name = 'ddd:scope'; } diff --git a/src/Commands/DomainSeederMakeCommand.php b/src/Commands/DomainSeederMakeCommand.php new file mode 100644 index 0000000..302a073 --- /dev/null +++ b/src/Commands/DomainSeederMakeCommand.php @@ -0,0 +1,15 @@ +resolveStubPath('value-object.php.stub'); + return $this->resolveDddStubPath('value-object.stub'); } } diff --git a/src/Commands/DomainViewModelMakeCommand.php b/src/Commands/DomainViewModelMakeCommand.php index e097cae..e75700b 100644 --- a/src/Commands/DomainViewModelMakeCommand.php +++ b/src/Commands/DomainViewModelMakeCommand.php @@ -2,11 +2,13 @@ namespace Lunarstorm\LaravelDDD\Commands; -use Illuminate\Support\Str; +use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Support\DomainResolver; class DomainViewModelMakeCommand extends DomainGeneratorCommand { + use HasDomainStubs; + protected $name = 'ddd:view-model'; /** @@ -29,7 +31,7 @@ protected function configure() protected function getStub() { - return $this->resolveStubPath('view-model.php.stub'); + return $this->resolveDddStubPath('view-model.stub'); } protected function preparePlaceholders(): array @@ -52,7 +54,10 @@ public function handle() $domain = DomainResolver::guessDomainFromClass($baseViewModel); - $name = Str::after($baseViewModel, $domain); + $name = str($baseViewModel) + ->after($domain) + ->replace(['\\', '/'], '/') + ->toString(); $this->call(DomainBaseViewModelMakeCommand::class, [ '--domain' => $domain, diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index 1ad031e..ef06bdb 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -3,8 +3,8 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Console\Command; -use Lunarstorm\LaravelDDD\Support\DomainResolver; -use Symfony\Component\Process\Process; + +use function Laravel\Prompts\confirm; class InstallCommand extends Command { @@ -14,58 +14,15 @@ class InstallCommand extends Command public function handle(): int { - $this->comment('Publishing config...'); - $this->call('vendor:publish', [ - '--tag' => 'ddd-config', - ]); - - $this->comment('Ensuring domain path is registered in composer.json...'); - $this->registerDomainAutoload(); + $this->call('ddd:publish', ['--config' => true]); - if ($this->confirm('Would you like to publish stubs?')) { - $this->comment('Publishing stubs...'); + $this->comment('Updating composer.json...'); + $this->callSilently('ddd:config', ['action' => 'composer']); - $this->callSilently('vendor:publish', [ - '--tag' => 'ddd-stubs', - ]); + if (confirm('Would you like to publish stubs now?', default: false, hint: 'You may do this at any time via ddd:stub')) { + $this->call('ddd:stub'); } return self::SUCCESS; } - - public function registerDomainAutoload() - { - $domainPath = DomainResolver::domainPath(); - - $domainRootNamespace = str(DomainResolver::domainRootNamespace()) - ->rtrim('/\\') - ->toString(); - - $this->comment("Registering domain path `{$domainPath}` in composer.json..."); - - $composerFile = base_path('composer.json'); - $data = json_decode(file_get_contents($composerFile), true); - data_fill($data, ['autoload', 'psr-4', $domainRootNamespace.'\\'], $domainPath); - - file_put_contents($composerFile, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); - - $this->composerReload(); - } - - protected function composerReload() - { - $composer = $this->option('composer'); - - if ($composer !== 'global') { - $command = ['php', $composer, 'dump-autoload']; - } else { - $command = ['composer', 'dump-autoload']; - } - - (new Process($command, base_path(), ['COMPOSER_MEMORY_LIMIT' => '-1'])) - ->setTimeout(null) - ->run(function ($type, $output) { - $this->output->write($output); - }); - } } diff --git a/src/Commands/Migration/BaseMigrateMakeCommand.php b/src/Commands/Migration/BaseMigrateMakeCommand.php new file mode 100644 index 0000000..de095eb --- /dev/null +++ b/src/Commands/Migration/BaseMigrateMakeCommand.php @@ -0,0 +1,42 @@ +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..4dcca4b --- /dev/null +++ b/src/Commands/Migration/DomainMigrateMakeCommand.php @@ -0,0 +1,27 @@ +blueprint) { + return $this->laravel->basePath($this->blueprint->getMigrationPath()); + } + + 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..58b2245 --- /dev/null +++ b/src/Commands/OptimizeCommand.php @@ -0,0 +1,32 @@ +setAliases([ + 'ddd:cache', + ]); + + parent::configure(); + } + + public function handle() + { + $this->components->info('Caching DDD providers, commands, migration paths.'); + $this->components->task('domain providers', fn () => Autoload::cacheProviders()); + $this->components->task('domain commands', fn () => Autoload::cacheCommands()); + $this->components->task('domain migration paths', fn () => DomainMigration::cachePaths()); + $this->newLine(); + } +} diff --git a/src/Commands/PublishCommand.php b/src/Commands/PublishCommand.php new file mode 100644 index 0000000..9180b84 --- /dev/null +++ b/src/Commands/PublishCommand.php @@ -0,0 +1,63 @@ + 'Stubs', + 'config' => 'Config File', + ]; + + return multiselect( + label: 'What should be published?', + options: $options, + required: true + ); + } + + public function handle(): int + { + $thingsToPublish = [ + ...$this->option('config') ? ['config'] : [], + ...$this->option('stubs') ? ['stubs'] : [], + ...$this->option('all') ? ['config', 'stubs'] : [], + ] ?: $this->askForThingsToPublish(); + + if (in_array('config', $thingsToPublish)) { + $this->comment('Publishing config...'); + $this->call('vendor:publish', [ + '--tag' => 'ddd-config', + ]); + } + + if (in_array('stubs', $thingsToPublish)) { + $this->comment('Publishing stubs...'); + $this->call('ddd:stub', [ + '--all' => true, + ]); + } + + return self::SUCCESS; + } +} diff --git a/src/Commands/StubCommand.php b/src/Commands/StubCommand.php new file mode 100644 index 0000000..984ad52 --- /dev/null +++ b/src/Commands/StubCommand.php @@ -0,0 +1,163 @@ +stubs()->dddStubs(), + ...app('ddd')->stubs()->frameworkStubs(), + ]; + } + + protected function resolveSelectedStubs(array $names = []) + { + $stubs = $this->getStubChoices(); + + if ($names) { + [$startsWith, $exactNames] = collect($names) + ->partition(fn ($name) => str($name)->endsWith(['*', '.'])); + + $startsWith = $startsWith->map( + fn ($name) => str($name) + ->replaceEnd('*', '.') + ->replaceEnd('.', '') + ); + + return collect($stubs) + ->filter(function ($stub, $path) use ($startsWith, $exactNames) { + $stubWithoutExtension = str($stub)->replaceEnd('.stub', ''); + + return $exactNames->contains($stub) + || $exactNames->contains($stubWithoutExtension) + || str($stub)->startsWith($startsWith); + }) + ->all(); + } + + $selected = multisearch( + label: 'Which stub should be published?', + placeholder: 'Search for a stub...', + options: fn (string $value) => strlen($value) > 0 + ? collect($stubs)->filter(fn ($stub, $path) => str($stub)->contains($value))->all() + : $stubs, + required: true + ); + + return collect($stubs) + ->filter(fn ($stub, $path) => in_array($stub, $selected)) + ->all(); + } + + public function handle(): int + { + $option = match (true) { + $this->option('list') => 'list', + $this->option('all') => 'all', + count($this->argument('name')) > 0 => 'named', + default => select( + label: 'What do you want to do?', + options: [ + 'some' => 'Choose stubs to publish', + 'all' => 'Publish all stubs', + ], + required: true, + default: 'some' + ) + }; + + if ($option === 'list') { + // $this->table( + // ['Stub', 'Path'], + // collect($this->getStubChoices())->map( + // fn($stub, $path) => [ + // $stub, + // Str::after($path, $this->laravel->basePath()) + // ] + // ) + // ); + + table( + headers: ['Stub', 'Source'], + rows: collect($this->getStubChoices())->map( + fn ($stub, $path) => [ + Str::replaceLast('.stub', '', $stub), + str($path)->startsWith(DDD::packagePath()) + ? 'ddd' + : 'laravel', + ] + ) + ); + + return self::SUCCESS; + } + + $stubs = $option === 'all' + ? $this->getStubChoices() + : $this->resolveSelectedStubs($this->argument('name')); + + if (empty($stubs)) { + $this->warn('No matching stubs found.'); + + return self::INVALID; + } + + File::ensureDirectoryExists($stubsPath = $this->laravel->basePath('stubs/ddd')); + + $this->laravel['events']->dispatch($event = new PublishingStubs($stubs)); + + foreach ($event->stubs as $from => $to) { + $to = $stubsPath.DIRECTORY_SEPARATOR.ltrim($to, DIRECTORY_SEPARATOR); + + $relativePath = Str::after($to, $this->laravel->basePath()); + + $this->info("Publishing {$relativePath}"); + + if ((! $this->option('existing') && (! file_exists($to) || $this->option('force'))) + || ($this->option('existing') && file_exists($to)) + ) { + file_put_contents($to, file_get_contents($from)); + } + } + + $this->components->info('Stubs published successfully.'); + + return self::SUCCESS; + } +} diff --git a/src/Commands/UpgradeCommand.php b/src/Commands/UpgradeCommand.php index e696605..ea0c3c4 100644 --- a/src/Commands/UpgradeCommand.php +++ b/src/Commands/UpgradeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Config; class UpgradeCommand extends Command { @@ -19,36 +20,73 @@ public function handle() return; } - $replacements = [ + $legacyMapping = [ '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', + 'application' => null, + 'layers' => null, + 'namespaces' => [ + 'model' => 'namespaces.models', + 'data_transfer_object' => 'namespaces.data_transfer_objects', + 'view_model' => 'namespaces.view_models', + 'value_object' => 'namespaces.value_objects', + 'action' => 'namespaces.actions', + ], 'base_model' => 'base_model', 'base_dto' => 'base_dto', 'base_view_model' => 'base_view_model', 'base_action' => 'base_action', + 'autoload' => null, + 'autoload_ignore' => null, + 'cache_directory' => null, ]; + $factoryConfig = require __DIR__.'/../../config/ddd.php'; $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'); + $replacements = []; + + $map = Arr::dot($legacyMapping); - foreach ($replacements as $dotPath => $legacyKey) { + foreach ($map as $dotPath => $legacyKey) { $value = match (true) { array_key_exists($dotPath, $oldConfig) => $oldConfig[$dotPath], array_key_exists($legacyKey, $oldConfig) => $oldConfig[$legacyKey], default => config("ddd.{$dotPath}"), }; + $replacements[$dotPath] = $value ?? data_get($factoryConfig, $dotPath); + } + + $replacements = Arr::undot($replacements); + + $freshConfig = $factoryConfig; + + // Grab a fresh copy of the new config + $newConfigContent = file_get_contents(__DIR__.'/../../config/ddd.php.stub'); + + foreach ($freshConfig as $key => $value) { + $resolved = null; + + if (is_array($value)) { + $resolved = [ + ...$value, + ...data_get($replacements, $key, []), + ]; + + if (array_is_list($resolved)) { + $resolved = array_unique($resolved); + } + } else { + $resolved = data_get($replacements, $key, $value); + } + + $freshConfig[$key] = $resolved; + $newConfigContent = str_replace( - '{{'.$dotPath.'}}', - var_export($value, true), + '{{'.$key.'}}', + var_export($resolved, true), $newConfigContent ); } diff --git a/src/ComposerManager.php b/src/ComposerManager.php new file mode 100755 index 0000000..52d9005 --- /dev/null +++ b/src/ComposerManager.php @@ -0,0 +1,179 @@ +composer = app(Composer::class)->setWorkingPath(app()->basePath()); + + $this->composerFile = $composerFile ?? app()->basePath('composer.json'); + + $this->data = json_decode(file_get_contents($this->composerFile), true); + } + + public static function make(?string $composerFile = null): self + { + return new self($composerFile); + } + + public function usingOutput(OutputStyle $output) + { + $this->output = $output; + + return $this; + } + + protected function guessAutoloadPathFromNamespace(string $namespace): string + { + $rootFolders = [ + 'src', + '', + ]; + + $relativePath = Str::rtrim(Path::fromNamespace($namespace), '/\\'); + + foreach ($rootFolders as $folder) { + $path = Path::join($folder, $relativePath); + + if (is_dir($path)) { + return $this->normalizePathForComposer($path); + } + } + + return $this->normalizePathForComposer("src/{$relativePath}"); + } + + protected function normalizePathForComposer($path): string + { + $path = Path::normalize($path); + + return str_replace(['\\', '/'], '/', $path); + } + + public function hasPsr4Autoload(string $namespace): bool + { + return collect($this->getPsr4Namespaces()) + ->hasAny([ + $namespace, + Str::finish($namespace, '\\'), + ]); + } + + public function registerPsr4Autoload(string $namespace, $path) + { + $namespace = str($namespace) + ->rtrim('/\\') + ->finish('\\') + ->toString(); + + $path = $path ?? $this->guessAutoloadPathFromNamespace($namespace); + + return $this->fill( + ['autoload', 'psr-4', $namespace], + $this->normalizePathForComposer($path) + ); + } + + public function fill($path, $value) + { + data_fill($this->data, $path, $value); + + return $this; + } + + protected function update($set = [], $forget = []) + { + foreach ($forget as $key) { + $this->forget($key); + } + + foreach ($set as $pair) { + [$path, $value] = $pair; + $this->fill($path, $value); + } + + return $this; + } + + public function forget($key) + { + $keys = Arr::wrap($key); + + foreach ($keys as $key) { + Arr::forget($this->data, $key); + } + + return $this; + } + + public function get($path, $default = null) + { + return data_get($this->data, $path, $default); + } + + public function getPsr4Namespaces() + { + return $this->get(['autoload', 'psr-4'], []); + } + + public function getAutoloadPath($namespace) + { + $namespace = Str::finish($namespace, '\\'); + + return $this->get(['autoload', 'psr-4', $namespace]); + } + + public function unsetPsr4Autoload($namespace) + { + $namespace = Str::finish($namespace, '\\'); + + return $this->forget(['autoload', 'psr-4', $namespace]); + } + + public function reload() + { + $this->output?->writeLn('Reloading composer (dump-autoload)...'); + + $this->composer->dumpAutoloads(); + + return $this; + } + + public function save() + { + $this->composer->modify(fn ($composerData) => $this->data); + + return $this; + } + + public function saveAndReload() + { + return $this->save()->reload(); + } + + public function toJson() + { + return json_encode($this->data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + } + + public function toArray() + { + return $this->data; + } +} diff --git a/src/ConfigManager.php b/src/ConfigManager.php new file mode 100755 index 0000000..39dcdfc --- /dev/null +++ b/src/ConfigManager.php @@ -0,0 +1,156 @@ +configPath = $configPath ?? app()->configPath('ddd.php'); + + $this->packageConfig = require DDD::packagePath('config/ddd.php'); + + $this->config = file_exists($configPath) ? require ($configPath) : $this->packageConfig; + + $this->stub = file_get_contents(DDD::packagePath('config/ddd.php.stub')); + } + + protected function mergeArray($path, $array) + { + $path = Arr::wrap($path); + + $merged = []; + + foreach ($array as $key => $value) { + $merged[$key] = is_array($value) + ? $this->mergeArray([...$path, $key], $value) + : $this->resolve([...$path, $key], $value); + } + + if (array_is_list($merged)) { + $merged = array_unique($merged); + } + + return $merged; + } + + public function resolve($path, $value) + { + $path = Arr::wrap($path); + + return data_get($this->config, $path, $value); + } + + public function syncWithLatest() + { + $fresh = []; + + foreach ($this->packageConfig as $key => $value) { + $resolved = is_array($value) + ? $this->mergeArray($key, $value) + : $this->resolve($key, $value); + + $fresh[$key] = $resolved; + } + + $this->config = $fresh; + + return $this; + } + + public function get($key = null) + { + if (is_null($key)) { + return $this->config; + } + + return data_get($this->config, $key); + } + + public function set($key, $value) + { + data_set($this->config, $key, $value); + + return $this; + } + + public function fill($values) + { + foreach ($values as $key => $value) { + $this->set($key, $value); + } + + return $this; + } + + public function save() + { + $content = $this->stub; + + // We will temporary substitute namespace slashes + // with a placeholder to avoid double exporter + // escaping them as double backslashes. + $keysWithNamespaces = [ + 'domain_namespace', + 'application_namespace', + 'layers', + 'namespaces', + 'base_model', + 'base_dto', + 'base_view_model', + 'base_action', + ]; + + foreach ($keysWithNamespaces as $key) { + $value = $this->get($key); + + if (is_string($value)) { + $value = str_replace('\\', '[[BACKSLASH]]', $value); + } + + if (is_array($value)) { + $array = $value; + foreach ($array as $k => $v) { + $array[$k] = str_replace('\\', '[[BACKSLASH]]', $v); + } + $value = $array; + } + + $this->set($key, $value); + } + + foreach ($this->config as $key => $value) { + $content = str_replace( + '{{'.$key.'}}', + VarExporter::export($value), + $content + ); + } + + // Restore namespace slashes + $content = str_replace('[[BACKSLASH]]', '\\', $content); + + // Write it to a temporary file first + $tempPath = sys_get_temp_dir().'/ddd.php'; + file_put_contents($tempPath, $content); + + // Format it using pint + Process::run("./vendor/bin/pint {$tempPath}"); + + // Copy the temporary file to the config path + copy($tempPath, $this->configPath); + + return $this; + } +} diff --git a/src/DomainManager.php b/src/DomainManager.php index 2de6b0f..2eb2ea3 100755 --- a/src/DomainManager.php +++ b/src/DomainManager.php @@ -2,6 +2,10 @@ namespace Lunarstorm\LaravelDDD; +use Lunarstorm\LaravelDDD\Support\AutoloadManager; +use Lunarstorm\LaravelDDD\Support\GeneratorBlueprint; +use Lunarstorm\LaravelDDD\Support\Path; + class DomainManager { /** @@ -11,9 +15,52 @@ class DomainManager */ protected $autoloadFilter; + /** + * The application layer filter callback. + * + * @var callable|null + */ + protected $applicationLayerFilter; + + /** + * The object schema resolver callback. + * + * @var callable|null + */ + protected $objectSchemaResolver; + + /** + * Resolved custom objects. + */ + protected array $resolvedObjects = []; + + protected ?GeneratorBlueprint $commandContext; + public function __construct() { $this->autoloadFilter = null; + $this->applicationLayerFilter = null; + $this->commandContext = null; + } + + public function autoloader(): AutoloadManager + { + return app(AutoloadManager::class); + } + + public function composer(): ComposerManager + { + return app(ComposerManager::class); + } + + public function config(): ConfigManager + { + return app(ConfigManager::class); + } + + public function stubs(): StubManager + { + return app(StubManager::class); } public function filterAutoloadPathsUsing(callable $filter): void @@ -25,4 +72,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 resolveObjectSchemaUsing(callable $resolver): void + { + $this->objectSchemaResolver = $resolver; + } + + public function getObjectSchemaResolver(): ?callable + { + return $this->objectSchemaResolver; + } + + public function packagePath($path = ''): string + { + return Path::normalize(realpath(__DIR__.'/../'.$path)); + } + + public function laravelVersion($value) + { + return version_compare(app()->version(), $value, '>='); + } } diff --git a/src/Enums/LayerType.php b/src/Enums/LayerType.php new file mode 100644 index 0000000..65e367a --- /dev/null +++ b/src/Enums/LayerType.php @@ -0,0 +1,10 @@ +domain, 'factory', "{$model->name}Factory"))) { + $factoryClass = DomainResolver::getDomainObjectNamespace($model->domain, 'factory', "{$model->name}Factory"); + if (class_exists($factoryClass)) { return $factoryClass; } diff --git a/src/LaravelDDDServiceProvider.php b/src/LaravelDDDServiceProvider.php index 6555823..cb3965e 100644 --- a/src/LaravelDDDServiceProvider.php +++ b/src/LaravelDDDServiceProvider.php @@ -2,7 +2,10 @@ namespace Lunarstorm\LaravelDDD; -use Lunarstorm\LaravelDDD\Support\DomainAutoloader; +use Illuminate\Database\Migrations\MigrationCreator; +use Lunarstorm\LaravelDDD\Facades\Autoload; +use Lunarstorm\LaravelDDD\Support\AutoloadManager; +use Lunarstorm\LaravelDDD\Support\DomainMigration; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -10,12 +13,6 @@ class LaravelDDDServiceProvider extends PackageServiceProvider { public function configurePackage(Package $package): void { - $this->app->scoped(DomainManager::class, function () { - return new DomainManager; - }); - - $this->app->bind('ddd', DomainManager::class); - /* * This class is a Package Service Provider * @@ -26,9 +23,12 @@ public function configurePackage(Package $package): void ->hasConfigFile() ->hasCommands([ Commands\InstallCommand::class, + Commands\ConfigCommand::class, + Commands\PublishCommand::class, + Commands\StubCommand::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,37 +41,111 @@ 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) { + if ($this->laravelVersion(11)) { $package->hasCommand(Commands\DomainClassMakeCommand::class); $package->hasCommand(Commands\DomainEnumMakeCommand::class); $package->hasCommand(Commands\DomainInterfaceMakeCommand::class); $package->hasCommand(Commands\DomainTraitMakeCommand::class); } + + if ($this->app->runningUnitTests()) { + $package->hasRoutes(['testing']); + } + + $this->registerBindings(); + } + + protected function laravelVersion($value) + { + return version_compare(app()->version(), $value, '>='); + } + + protected function registerMigrations() + { + $this->app->when(MigrationCreator::class) + ->needs('$customStubPath') + ->give(fn () => $this->app->basePath('stubs')); + + $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()); + + return $this; + } + + protected function registerBindings() + { + $this->app->scoped(DomainManager::class, function () { + return new DomainManager; + }); + + $this->app->scoped(ComposerManager::class, function () { + return ComposerManager::make($this->app->basePath('composer.json')); + }); + + $this->app->scoped(ConfigManager::class, function () { + return new ConfigManager($this->app->configPath('ddd.php')); + }); + + $this->app->scoped(StubManager::class, function () { + return new StubManager; + }); + + $this->app->scoped(AutoloadManager::class, function () { + return new AutoloadManager; + }); + + $this->app->bind('ddd', DomainManager::class); + $this->app->bind('ddd.autoloader', AutoloadManager::class); + $this->app->bind('ddd.config', ConfigManager::class); + $this->app->bind('ddd.composer', ComposerManager::class); + $this->app->bind('ddd.stubs', StubManager::class); + + return $this; } public function packageBooted() { - $this->publishes([ - $this->package->basePath('/../stubs') => resource_path("stubs/{$this->package->shortName()}"), - ], "{$this->package->shortName()}-stubs"); + Autoload::run(); + + if ($this->app->runningInConsole() && method_exists($this, 'optimizes')) { + $this->optimizes( + optimize: 'ddd:optimize', + clear: 'ddd:clear', + key: 'laravel-ddd', + ); + } } public function packageRegistered() { - (new DomainAutoloader)->autoload(); + $this->registerMigrations(); } } diff --git a/src/StubManager.php b/src/StubManager.php new file mode 100755 index 0000000..5844f40 --- /dev/null +++ b/src/StubManager.php @@ -0,0 +1,102 @@ +dddStubs(), + ...$this->frameworkStubs(), + ]; + } + + public function dddStubs() + { + return [ + realpath(__DIR__.'/../stubs/action.stub') => 'action.stub', + realpath(__DIR__.'/../stubs/dto.stub') => 'dto.stub', + realpath(__DIR__.'/../stubs/value-object.stub') => 'value-object.stub', + realpath(__DIR__.'/../stubs/view-model.stub') => 'view-model.stub', + realpath(__DIR__.'/../stubs/base-view-model.stub') => 'base-view-model.stub', + realpath(__DIR__.'/../stubs/factory.stub') => 'factory.stub', + ]; + } + + public function frameworkStubs() + { + $laravelStubCommand = new ReflectionClass(new StubPublishCommand); + + $dir = dirname($laravelStubCommand->getFileName()); + + $stubs = [ + $dir.'/stubs/cast.inbound.stub' => 'cast.inbound.stub', + $dir.'/stubs/cast.stub' => 'cast.stub', + $dir.'/stubs/class.stub' => 'class.stub', + $dir.'/stubs/class.invokable.stub' => 'class.invokable.stub', + $dir.'/stubs/console.stub' => 'console.stub', + $dir.'/stubs/enum.stub' => 'enum.stub', + $dir.'/stubs/enum.backed.stub' => 'enum.backed.stub', + $dir.'/stubs/event.stub' => 'event.stub', + $dir.'/stubs/job.queued.stub' => 'job.queued.stub', + $dir.'/stubs/job.stub' => 'job.stub', + $dir.'/stubs/listener.typed.queued.stub' => 'listener.typed.queued.stub', + $dir.'/stubs/listener.queued.stub' => 'listener.queued.stub', + $dir.'/stubs/listener.typed.stub' => 'listener.typed.stub', + $dir.'/stubs/listener.stub' => 'listener.stub', + $dir.'/stubs/mail.stub' => 'mail.stub', + $dir.'/stubs/markdown-mail.stub' => 'markdown-mail.stub', + $dir.'/stubs/markdown-notification.stub' => 'markdown-notification.stub', + $dir.'/stubs/model.pivot.stub' => 'model.pivot.stub', + $dir.'/stubs/model.stub' => 'model.stub', + $dir.'/stubs/notification.stub' => 'notification.stub', + $dir.'/stubs/observer.plain.stub' => 'observer.plain.stub', + $dir.'/stubs/observer.stub' => 'observer.stub', + // $dir . '/stubs/pest.stub' => 'pest.stub', + // $dir . '/stubs/pest.unit.stub' => 'pest.unit.stub', + $dir.'/stubs/policy.plain.stub' => 'policy.plain.stub', + $dir.'/stubs/policy.stub' => 'policy.stub', + $dir.'/stubs/provider.stub' => 'provider.stub', + $dir.'/stubs/request.stub' => 'request.stub', + $dir.'/stubs/resource.stub' => 'resource.stub', + $dir.'/stubs/resource-collection.stub' => 'resource-collection.stub', + $dir.'/stubs/rule.stub' => 'rule.stub', + $dir.'/stubs/scope.stub' => 'scope.stub', + // $dir.'/stubs/test.stub' => 'test.stub', + // $dir.'/stubs/test.unit.stub' => 'test.unit.stub', + $dir.'/stubs/trait.stub' => 'trait.stub', + $dir.'/stubs/view-component.stub' => 'view-component.stub', + // Factories will use a ddd-specific stub + // realpath($dir . '/../../Database/Console/Factories/stubs/factory.stub') => 'factory.stub', + realpath($dir.'/../../Database/Console/Seeds/stubs/seeder.stub') => 'seeder.stub', + realpath($dir.'/../../Database/Migrations/stubs/migration.create.stub') => 'migration.create.stub', + realpath($dir.'/../../Database/Migrations/stubs/migration.stub') => 'migration.stub', + realpath($dir.'/../../Database/Migrations/stubs/migration.update.stub') => 'migration.update.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.api.stub') => 'controller.api.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.invokable.stub') => 'controller.invokable.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.model.api.stub') => 'controller.model.api.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.model.stub') => 'controller.model.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.nested.api.stub') => 'controller.nested.api.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.nested.singleton.api.stub') => 'controller.nested.singleton.api.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.nested.singleton.stub') => 'controller.nested.singleton.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.nested.stub') => 'controller.nested.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.plain.stub') => 'controller.plain.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.singleton.api.stub') => 'controller.singleton.api.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.singleton.stub') => 'controller.singleton.stub', + realpath($dir.'/../../Routing/Console/stubs/controller.stub') => 'controller.stub', + realpath($dir.'/../../Routing/Console/stubs/middleware.stub') => 'middleware.stub', + ]; + + // Some stubs are not available across all Laravel versions, + // so we'll just skip the files that don't exist. + return collect($stubs)->filter(function ($stub, $path) { + return file_exists($path); + })->all(); + } +} diff --git a/src/Support/AutoloadManager.php b/src/Support/AutoloadManager.php new file mode 100644 index 0000000..90bf8cb --- /dev/null +++ b/src/Support/AutoloadManager.php @@ -0,0 +1,360 @@ +container = $container ?? Container::getInstance(); + + $this->app = $this->container->make(Application::class); + + $this->appNamespace = $this->app->getNamespace(); + } + + public function boot() + { + $this->booted = true; + + if (! config()->has('ddd.autoload')) { + return $this->flush(); + } + + $this + ->flush() + ->when(config('ddd.autoload.providers') === true, fn () => $this->handleProviders()) + ->when($this->app->runningInConsole() && config('ddd.autoload.commands') === true, fn () => $this->handleCommands()) + ->when(config('ddd.autoload.policies') === true, fn () => $this->handlePolicies()) + ->when(config('ddd.autoload.factories') === true, fn () => $this->handleFactories()); + + return $this; + } + + public function isBooted(): bool + { + return $this->booted; + } + + public function isConsoleBooted(): bool + { + return $this->consoleBooted; + } + + public function hasRun(): bool + { + return $this->ran; + } + + protected function flush() + { + foreach (static::$registeredProviders as $provider) { + $this->app?->forgetInstance($provider); + } + + static::$registeredProviders = []; + + static::$registeredCommands = []; + + static::$resolvedPolicies = []; + + static::$resolvedFactories = []; + + return $this; + } + + protected function normalizePaths($path): array + { + return collect($path) + ->filter(fn ($path) => is_dir($path)) + ->toArray(); + } + + public function getAllLayerPaths(): array + { + return collect([ + DomainResolver::domainPath(), + DomainResolver::applicationLayerPath(), + ...array_values(config('ddd.layers', [])), + ])->map(fn ($path) => Path::normalize($this->app->basePath($path)))->toArray(); + } + + protected function getCustomLayerPaths(): array + { + return collect([ + ...array_values(config('ddd.layers', [])), + ])->map(fn ($path) => Path::normalize($this->app->basePath($path)))->toArray(); + } + + protected function handleProviders() + { + $providers = DomainCache::has('domain-providers') + ? DomainCache::get('domain-providers') + : $this->discoverProviders(); + + foreach ($providers as $provider) { + static::$registeredProviders[$provider] = $provider; + } + + return $this; + } + + protected function handleCommands() + { + $commands = DomainCache::has('domain-commands') + ? DomainCache::get('domain-commands') + : $this->discoverCommands(); + + foreach ($commands as $command) { + static::$registeredCommands[$command] = $command; + } + + return $this; + } + + public function run() + { + if (! $this->isBooted()) { + $this->boot(); + } + + foreach (static::$registeredProviders as $provider) { + $this->app->register($provider); + } + + if ($this->app->runningInConsole() && ! $this->isConsoleBooted()) { + ConsoleApplication::starting(function (ConsoleApplication $artisan) { + foreach (static::$registeredCommands as $command) { + $artisan->resolve($command); + } + }); + + $this->consoleBooted = true; + } + + $this->ran = true; + + return $this; + } + + public function getRegisteredCommands(): array + { + return static::$registeredCommands; + } + + public function getRegisteredProviders(): array + { + return static::$registeredProviders; + } + + public function getResolvedPolicies(): array + { + return static::$resolvedPolicies; + } + + public function getResolvedFactories(): array + { + return static::$resolvedFactories; + } + + protected function handlePolicies() + { + Gate::guessPolicyNamesUsing(static::$policyResolver = function (string $class): array|string { + if ($model = DomainObject::fromClass($class, 'model')) { + $resolved = (new Domain($model->domain)) + ->object('policy', "{$model->name}Policy") + ->fullyQualifiedName; + + static::$resolvedPolicies[$class] = $resolved; + + return $resolved; + } + + $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']); + }); + + return $this; + } + + protected function handleFactories() + { + Factory::guessFactoryNamesUsing(static::$factoryResolver = function (string $modelName) { + if ($factoryName = DomainFactory::resolveFactoryName($modelName)) { + static::$resolvedFactories[$modelName] = $factoryName; + + return $factoryName; + } + + $modelName = Str::startsWith($modelName, $this->appNamespace.'Models\\') + ? Str::after($modelName, $this->appNamespace.'Models\\') + : Str::after($modelName, $this->appNamespace); + + return 'Database\\Factories\\'.$modelName.'Factory'; + }); + + return $this; + } + + protected function finder($paths) + { + $filter = DDD::getAutoloadFilter() ?? function (SplFileInfo $file) { + $pathAfterDomain = str($file->getRelativePath()) + ->replace('\\', '/') + ->after('/') + ->finish('/'); + + $ignoredFolders = collect(config('ddd.autoload_ignore', [])) + ->map(fn ($path) => Str::finish($path, '/')); + + if ($pathAfterDomain->startsWith($ignoredFolders)) { + return false; + } + }; + + return Finder::create() + ->files() + ->in($paths) + ->filter($filter); + } + + public function discoverProviders(): array + { + $configValue = config('ddd.autoload.providers'); + + if ($configValue === false) { + return []; + } + + $paths = $this->normalizePaths( + $configValue === true + ? $this->getAllLayerPaths() + : $configValue + ); + + if (empty($paths)) { + return []; + } + + return Lody::classesFromFinder($this->finder($paths)) + ->isNotAbstract() + ->isInstanceOf(ServiceProvider::class) + ->values() + ->toArray(); + } + + public function discoverCommands(): array + { + $configValue = config('ddd.autoload.commands'); + + if ($configValue === false) { + return []; + } + + $paths = $this->normalizePaths( + $configValue === true + ? $this->getAllLayerPaths() + : $configValue + ); + + if (empty($paths)) { + return []; + } + + return Lody::classesFromFinder($this->finder($paths)) + ->isNotAbstract() + ->isInstanceOf(Command::class) + ->values() + ->toArray(); + } + + public function cacheCommands() + { + DomainCache::set('domain-commands', $this->discoverCommands()); + + return $this; + } + + public function cacheProviders() + { + DomainCache::set('domain-providers', $this->discoverProviders()); + + return $this; + } + + protected function resolveAppNamespace() + { + try { + return Container::getInstance() + ->make(Application::class) + ->getNamespace(); + } catch (Throwable) { + return 'App\\'; + } + } + + public static function partialMock() + { + $mock = Mockery::mock(AutoloadManager::class, [null]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + + $mock->shouldReceive('isBooted')->andReturn(false); + + return $mock; + } +} diff --git a/src/Support/Concerns/InteractsWithComposer.php b/src/Support/Concerns/InteractsWithComposer.php new file mode 100644 index 0000000..cc1bbee --- /dev/null +++ b/src/Support/Concerns/InteractsWithComposer.php @@ -0,0 +1,30 @@ +rtrim('/\\') + ->finish('\\') + ->toString(); + + $this->composerFill(['autoload', 'psr-4', $namespace], $path); + + return $this; + } +} diff --git a/src/Support/Domain.php b/src/Support/Domain.php index 062aedc..d8e608b 100644 --- a/src/Support/Domain.php +++ b/src/Support/Domain.php @@ -2,7 +2,6 @@ namespace Lunarstorm\LaravelDDD\Support; -use Lunarstorm\LaravelDDD\ValueObjects\DomainNamespaces; use Lunarstorm\LaravelDDD\ValueObjects\DomainObject; class Domain @@ -11,13 +10,15 @@ class Domain public readonly string $path; + public readonly string $migrationPath; + public readonly string $domain; public readonly ?string $subdomain; public readonly string $domainWithSubdomain; - public readonly DomainNamespaces $namespace; + public readonly Layer $layer; public static array $objects = []; @@ -54,9 +55,11 @@ public function __construct(string $domain, ?string $subdomain = null) ? "{$this->domain}.{$this->subdomain}" : $this->domain; - $this->namespace = DomainNamespaces::from($this->domain, $this->subdomain); + $this->layer = DomainResolver::resolveLayer($this->domainWithSubdomain); + + $this->path = $this->layer->path; - $this->path = Path::join(DomainResolver::domainPath(), $this->domainWithSubdomain); + $this->migrationPath = Path::join($this->path, config('ddd.namespaces.migration', 'Database/Migrations')); } protected function getDomainBasePath() @@ -70,13 +73,28 @@ public function path(?string $path = null): string return $this->path; } + $resolvedPath = str($path) + ->replace($this->layer->namespace, '') + ->replace(['\\', '/'], DIRECTORY_SEPARATOR) + ->append('.php') + ->toString(); + + return Path::join($this->path, $resolvedPath); + } + + public function pathInApplicationLayer(?string $path = null): string + { + if (is_null($path)) { + return $this->path; + } + $path = str($path) - ->replace($this->namespace->root, '') + ->replace(DomainResolver::applicationLayerRootNamespace(), '') ->replace(['\\', '/'], DIRECTORY_SEPARATOR) ->append('.php') ->toString(); - return Path::join($this->path, $path); + return Path::join(DomainResolver::applicationLayerPath(), $path); } public function relativePath(string $path = ''): string @@ -84,9 +102,19 @@ public function relativePath(string $path = ''): string return collect([$this->domain, $path])->filter()->implode(DIRECTORY_SEPARATOR); } - public function namespaceFor(string $type): string + public function rootNamespace(): string + { + return $this->layer->namespace; + } + + public function intendedLayerFor(string $type) { - return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type); + return DomainResolver::resolveLayer($this->domainWithSubdomain, $type); + } + + public function namespaceFor(string $type, ?string $name = null): string + { + return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type, $name); } public function guessNamespaceFromName(string $name): string @@ -102,20 +130,28 @@ public function guessNamespaceFromName(string $name): string public function object(string $type, string $name, bool $absolute = false): DomainObject { + $layer = $this->intendedLayerFor($type); + $namespace = match (true) { - $absolute => $this->namespace->root, - str($name)->startsWith('\\') => $this->guessNamespaceFromName($name), - default => $this->namespaceFor($type), + $absolute => $layer->namespace, + str($name)->startsWith('\\') => $layer->guessNamespaceFromName($name), + default => $layer->namespaceFor($type), }; - $baseName = str($name)->replace($namespace, '')->trim('\\')->toString(); + $baseName = str($name)->replace($namespace, '') + ->replace(['\\', '/'], '\\') + ->trim('\\') + ->when($type === 'factory', fn ($name) => $name->finish('Factory')) + ->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: $layer->path($fullyQualifiedName), type: $type ); } diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php deleted file mode 100644 index beae39e..0000000 --- a/src/Support/DomainAutoloader.php +++ /dev/null @@ -1,214 +0,0 @@ -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 ($factoryName = DomainFactory::resolveFactoryName($modelName)) { - return $factoryName; - } - - $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 finder($paths) - { - $filter = app('ddd')->getAutoloadFilter() ?? function (SplFileInfo $file) { - $pathAfterDomain = str($file->getRelativePath()) - ->replace('\\', '/') - ->after('/') - ->finish('/'); - - $ignoredFolders = collect(config('ddd.autoload_ignore', [])) - ->map(fn ($path) => Str::finish($path, '/')); - - if ($pathAfterDomain->startsWith($ignoredFolders)) { - return false; - } - }; - - return Finder::create() - ->files() - ->in($paths) - ->filter($filter); - } - - 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(static::finder($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(static::finder($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/DomainMigration.php b/src/Support/DomainMigration.php new file mode 100644 index 0000000..b6cd0ba --- /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::filterDirectories([ + 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..ca0213a 100644 --- a/src/Support/DomainResolver.php +++ b/src/Support/DomainResolver.php @@ -3,6 +3,7 @@ namespace Lunarstorm\LaravelDDD\Support; use Illuminate\Support\Str; +use Lunarstorm\LaravelDDD\Enums\LayerType; class DomainResolver { @@ -35,6 +36,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 +62,85 @@ 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 + { + return static::isApplicationLayer($type) + ? static::applicationLayerRootNamespace() + : static::domainRootNamespace(); + } + + /** + * Resolve the intended layer of a specified domain name keyword. + */ + public static function resolveLayer(string $domain, ?string $type = null): ?Layer { - $namespace = collect([ - static::domainRootNamespace(), - $domain, - static::getRelativeObjectNamespace($type), - ])->filter()->implode('\\'); - - if ($object) { - $namespace .= "\\{$object}"; + $layers = config('ddd.layers', []); + + // Objects in the application layer take precedence + if ($type && static::isApplicationLayer($type)) { + return new Layer( + static::applicationLayerRootNamespace().'\\'.$domain, + Path::join(static::applicationLayerPath(), $domain), + LayerType::Application, + ); } - return $namespace; + return match (true) { + array_key_exists($domain, $layers) + && is_string($layers[$domain]) => new Layer($domain, $layers[$domain], LayerType::Custom), + + default => new Layer( + static::domainRootNamespace().'\\'.$domain, + Path::join(static::domainPath(), $domain), + LayerType::Domain, + ) + }; + } + + /** + * 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 + { + $resolver = function (string $domain, string $type, ?string $name) { + $layer = static::resolveLayer($domain, $type); + + $namespace = collect([ + $layer->namespace, + static::getRelativeObjectNamespace($type), + ])->filter()->implode('\\'); + + if ($name) { + $namespace .= "\\{$name}"; + } + + return $namespace; + }; + + return $resolver($domain, $type, $name); } /** @@ -95,6 +178,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/GeneratorBlueprint.php b/src/Support/GeneratorBlueprint.php new file mode 100644 index 0000000..00e1354 --- /dev/null +++ b/src/Support/GeneratorBlueprint.php @@ -0,0 +1,150 @@ +command = new CommandContext($commandName, $arguments, $options); + + $this->nameInput = str($nameInput)->toString(); + + $this->isAbsoluteName = str($this->nameInput)->startsWith('/'); + + $this->type = $this->guessObjectType(); + + $this->normalizedName = Path::normalizeNamespace( + str($nameInput) + ->studly() + ->replace(['.', '\\', '/'], '\\') + ->trim('\\') + ->when($this->type === 'factory', fn ($name) => $name->finish('Factory')) + ->toString() + ); + + $this->baseName = class_basename($this->normalizedName); + + $this->domain = new Domain($domainName); + + $this->domainName = $this->domain->domainWithSubdomain; + + $this->layer = DomainResolver::resolveLayer($this->domainName, $this->type); + + $this->schema = $this->resolveSchema(); + } + + public static function capture(Command $command) {} + + protected function guessObjectType(): string + { + return match ($this->command->name) { + 'ddd:base-view-model' => 'view_model', + 'ddd:base-model' => 'model', + 'ddd:value' => 'value_object', + 'ddd:dto' => 'data_transfer_object', + 'ddd:migration' => 'migration', + default => str($this->command->name)->after(':')->snake()->toString(), + }; + } + + protected function resolveSchema(): ObjectSchema + { + $customResolver = app('ddd')->getObjectSchemaResolver(); + + $blueprint = is_callable($customResolver) + ? App::call($customResolver, [ + 'domainName' => $this->domainName, + 'nameInput' => $this->nameInput, + 'type' => $this->type, + 'command' => $this->command, + ]) + : null; + + if ($blueprint instanceof ObjectSchema) { + return $blueprint; + } + + $namespace = match (true) { + $this->isAbsoluteName => $this->layer->namespace, + str($this->nameInput)->startsWith('\\') => $this->layer->guessNamespaceFromName($this->nameInput), + default => $this->layer->namespaceFor($this->type), + }; + + $fullyQualifiedName = str($this->normalizedName) + ->start($namespace.'\\') + ->toString(); + + return new ObjectSchema( + name: $this->normalizedName, + namespace: $namespace, + fullyQualifiedName: $fullyQualifiedName, + path: $this->layer->path($fullyQualifiedName), + ); + } + + public function rootNamespace() + { + return str($this->schema->namespace)->finish('\\')->toString(); + } + + public function getDefaultNamespace($rootNamespace) + { + return $this->schema->namespace; + } + + public function getPath($name) + { + return Path::normalize(app()->basePath($this->schema->path)); + } + + public function qualifyClass($name) + { + return $this->schema->fullyQualifiedName; + } + + public function getFactoryFor(string $name) + { + return $this->domain->factory($name); + } + + public function getMigrationPath() + { + return $this->domain->migrationPath; + } + + public function getNamespaceFor($type, $name = null) + { + return $this->domain->namespaceFor($type, $name); + } +} diff --git a/src/Support/Layer.php b/src/Support/Layer.php new file mode 100644 index 0000000..1e86c0f --- /dev/null +++ b/src/Support/Layer.php @@ -0,0 +1,76 @@ +namespace = Path::normalizeNamespace(Str::replaceEnd('\\', '', $namespace)); + + $this->path = is_null($path) + ? Path::fromNamespace($this->namespace) + : Path::normalize(Str::replaceEnd('/', '', $path)); + } + + public static function fromNamespace(string $namespace): self + { + return new self($namespace); + } + + public function path(?string $path = null): string + { + if (is_null($path)) { + return $this->path; + } + + $baseName = class_basename($path); + + $relativePath = str($path) + ->beforeLast($baseName) + ->replaceStart($this->namespace, '') + ->replace(['\\', '/'], DIRECTORY_SEPARATOR) + ->append($baseName) + ->finish('.php') + ->toString(); + + return Path::join($this->path, $relativePath); + } + + public function namespaceFor(string $type, ?string $name = null): string + { + $namespace = collect([ + $this->namespace, + DomainResolver::getRelativeObjectNamespace($type), + ])->filter()->implode('\\'); + + if ($name) { + $namespace .= "\\{$name}"; + } + + return Path::normalizeNamespace($namespace); + } + + public function guessNamespaceFromName(string $name): string + { + $baseName = class_basename($name); + + return Path::normalizeNamespace( + str($name) + ->before($baseName) + ->trim('\\') + ->prepend($this->namespace.'\\') + ->toString() + ); + } +} diff --git a/src/Support/Path.php b/src/Support/Path.php index 931230c..28da83c 100644 --- a/src/Support/Path.php +++ b/src/Support/Path.php @@ -18,6 +18,14 @@ public static function join(...$parts) return implode(DIRECTORY_SEPARATOR, $parts); } + public static function fromNamespace(string $namespace, ?string $classname = null): string + { + return str($namespace) + ->replace(['\\', '/'], DIRECTORY_SEPARATOR) + ->when($classname, fn ($s) => $s->append("{$classname}.php")) + ->toString(); + } + public static function filePathToNamespace(string $path, string $namespacePath, string $namespace): string { return str_replace( @@ -26,4 +34,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/CommandContext.php b/src/ValueObjects/CommandContext.php new file mode 100644 index 0000000..5ad6032 --- /dev/null +++ b/src/ValueObjects/CommandContext.php @@ -0,0 +1,32 @@ +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/src/ValueObjects/ObjectSchema.php b/src/ValueObjects/ObjectSchema.php new file mode 100644 index 0000000..e3b78aa --- /dev/null +++ b/src/ValueObjects/ObjectSchema.php @@ -0,0 +1,13 @@ + 'src/Domain', + 'domain_namespace' => 'Domain', + + /* + |-------------------------------------------------------------------------- + | Application Layer + |-------------------------------------------------------------------------- + | + | The path and namespace of the application layer, and the objects + | that should be recognized as part of the application layer. + | + */ + 'application_path' => 'src/Application', + 'application_namespace' => 'Application', + 'application_objects' => [ + 'controller', + 'request', + 'middleware', + ], + + /* + |-------------------------------------------------------------------------- + | Custom Layers + |-------------------------------------------------------------------------- + | + | Additional top-level namespaces and paths that should be recognized as + | layers when generating ddd:* objects. + | + | e.g., 'Infrastructure' => 'src/Infrastructure', + | + */ + 'layers' => [ + 'Infrastructure' => 'src/Infrastructure', + ], + + /* + |-------------------------------------------------------------------------- + | Object Namespaces + |-------------------------------------------------------------------------- + | + | This value contains the default namespaces of ddd:* generated + | objects relative to the layer of which the object belongs to. + | + */ + 'namespaces' => [ + 'model' => 'Models', + 'data_transfer_object' => 'Data', + 'view_model' => 'ViewModels', + 'value_object' => 'ValueObjects', + 'action' => 'Actions', + 'cast' => 'Casts', + 'class' => '', + 'channel' => 'Channels', + 'command' => 'Commands', + 'controller' => 'Controllers', + 'enum' => 'Enums', + 'event' => 'Events', + 'exception' => 'Exceptions', + 'factory' => 'Database\Factories', + 'interface' => '', + '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' => '', + ], + + /* + |-------------------------------------------------------------------------- + | Base Model + |-------------------------------------------------------------------------- + | + | The base model class which generated domain models should extend. If + | set to null, the generated models will extend Laravel's default. + | + */ + 'base_model' => null, + + /* + |-------------------------------------------------------------------------- + | 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\ViewModel', + + /* + |-------------------------------------------------------------------------- + | 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, + + /* + |-------------------------------------------------------------------------- + | Autoloading + |-------------------------------------------------------------------------- + | + | Configure whether domain providers, commands, policies, factories, + | and migrations should be auto-discovered and registered. + | + */ + 'autoload' => [ + 'providers' => true, + 'commands' => true, + 'policies' => true, + 'factories' => true, + 'migrations' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Autoload Ignore Folders + |-------------------------------------------------------------------------- + | + | Folders that should be skipped during autoloading discovery, + | relative to the root of each domain. + | + | e.g., src/Domain/Invoicing/ + | + | If more advanced filtering is needed, a callback can be registered + | using `DDD::filterAutoloadPathsUsing(callback $filter)` in + | the AppServiceProvider's boot method. + | + */ + 'autoload_ignore' => [ + 'Tests', + 'Database/Migrations', + ], + + /* + |-------------------------------------------------------------------------- + | Caching + |-------------------------------------------------------------------------- + | + | The folder where the domain cache files will be stored. Used for domain + | autoloading. + | + */ + 'cache_directory' => 'bootstrap/cache/ddd', +]; diff --git a/tests/.skeleton/src/Application/Commands/ApplicationSync.php b/tests/.skeleton/src/Application/Commands/ApplicationSync.php new file mode 100644 index 0000000..d295169 --- /dev/null +++ b/tests/.skeleton/src/Application/Commands/ApplicationSync.php @@ -0,0 +1,24 @@ +info('Application state synced!'); + + if ($secret = AppSession::getSecret()) { + $this->line($secret); + + return; + } + } +} diff --git a/tests/.skeleton/src/Application/Database/Migrations/2024_10_14_215912_application_setup.php b/tests/.skeleton/src/Application/Database/Migrations/2024_10_14_215912_application_setup.php new file mode 100644 index 0000000..88fa2f3 --- /dev/null +++ b/tests/.skeleton/src/Application/Database/Migrations/2024_10_14_215912_application_setup.php @@ -0,0 +1,24 @@ +app->singleton('application-singleton', function (Application $app) { + return 'application-singleton'; + }); + } + + /** + * Bootstrap any application services. + * + * @return void + */ + public function boot() + { + AppSession::setSecret('application-secret'); + Clipboard::set('application-secret', 'application-secret'); + } +} 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 @@ +app->singleton('invoicing', function (Application $app) { + $this->app->singleton('invoicing-singleton', function (Application $app) { return 'invoicing-singleton'; }); } @@ -23,5 +24,6 @@ public function register() public function boot() { Invoice::setSecret('invoice-secret'); + Clipboard::set('invoicing-secret', 'invoicing-secret'); } } diff --git a/tests/.skeleton/src/Infrastructure/Commands/LogPrune.php b/tests/.skeleton/src/Infrastructure/Commands/LogPrune.php new file mode 100644 index 0000000..57f7ef7 --- /dev/null +++ b/tests/.skeleton/src/Infrastructure/Commands/LogPrune.php @@ -0,0 +1,24 @@ +info('System logs pruned!'); + + if ($secret = Clipboard::get('secret')) { + $this->line($secret); + + return; + } + } +} diff --git a/tests/.skeleton/src/Infrastructure/Models/AppSession.php b/tests/.skeleton/src/Infrastructure/Models/AppSession.php new file mode 100644 index 0000000..30f1ee1 --- /dev/null +++ b/tests/.skeleton/src/Infrastructure/Models/AppSession.php @@ -0,0 +1,23 @@ +app->singleton('infrastructure-singleton', function (Application $app) { + return 'infrastructure-singleton'; + }); + } + + /** + * Bootstrap any application services. + * + * @return void + */ + public function boot() + { + Clipboard::set('infrastructure-secret', 'infrastructure-secret'); + } +} diff --git a/tests/.skeleton/src/Infrastructure/Support/Clipboard.php b/tests/.skeleton/src/Infrastructure/Support/Clipboard.php new file mode 100644 index 0000000..b8eef1e --- /dev/null +++ b/tests/.skeleton/src/Infrastructure/Support/Clipboard.php @@ -0,0 +1,18 @@ +commands = [ + 'application:sync' => 'Application\Commands\ApplicationSync', + 'invoice:deliver' => 'Domain\Invoicing\Commands\InvoiceDeliver', + 'log:prune' => 'Infrastructure\Commands\LogPrune', + ]; - $this->setupTestApplication(); + $this->setupTestApplication(); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); - }); + DomainCache::clear(); + Artisan::call('ddd:clear'); +}); - it('does not register the command', function () { - expect(class_exists('Domain\Invoicing\Commands\InvoiceDeliver'))->toBeTrue(); - expect(fn () => Artisan::call('invoice:deliver'))->toThrow(CommandNotFoundException::class); - }); +afterEach(function () { + DomainCache::clear(); + Artisan::call('ddd:clear'); }); -describe('with autoload', function () { - beforeEach(function () { - Config::set('ddd.autoload.commands', true); +describe('when ddd.autoload.commands = false', function () { + it('skips handling commands', function () { + config()->set('ddd.autoload.commands', false); - $this->setupTestApplication(); + $mock = AutoloadManager::partialMock(); + $mock->shouldNotReceive('handleCommands'); + $mock->run(); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); + expect($mock->getRegisteredCommands())->toBeEmpty(); }); +}); - it('registers existing commands', function () { - $command = 'invoice:deliver'; +describe('when ddd.autoload.commands = true', function () { + it('registers the commands', function () { + config()->set('ddd.autoload.commands', true); - expect(collect(Artisan::all())) - ->has($command) - ->toBeTrue(); + $mock = AutoloadManager::partialMock(); + $mock->run(); - expect(class_exists('Domain\Invoicing\Commands\InvoiceDeliver'))->toBeTrue(); - Artisan::call($command); - expect(Artisan::output())->toContain('Invoice delivered!'); + $expected = array_values($this->commands); + $registered = array_values($mock->getRegisteredCommands()); + expect($expected)->each(fn ($item) => $item->toBeIn($registered)); + expect($registered)->toHaveCount(count($expected)); }); - - 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(); - }); + config()->set('ddd.autoload.commands', true); - // command should not be recognized due to cached empty-state - expect(fn () => Artisan::call('invoice:deliver'))->toThrow(CommandNotFoundException::class); + $mock = AutoloadManager::partialMock(); + $mock->run(); + + $registered = array_values($mock->getRegisteredCommands()); + expect($registered)->toHaveCount(0); }); it('can bust the cache', function () { DomainCache::set('domain-commands', []); DomainCache::clear(); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); + config()->set('ddd.autoload.commands', true); + + $mock = AutoloadManager::partialMock(); + $mock->run(); - $this->artisan('invoice:deliver')->assertSuccessful(); + $expected = array_values($this->commands); + $registered = array_values($mock->getRegisteredCommands()); + expect($expected)->each(fn ($item) => $item->toBeIn($registered)); + expect($registered)->toHaveCount(count($expected)); }); }); diff --git a/tests/Autoload/FactoryTest.php b/tests/Autoload/FactoryTest.php index f496b38..2de51c0 100644 --- a/tests/Autoload/FactoryTest.php +++ b/tests/Autoload/FactoryTest.php @@ -1,25 +1,39 @@ setupTestApplication(); + DomainCache::clear(); + Artisan::call('ddd:clear'); +}); - Config::set('ddd.domain_namespace', 'Domain'); +afterEach(function () { + DomainCache::clear(); + Artisan::call('ddd:clear'); }); -describe('autoload enabled', function () { - beforeEach(function () { - Config::set('ddd.autoload.factories', true); +describe('when ddd.autoload.factories = true', function () { + it('handles the factories', function () { + config()->set('ddd.autoload.factories', true); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); + $mock = AutoloadManager::partialMock(); + $mock->shouldReceive('handleFactories')->once(); + $mock->run(); }); it('can resolve domain factory', function ($modelClass, $expectedFactoryClass) { + config()->set('ddd.autoload.factories', true); + + $mock = AutoloadManager::partialMock(); + $mock->run(); + expect($modelClass::factory())->toBeInstanceOf($expectedFactoryClass); })->with([ // VanillaModel is a vanilla eloquent model in the domain layer @@ -36,30 +50,42 @@ ]); it('gracefully falls back for non-domain factories', function () { + config()->set('ddd.autoload.factories', true); + + $this->refreshApplication(); + Artisan::call('make:model RegularModel -f'); $modelClass = 'App\Models\RegularModel'; expect(class_exists($modelClass))->toBeTrue(); + expect(Factory::resolveFactoryName($modelClass)) + ->toEqual('Database\Factories\RegularModelFactory'); + expect($modelClass::factory()) ->toBeInstanceOf('Database\Factories\RegularModelFactory'); }); }); -describe('autoload disabled', function () { - beforeEach(function () { - Config::set('ddd.autoload.factories', false); +describe('when ddd.autoload.factories = false', function () { + it('skips handling factories', function () { + config()->set('ddd.autoload.factories', false); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); + $mock = AutoloadManager::partialMock(); + $mock->shouldNotReceive('handleFactories'); + $mock->run(); }); - it('cannot resolve factories that rely on autoloading', function ($modelClass) { + it('cannot resolve factories that rely on autoloading', function ($modelClass, $correctFactories) { + config()->set('ddd.autoload.factories', false); + + $mock = AutoloadManager::partialMock(); + $mock->run(); + expect(fn () => $modelClass::factory())->toThrow(Error::class); })->with([ - ['Domain\Invoicing\Models\VanillaModel'], - ['Domain\Internal\Reporting\Models\Report'], + ['Domain\Invoicing\Models\VanillaModel', ['Domain\Invoicing\Database\Factories\VanillaModelFactory', 'Database\Factories\Invoicing\VanillaModelFactory']], + ['Domain\Internal\Reporting\Models\Report', ['Domain\Internal\Reporting\Database\Factories\ReportFactory', 'Database\Factories\Internal\Reporting\ReportFactory']], ]); }); diff --git a/tests/Autoload/IgnoreTest.php b/tests/Autoload/IgnoreTest.php index 0e94256..5be1acf 100644 --- a/tests/Autoload/IgnoreTest.php +++ b/tests/Autoload/IgnoreTest.php @@ -5,74 +5,105 @@ use Illuminate\Support\Str; use Lunarstorm\LaravelDDD\Facades\DDD; use Lunarstorm\LaravelDDD\Support\DomainCache; +use Lunarstorm\LaravelDDD\Tests\BootsTestApplication; use Symfony\Component\Finder\SplFileInfo; +uses(BootsTestApplication::class); + beforeEach(function () { - Config::set('ddd.domain_path', 'src/Domain'); - Config::set('ddd.domain_namespace', 'Domain'); + $this->providers = [ + 'Application\Providers\ApplicationServiceProvider', + 'Domain\Invoicing\Providers\InvoiceServiceProvider', + 'Infrastructure\Providers\InfrastructureServiceProvider', + ]; + + $this->commands = [ + 'application:sync' => 'Application\Commands\ApplicationSync', + 'invoice:deliver' => 'Domain\Invoicing\Commands\InvoiceDeliver', + 'log:prune' => 'Infrastructure\Commands\LogPrune', + ]; + $this->setupTestApplication(); + + DomainCache::clear(); + Artisan::call('ddd:clear'); + + Config::set('ddd.autoload', [ + 'providers' => true, + 'commands' => true, + 'factories' => true, + 'policies' => true, + 'migrations' => true, + ]); +}); + +afterEach(function () { + DomainCache::clear(); + Artisan::call('ddd:clear'); }); it('can ignore folders when autoloading', function () { - Artisan::call('ddd:cache'); + expect(config('ddd.domain_path'))->toEqual('src/Domain'); + expect(config('ddd.domain_namespace'))->toEqual('Domain'); + expect(config('ddd.application_path'))->toEqual('src/Application'); + expect(config('ddd.application_namespace'))->toEqual('Application'); + expect(config('ddd.layers'))->toContain('src/Infrastructure'); $expected = [ - 'Domain\Invoicing\Providers\InvoiceServiceProvider', - 'Domain\Invoicing\Commands\InvoiceDeliver', + ...array_values($this->providers), + ...array_values($this->commands), ]; - $cached = [ - ...DomainCache::get('domain-providers'), - ...DomainCache::get('domain-commands'), + $discovered = [ + ...DDD::autoloader()->discoverProviders(), + ...DDD::autoloader()->discoverCommands(), ]; - expect($cached)->toEqual($expected); + expect($expected)->each(fn ($item) => $item->toBeIn($discovered)); + expect($discovered)->toHaveCount(count($expected)); Config::set('ddd.autoload_ignore', ['Commands']); - Artisan::call('ddd:cache'); - $expected = [ - 'Domain\Invoicing\Providers\InvoiceServiceProvider', + ...array_values($this->providers), ]; - $cached = [ - ...DomainCache::get('domain-providers'), - ...DomainCache::get('domain-commands'), + $discovered = [ + ...DDD::autoloader()->discoverProviders(), + ...DDD::autoloader()->discoverCommands(), ]; - expect($cached)->toEqual($expected); + expect($expected)->each(fn ($item) => $item->toBeIn($discovered)); + expect($discovered)->toHaveCount(count($expected)); Config::set('ddd.autoload_ignore', ['Providers']); - Artisan::call('ddd:cache'); - $expected = [ - 'Domain\Invoicing\Commands\InvoiceDeliver', + ...array_values($this->commands), ]; - $cached = [ - ...DomainCache::get('domain-providers'), - ...DomainCache::get('domain-commands'), + $discovered = [ + ...DDD::autoloader()->discoverProviders(), + ...DDD::autoloader()->discoverCommands(), ]; - expect($cached)->toEqual($expected); + expect($expected)->each(fn ($item) => $item->toBeIn($discovered)); + expect($discovered)->toHaveCount(count($expected)); }); it('can register a custom autoload filter', function () { - Artisan::call('ddd:cache'); - $expected = [ - 'Domain\Invoicing\Providers\InvoiceServiceProvider', - 'Domain\Invoicing\Commands\InvoiceDeliver', + ...array_values($this->providers), + ...array_values($this->commands), ]; - $cached = [ - ...DomainCache::get('domain-providers'), - ...DomainCache::get('domain-commands'), + $discovered = [ + ...DDD::autoloader()->discoverProviders(), + ...DDD::autoloader()->discoverCommands(), ]; - expect($cached)->toEqual($expected); + expect($expected)->each(fn ($item) => $item->toBeIn($discovered)); + expect($discovered)->toHaveCount(count($expected)); $secret = null; @@ -80,6 +111,10 @@ $ignoredFiles = [ 'InvoiceServiceProvider.php', 'InvoiceDeliver.php', + 'ApplicationServiceProvider.php', + 'ApplicationSync.php', + 'InfrastructureServiceProvider.php', + 'LogPrune.php', ]; $secret = 'i-was-invoked'; @@ -89,14 +124,12 @@ } }); - Artisan::call('ddd:cache'); - - $cached = [ - ...DomainCache::get('domain-providers'), - ...DomainCache::get('domain-commands'), + $discovered = [ + ...DDD::autoloader()->discoverProviders(), + ...DDD::autoloader()->discoverCommands(), ]; - expect($cached)->toEqual([]); + expect($discovered)->toHaveCount(0); expect($secret)->toEqual('i-was-invoked'); }); diff --git a/tests/Autoload/PolicyTest.php b/tests/Autoload/PolicyTest.php index 160117c..5c1859c 100644 --- a/tests/Autoload/PolicyTest.php +++ b/tests/Autoload/PolicyTest.php @@ -1,30 +1,72 @@ policies = [ + 'Domain\Invoicing\Models\Invoice' => 'Domain\Invoicing\Policies\InvoicePolicy', + 'Infrastructure\Models\AppSession' => 'Infrastructure\Policies\AppSessionPolicy', + 'Application\Models\Login' => 'Application\Policies\LoginPolicy', + ]; + $this->setupTestApplication(); - Config::set('ddd.domain_namespace', 'Domain'); - Config::set('ddd.autoload.factories', true); + DomainCache::clear(); + Artisan::call('ddd:clear'); +}); + +afterEach(function () { + DomainCache::clear(); + Artisan::call('ddd:clear'); +}); + +describe('when ddd.autoload.policies = false', function () { + it('skips handling policies', function () { + config()->set('ddd.autoload.policies', false); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); + $mock = AutoloadManager::partialMock(); + $mock->shouldNotReceive('handlePolicies'); + $mock->run(); }); }); -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'], -]); +describe('when ddd.autoload.policies = true', function () { + it('handles policies', function () { + config()->set('ddd.autoload.policies', true); + + $mock = AutoloadManager::partialMock(); + $mock->shouldReceive('handlePolicies')->once(); + $mock->run(); + }); + + it('can resolve the policies', function () { + config()->set('ddd.autoload.policies', true); + + $mock = AutoloadManager::partialMock(); + $mock->run(); + + foreach ($this->policies as $class => $expectedPolicy) { + $resolvedPolicy = Gate::getPolicyFor($class); + expect($mock->getResolvedPolicies())->toHaveKey($class); + } + })->markTestIncomplete('custom layer policies are not yet supported'); + + it('gracefully falls back for non-ddd policies', function ($class, $expectedPolicy) { + config()->set('ddd.autoload.policies', true); + + $mock = AutoloadManager::partialMock(); + $mock->run(); + + expect(class_exists($class))->toBeTrue(); + expect(Gate::getPolicyFor($class))->toBeInstanceOf($expectedPolicy); + expect($mock->getResolvedPolicies())->not->toHaveKey($class); + })->with([ + ['App\Models\Post', 'App\Policies\PostPolicy'], + ]); +}); diff --git a/tests/Autoload/ProviderTest.php b/tests/Autoload/ProviderTest.php index 6316e54..871eba6 100644 --- a/tests/Autoload/ProviderTest.php +++ b/tests/Autoload/ProviderTest.php @@ -1,77 +1,117 @@ providers = [ + 'Application\Providers\ApplicationServiceProvider', + 'Domain\Invoicing\Providers\InvoiceServiceProvider', + 'Infrastructure\Providers\InfrastructureServiceProvider', + ]; $this->setupTestApplication(); + + DomainCache::clear(); + Artisan::call('ddd:clear'); + + expect(config('ddd.autoload_ignore'))->toEqualCanonicalizing([ + 'Tests', + 'Database/Migrations', + ]); +}); + +afterEach(function () { + DomainCache::clear(); + Artisan::call('ddd:clear'); }); -describe('without autoload', function () { - beforeEach(function () { - config([ - 'ddd.autoload.providers' => false, - ]); +describe('when ddd.autoload.providers = false', function () { + it('skips handling providers', function () { + config()->set('ddd.autoload.providers', false); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); + $mock = AutoloadManager::partialMock(); + $mock->shouldNotReceive('handleProviders'); + $mock->run(); }); - it('does not register the provider', function () { - expect(fn () => app('invoicing'))->toThrow(Exception::class); + it('does not register the providers', function () { + config()->set('ddd.autoload.providers', false); + + $mock = AutoloadManager::partialMock(); + $mock->run(); + + expect($mock->getRegisteredProviders())->toBeEmpty(); + + expect(fn () => app('invoicing-singleton'))->toThrow(Exception::class); + expect(fn () => app('application-singleton'))->toThrow(Exception::class); + expect(fn () => app('infrastructure-singleton'))->toThrow(Exception::class); }); }); -describe('with autoload', function () { - beforeEach(function () { - config([ - 'ddd.autoload.providers' => true, - ]); +describe('when ddd.autoload.providers = true', function () { + it('handles the providers', function () { + config()->set('ddd.autoload.providers', true); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); + $mock = AutoloadManager::partialMock(); + $mock->shouldReceive('handleProviders')->once(); + $mock->run(); }); - it('registers the provider', function () { - expect(app('invoicing'))->toEqual('invoicing-singleton'); - $this->artisan('invoice:deliver')->expectsOutputToContain('invoice-secret'); - }); -}); + it('registers the providers', function () { + config()->set('ddd.autoload.providers', true); -describe('caching', function () { - beforeEach(function () { - config([ - 'ddd.autoload.providers' => true, - ]); + $mock = AutoloadManager::partialMock(); + $mock->run(); + + expect(DomainCache::has('domain-providers'))->toBeFalse(); + + $expected = array_values($this->providers); + $registered = array_values($mock->getRegisteredProviders()); + expect($expected)->each(fn ($item) => $item->toBeIn($registered)); + expect($registered)->toHaveCount(count($expected)); - $this->setupTestApplication(); + expect(app('application-singleton'))->toEqual('application-singleton'); + expect(app('invoicing-singleton'))->toEqual('invoicing-singleton'); + expect(app('infrastructure-singleton'))->toEqual('infrastructure-singleton'); }); +}); +describe('caching', function () { it('remembers the last cached state', function () { DomainCache::set('domain-providers', []); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); + config()->set('ddd.autoload.providers', true); - expect(fn () => app('invoicing'))->toThrow(Exception::class); + $mock = AutoloadManager::partialMock(); + $mock->run(); + + expect(DomainCache::has('domain-providers'))->toBeTrue(); + + $registered = array_values($mock->getRegisteredProviders()); + expect($registered)->toHaveCount(0); }); it('can bust the cache', function () { DomainCache::set('domain-providers', []); DomainCache::clear(); - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); - }); + config()->set('ddd.autoload.providers', true); + + $mock = AutoloadManager::partialMock(); + $mock->run(); + + $expected = array_values($this->providers); + $registered = array_values($mock->getRegisteredProviders()); + expect($expected)->each(fn ($item) => $item->toBeIn($registered)); + expect($registered)->toHaveCount(count($expected)); - expect(app('invoicing'))->toEqual('invoicing-singleton'); - $this->artisan('invoice:deliver')->expectsOutputToContain('invoice-secret'); + expect(app('application-singleton'))->toEqual('application-singleton'); + expect(app('invoicing-singleton'))->toEqual('invoicing-singleton'); + expect(app('infrastructure-singleton'))->toEqual('infrastructure-singleton'); }); }); diff --git a/tests/BootsTestApplication.php b/tests/BootsTestApplication.php new file mode 100644 index 0000000..d653a3d --- /dev/null +++ b/tests/BootsTestApplication.php @@ -0,0 +1,5 @@ +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(); -}); - -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(); - - $this->artisan('ddd:cache')->execute(); - - expect(DomainCache::get('domain-providers'))->not->toBeNull(); - expect(DomainCache::get('domain-commands'))->not->toBeNull(); - - $this->artisan('cache:clear')->execute(); - - expect(DomainCache::get('domain-providers'))->not->toBeNull(); - expect(DomainCache::get('domain-commands'))->not->toBeNull(); - - $this->artisan('optimize:clear')->execute(); - - expect(DomainCache::get('domain-providers'))->not->toBeNull(); - expect(DomainCache::get('domain-commands'))->not->toBeNull(); -}); diff --git a/tests/Command/ConfigTest.php b/tests/Command/ConfigTest.php new file mode 100644 index 0000000..5627e9f --- /dev/null +++ b/tests/Command/ConfigTest.php @@ -0,0 +1,209 @@ +setupTestApplication(); + + // $this->app->when(MigrationCreator::class) + // ->needs('$customStubPath') + // ->give(fn() => $this->app->basePath('stubs')); + + $this->originalComposerContents = file_get_contents(base_path('composer.json')); + + // $this->artisan('clear-compiled')->assertSuccessful()->execute(); + // $this->artisan('optimize:clear')->assertSuccessful()->execute(); +}); + +afterEach(function () { + $this->cleanSlate(); + + file_put_contents(base_path('composer.json'), $this->originalComposerContents); + + $this->artisan('optimize:clear')->assertSuccessful()->execute(); +}); + +it('can run the config wizard', function () { + $this->artisan('config:cache')->assertSuccessful()->execute(); + + expect(config('ddd.domain_path'))->toBe('src/Domain'); + expect(config('ddd.domain_namespace'))->toBe('Domain'); + expect(config('ddd.layers'))->toBe([ + 'Infrastructure' => 'src/Infrastructure', + ]); + + $this->reloadApplication(); + + $configPath = config_path('ddd.php'); + + $this->artisan('ddd:config') + ->expectsQuestion('Laravel-DDD Config Utility', 'wizard') + ->expectsQuestion('Domain Path', 'src/CustomDomain') + ->expectsQuestion('Domain Namespace', 'CustomDomain') + ->expectsQuestion('Path to Application Layer', null) + ->expectsQuestion('Custom Layers (Optional)', ['Support' => 'src/Support']) + ->expectsOutput('Building configuration...') + ->expectsOutput("Configuration updated: {$configPath}") + ->assertSuccessful() + ->execute(); + + expect(file_exists($configPath))->toBeTrue(); + + $this->artisan('config:cache')->assertSuccessful()->execute(); + + expect(config('ddd.domain_path'))->toBe('src/CustomDomain'); + expect(config('ddd.domain_namespace'))->toBe('CustomDomain'); + expect(config('ddd.application_path'))->toBe('src/Application'); + expect(config('ddd.application_namespace'))->toBe('Application'); + expect(config('ddd.layers'))->toBe([ + 'Support' => 'src/Support', + ]); + + $this->artisan('config:clear')->assertSuccessful()->execute(); + + unlink($configPath); +})->skip(fn () => ! ConfigCommand::hasRequiredVersionOfLaravelPrompts()); + +it('requires supported version of Laravel Prompts to run the wizard', function () { + $this->artisan('ddd:config') + ->expectsQuestion('Laravel-DDD Config Utility', 'wizard') + ->expectsOutput('This command is not supported with your currently installed version of Laravel Prompts.') + ->assertFailed() + ->execute(); +})->skip(fn () => ConfigCommand::hasRequiredVersionOfLaravelPrompts()); + +it('can update and merge ddd.php with latest package version', function () { + $configPath = config_path('ddd.php'); + + $originalContents = <<<'PHP' +artisan('ddd:config') + ->expectsQuestion('Laravel-DDD Config Utility', 'update') + ->expectsQuestion('Are you sure you want to update ddd.php and merge with latest copy from the package?', true) + ->expectsOutput('Merging ddd.php...') + ->expectsOutput("Configuration updated: {$configPath}") + ->expectsOutput('Note: Some values may require manual adjustment.') + ->assertSuccessful() + ->execute(); + + $packageConfigContents = file_get_contents(DDD::packagePath('config/ddd.php')); + + expect($updatedContents = file_get_contents($configPath)) + ->not->toEqual($originalContents); + + $updatedConfigArray = include $configPath; + $packageConfigArray = include DDD::packagePath('config/ddd.php'); + + expect($updatedConfigArray)->toHaveKeys(array_keys($packageConfigArray)); + + unlink($configPath); +}); + +it('can sync composer.json from ddd.php ', function () { + $configContent = <<<'PHP' + 'src/CustomDomain', + 'domain_namespace' => 'CustomDomain', + 'application_path' => 'src/CustomApplication', + 'application_namespace' => 'CustomApplication', + 'application_objects' => [ + 'controller', + 'request', + 'middleware', + ], + 'layers' => [ + 'Infrastructure' => 'src/Infrastructure', + 'CustomLayer' => 'src/CustomLayer', + ], +]; +PHP; + + file_put_contents(config_path('ddd.php'), $configContent); + + $this->artisan('config:cache')->assertSuccessful()->execute(); + + $composerContents = file_get_contents(base_path('composer.json')); + + $fragments = [ + '"CustomDomain\\\\": "src/CustomDomain"', + '"Infrastructure\\\\": "src/Infrastructure"', + '"CustomLayer\\\\": "src/CustomLayer"', + '"CustomApplication\\\\": "src/CustomApplication"', + ]; + + expect($composerContents)->not->toContain(...$fragments); + + $this->artisan('ddd:config') + ->expectsQuestion('Laravel-DDD Config Utility', 'composer') + ->expectsOutput('Syncing composer.json from ddd.php...') + ->expectsOutputToContain(...[ + 'Namespace', + 'Path', + 'Status', + + 'CustomDomain', + 'src/CustomDomain', + 'Added', + + 'CustomApplication', + 'src/CustomApplication', + 'Added', + + 'Infrastructure', + 'src/Infrastructure', + 'Added', + ]) + ->assertSuccessful() + ->execute(); + + $composerContents = file_get_contents(base_path('composer.json')); + + expect($composerContents)->toContain(...$fragments); + + $this->artisan('config:clear')->assertSuccessful()->execute(); + + unlink(config_path('ddd.php')); +}); + +it('can detect domain namespace from composer.json', function () { + $sampleComposer = file_get_contents(__DIR__.'/resources/composer.sample.json'); + + file_put_contents( + app()->basePath('composer.json'), + $sampleComposer + ); + + $configPath = config_path('ddd.php'); + + $this->artisan('ddd:config') + ->expectsQuestion('Laravel-DDD Config Utility', 'detect') + ->expectsOutputToContain(...[ + 'Detected configuration:', + 'domain_path', + 'lib/CustomDomain', + 'domain_namespace', + 'Domain', + ]) + ->expectsQuestion('Update configuration with these values?', true) + ->expectsOutput('Configuration updated: '.$configPath) + ->assertSuccessful() + ->execute(); + + $configValues = DDD::config()->get(); + + expect(data_get($configValues, 'domain_path'))->toBe('lib/CustomDomain'); + expect(data_get($configValues, 'domain_namespace'))->toBe('Domain'); + + unlink($configPath); +}); diff --git a/tests/Command/InstallTest.php b/tests/Command/InstallTest.php index 2be26aa..54bbc2a 100644 --- a/tests/Command/InstallTest.php +++ b/tests/Command/InstallTest.php @@ -3,9 +3,14 @@ use Illuminate\Support\Facades\Config; beforeEach(function () { + $this->originalComposerContents = file_get_contents(base_path('composer.json')); $this->setupTestApplication(); }); +afterEach(function () { + file_put_contents(base_path('composer.json'), $this->originalComposerContents); +}); + it('publishes config', function () { $path = config_path('ddd.php'); @@ -15,11 +20,11 @@ expect(file_exists($path))->toBeFalse(); - $command = $this->artisan('ddd:install'); - $command->expectsOutput('Publishing config...'); - $command->expectsOutput('Ensuring domain path is registered in composer.json...'); - $command->expectsConfirmation('Would you like to publish stubs?', 'no'); - $command->execute(); + $command = $this->artisan('ddd:install') + ->expectsOutput('Publishing config...') + ->expectsOutput('Updating composer.json...') + ->expectsQuestion('Would you like to publish stubs now?', false) + ->execute(); expect(file_exists($path))->toBeTrue(); expect(file_get_contents($path))->toEqual(file_get_contents(__DIR__.'/../../config/ddd.php')); @@ -42,9 +47,9 @@ $before = data_get($data, ['autoload', 'psr-4', $domainRoot.'\\']); expect($before)->toBeNull(); - $command = $this->artisan('ddd:install'); - $command->expectsConfirmation('Would you like to publish stubs?', 'no'); - $command->execute(); + $command = $this->artisan('ddd:install') + ->expectsQuestion('Would you like to publish stubs now?', false) + ->execute(); $data = json_decode(file_get_contents(base_path('composer.json')), true); $after = data_get($data, ['autoload', 'psr-4', $domainRoot.'\\']); @@ -53,7 +58,7 @@ unlink(config_path('ddd.php')); // Reset composer back to the factory state - $this->setDomainPathInComposer('Domain', 'src/Domain', reload: true); + $this->setAutoloadPathInComposer('Domain', 'src/Domain', reload: true); })->with([ ['src/Domain', 'Domain'], ['src/Domains', 'Domains'], diff --git a/tests/Command/ListTest.php b/tests/Command/ListTest.php index 36afcee..48e067c 100644 --- a/tests/Command/ListTest.php +++ b/tests/Command/ListTest.php @@ -1,8 +1,11 @@ setupTestApplication(); + $this->artisan('ddd:model', [ 'name' => 'Invoice', '--domain' => 'Invoicing', @@ -13,6 +16,11 @@ '--domain' => 'Customer', ]); + $this->artisan('ddd:value', [ + 'name' => 'Subtotal', + '--domain' => 'Shared', + ]); + $this->expectedDomains = [ 'Customer', 'Invoicing', @@ -33,9 +41,10 @@ $this ->artisan('ddd:list') - ->expectsTable([ + ->expectsOutputToContain(...[ 'Domain', 'Namespace', 'Path', - ], $expectedTableContent); + ...Arr::flatten($expectedTableContent), + ]); }); diff --git a/tests/Command/OptimizeTest.php b/tests/Command/OptimizeTest.php new file mode 100644 index 0000000..f2db8a4 --- /dev/null +++ b/tests/Command/OptimizeTest.php @@ -0,0 +1,122 @@ +setupTestApplication(); + + DomainCache::clear(); + + $this->originalComposerContents = file_get_contents(base_path('composer.json')); +}); + +afterEach(function () { + DomainCache::clear(); + + file_put_contents(base_path('composer.json'), $this->originalComposerContents); + + $this->artisan('optimize:clear')->assertSuccessful()->execute(); +}); + +it('can optimize 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:optimize') + ->expectsOutputToContain('Caching DDD providers, commands, migration paths.') + ->expectsOutputToContain('domain providers') + ->expectsOutputToContain('domain commands') + ->expectsOutputToContain('domain migration paths') + ->execute(); + + expect(DomainCache::get('domain-providers')) + ->toContain('Domain\Invoicing\Providers\InvoiceServiceProvider'); + + 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 () { + $this->artisan('ddd:optimize')->assertSuccessful()->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('ddd:clear') + ->expectsOutputToContain('Domain cache cleared successfully.') + ->execute(); + + 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 () { + expect(DomainCache::get('domain-providers'))->toBeNull(); + expect(DomainCache::get('domain-commands'))->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->toBeNull(); + + $this->artisan('ddd:optimize')->assertSuccessful()->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')->assertSuccessful()->execute(); + + expect(DomainCache::get('domain-providers'))->not->toBeNull(); + expect(DomainCache::get('domain-commands'))->not->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->not->toBeNull(); + + if (Feature::LaravelPackageOptimizeCommands->missing()) { + $this->artisan('optimize:clear')->assertSuccessful()->execute(); + + 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:optimize', function () { + expect(DomainCache::get('domain-providers'))->toBeNull(); + expect(DomainCache::get('domain-commands'))->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->toBeNull(); + + $this->artisan('optimize')->assertSuccessful()->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')->assertSuccessful()->execute(); + }); + + test('optimize:clear will clear ddd cache', function () { + $this->artisan('ddd:optimize')->assertSuccessful()->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')->assertSuccessful()->execute(); + + expect(DomainCache::get('domain-providers'))->toBeNull(); + expect(DomainCache::get('domain-commands'))->toBeNull(); + expect(DomainCache::get('domain-migration-paths'))->toBeNull(); + + $this->artisan('optimize:clear')->assertSuccessful()->execute(); + }); +})->skipOnLaravelVersionsBelow(Feature::LaravelPackageOptimizeCommands->value); diff --git a/tests/Command/PublishTest.php b/tests/Command/PublishTest.php new file mode 100644 index 0000000..76dac3c --- /dev/null +++ b/tests/Command/PublishTest.php @@ -0,0 +1,116 @@ +cleanSlate(); + + $path = app()->configPath('ddd.php'); + + if (file_exists($path)) { + unlink($path); + } + + expect(file_exists($path))->toBeFalse(); + + $publishedStubFolder = app()->basePath('stubs/ddd'); + + File::deleteDirectory($publishedStubFolder); + + assertDirectoryDoesNotExist($publishedStubFolder); +}); + +afterEach(function () { + $this->cleanSlate(); +}); + +it('can publish config using --config option', function () { + $path = app()->configPath('ddd.php'); + + $this + ->artisan('ddd:publish --config') + ->expectsOutputToContain('Publishing config...') + ->doesntExpectOutput('Publishing stubs...') + ->assertSuccessful() + ->execute(); + + expect(file_exists($path))->toBeTrue(); +}); + +it('can publish everything', function ($options) { + $path = app()->configPath('ddd.php'); + $publishedStubFolder = app()->basePath('stubs/ddd'); + + $this + ->artisan('ddd:publish', $options) + ->expectsOutputToContain('Publishing config...') + ->expectsOutputToContain('Publishing stubs...') + ->assertSuccessful() + ->execute(); + + expect(file_exists($path))->toBeTrue(); + + assertDirectoryExists($publishedStubFolder); + + $stubFiles = File::files($publishedStubFolder); + + expect(count($stubFiles))->toBeGreaterThan(0); +})->with([ + '--all' => [['--all' => true]], + '--config --stubs' => [['--config' => true, '--stubs' => true]], +]); + +it('can publish config interactively', function () { + $path = app()->configPath('ddd.php'); + + $this + ->artisan('ddd:publish') + ->expectsQuestion('What should be published?', ['config']) + ->expectsOutputToContain('Publishing config...') + ->doesntExpectOutput('Publishing stubs...') + ->assertSuccessful() + ->execute(); + + expect(file_exists($path))->toBeTrue(); +}); + +it('can publish stubs interactively', function () { + $path = app()->configPath('ddd.php'); + $publishedStubFolder = app()->basePath('stubs/ddd'); + + $this + ->artisan('ddd:publish') + ->expectsQuestion('What should be published?', ['stubs']) + ->expectsOutputToContain('Publishing stubs...') + ->doesntExpectOutput('Publishing config...') + ->assertSuccessful() + ->execute(); + + expect(file_exists($path))->toBeFalse(); + + assertDirectoryExists($publishedStubFolder); +}); + +it('can publish everything interactively', function () { + $path = app()->configPath('ddd.php'); + $publishedStubFolder = app()->basePath('stubs/ddd'); + + $this + ->artisan('ddd:publish') + ->expectsQuestion('What should be published?', ['config', 'stubs']) + ->expectsOutputToContain('Publishing config...') + ->expectsOutputToContain('Publishing stubs...') + ->assertSuccessful() + ->execute(); + + expect(file_exists($path))->toBeTrue(); + + assertDirectoryExists($publishedStubFolder); + + $stubFiles = File::files($publishedStubFolder); + + expect(count($stubFiles))->toBeGreaterThan(0); +}); diff --git a/tests/Command/StubTest.php b/tests/Command/StubTest.php new file mode 100644 index 0000000..abdfbd3 --- /dev/null +++ b/tests/Command/StubTest.php @@ -0,0 +1,182 @@ +cleanSlate(); + + $publishedStubFolder = app()->basePath('stubs/ddd'); + + File::deleteDirectory($publishedStubFolder); + + assertDirectoryDoesNotExist($publishedStubFolder); +}); + +afterEach(function () { + $this->cleanSlate(); +}); + +it('can publish all stubs using --all option', function () { + $this + ->artisan('ddd:stub --all') + ->doesntExpectOutput('Publishing stubs...') + ->assertSuccessful() + ->execute(); + + $publishedStubFolder = app()->basePath('stubs/ddd'); + + assertDirectoryExists($publishedStubFolder); + + $stubFiles = File::files($publishedStubFolder); + + expect(count($stubFiles))->toBeGreaterThan(0); + + expect(count($stubFiles))->toEqual(count([ + ...app('ddd')->stubs()->dddStubs(), + ...app('ddd')->stubs()->frameworkStubs(), + ])); +}); + +it('can publish all stubs interactively', function () { + $path = app()->configPath('ddd.php'); + $publishedStubFolder = app()->basePath('stubs/ddd'); + + $this + ->artisan('ddd:stub') + ->expectsQuestion('What do you want to do?', 'all') + ->assertSuccessful() + ->execute(); + + expect(file_exists($path))->toBeFalse(); + + assertDirectoryExists($publishedStubFolder); + + $stubFiles = File::files($publishedStubFolder); + + expect(count($stubFiles))->toBeGreaterThan(0); + + expect(count($stubFiles))->toEqual(count([ + ...app('ddd')->stubs()->dddStubs(), + ...app('ddd')->stubs()->frameworkStubs(), + ])); +}); + +it('can publish specific stubs using arguments', function ($stubsToPublish) { + $expectedStubFilenames = collect($stubsToPublish) + ->map(fn ($stub) => $stub.'.stub') + ->all(); + + $arguments = collect($stubsToPublish)->join(' '); + + $this + ->artisan("ddd:stub {$arguments}") + ->assertSuccessful() + ->execute(); + + $publishedStubFolder = app()->basePath('stubs/ddd'); + + assertDirectoryExists($publishedStubFolder); + + $stubFiles = File::files($publishedStubFolder); + + expect(count($stubFiles))->toEqual(count($stubsToPublish)); + + foreach ($stubFiles as $file) { + expect($file->getFilename())->toBeIn($expectedStubFilenames); + } +})->with([ + 'model' => [['model']], + 'model/action/dto' => [['model', 'action', 'dto']], + 'model/model.pivot' => [['model', 'model.pivot']], + 'controller' => [['controller']], +]); + +it('can publish stubs using wildcard', function ($argument, $stubsToPublish) { + $expectedStubFilenames = collect($stubsToPublish) + ->map(fn ($stub) => $stub.'.stub') + ->all(); + + $this + ->artisan("ddd:stub {$argument}") + ->assertSuccessful() + ->execute(); + + $publishedStubFolder = app()->basePath('stubs/ddd'); + + assertDirectoryExists($publishedStubFolder); + + $stubFiles = File::files($publishedStubFolder); + + expect(count($stubFiles))->toEqual(count($stubsToPublish)); + + foreach ($stubFiles as $file) { + expect($file->getFilename())->toBeIn($expectedStubFilenames); + } +})->with([ + 'model*' => ['model*', ['model', 'model.pivot']], + 'model.' => ['model.', ['model', 'model.pivot']], + 'policy.' => ['policy.', ['policy', 'policy.plain']], +]); + +it('can publish stubs using wildcard (laravel 11 stubs)', function ($argument, $stubsToPublish) { + $expectedStubFilenames = collect($stubsToPublish) + ->map(fn ($stub) => $stub.'.stub') + ->all(); + + $this + ->artisan("ddd:stub {$argument}") + ->assertSuccessful() + ->execute(); + + $publishedStubFolder = app()->basePath('stubs/ddd'); + + assertDirectoryExists($publishedStubFolder); + + $stubFiles = File::files($publishedStubFolder); + + expect(count($stubFiles))->toEqual(count($stubsToPublish)); + + foreach ($stubFiles as $file) { + expect($file->getFilename())->toBeIn($expectedStubFilenames); + } +})->with([ + 'listener*' => ['listener*', ['listener', 'listener.typed', 'listener.queued', 'listener.typed.queued']], + 'listener.' => ['listener.', ['listener', 'listener.typed', 'listener.queued', 'listener.typed.queued']], + 'enum.' => ['enum.', ['enum', 'enum.backed']], +])->skipOnLaravelVersionsBelow(11); + +it('can publish specific stubs interactively', function () { + $publishedStubFolder = app()->basePath('stubs/ddd'); + + assertDirectoryDoesNotExist($publishedStubFolder); + + $options = app('ddd')->stubs()->allStubs(); + + $matches = collect($options) + ->filter(fn ($stub, $path) => str($stub)->contains('model')) + ->all(); + + $this + ->artisan('ddd:stub') + ->expectsQuestion('What do you want to do?', 'some') + ->expectsSearch( + 'Which stub should be published?', + search: 'model', + answers: $matches, + answer: ['model.stub'] + ) + ->assertSuccessful() + ->execute(); + + assertDirectoryExists($publishedStubFolder); + + $stubFiles = File::files($publishedStubFolder); + + expect(count($stubFiles))->toEqual(1); + + expect($stubFiles[0]->getFilename())->toEqual('model.stub'); +})->skip(fn () => Feature::PromptMultiSearchAssertion->missing(), 'Multi-search assertion not available'); diff --git a/tests/Command/UpgradeTest.php b/tests/Command/UpgradeTest.php index 9d649e6..1ddb156 100644 --- a/tests/Command/UpgradeTest.php +++ b/tests/Command/UpgradeTest.php @@ -5,11 +5,11 @@ use Illuminate\Support\Facades\File; it('can upgrade 0.x config to 1.x', function (string $pathToOldConfig, array $expectedValues) { - $path = config_path('ddd.php'); + $configFilePath = config_path('ddd.php'); - File::copy($pathToOldConfig, $path); + File::copy($pathToOldConfig, $configFilePath); - expect(file_exists($path))->toBeTrue(); + expect(file_exists($configFilePath))->toBeTrue(); $this->artisan('ddd:upgrade') ->expectsOutputToContain('Configuration upgraded successfully.') @@ -21,10 +21,13 @@ $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."); + foreach ($expectedValues as $key => $value) { + expect(data_get($configAsArray, $key)) + ->toEqual($value, "Config {$key} does not match expected value."); } + + // Delete the config file after the test + unlink($configFilePath); })->with('configUpgrades'); it('skips upgrade if config file was not published', function () { diff --git a/tests/Command/resources/composer.sample.json b/tests/Command/resources/composer.sample.json new file mode 100644 index 0000000..61f8be5 --- /dev/null +++ b/tests/Command/resources/composer.sample.json @@ -0,0 +1,26 @@ +{ + "name": "laravel/laravel", + "description": "The Laravel Framework.", + "keywords": [ + "framework", + "laravel" + ], + "license": "MIT", + "type": "project", + "autoload": { + "classmap": [ + "database", + "tests/TestCase.php" + ], + "psr-4": { + "App\\": "app/", + "Domain\\": "lib/CustomDomain" + } + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "minimum-stability": "dev" +} diff --git a/tests/Config/ManagerTest.php b/tests/Config/ManagerTest.php new file mode 100644 index 0000000..14f3e35 --- /dev/null +++ b/tests/Config/ManagerTest.php @@ -0,0 +1,55 @@ +cleanSlate(); + + $this->latestConfig = require DDD::packagePath('config/ddd.php'); +}); + +afterEach(function () { + $this->setupTestApplication(); + Artisan::call('optimize:clear'); +}); + +it('can update and merge current config file with latest copy from package', function () { + $path = __DIR__.'/resources/config.sparse.php'; + + File::copy($path, config_path('ddd.php')); + + expect(file_exists($path))->toBeTrue(); + + $originalContents = file_get_contents($path); + + expect(file_get_contents(config_path('ddd.php')))->toEqual($originalContents); + + $original = include $path; + + $config = DDD::config(); + + $config->syncWithLatest()->save(); + + $updatedContents = file_get_contents(config_path('ddd.php')); + + expect($updatedContents)->not->toEqual($originalContents); + + $updatedConfig = include config_path('ddd.php'); + + // Expect original values to be retained + foreach ($original as $key => $value) { + if (is_array($value)) { + // We won't worry about arrays for now + continue; + } + + expect($updatedConfig[$key])->toEqual($value); + } + + // Expect the updated config to have all top-level keys from the latest config + expect($updatedConfig)->toHaveKeys(array_keys($this->latestConfig)); + + unlink(config_path('ddd.php')); +}); diff --git a/tests/Config/resources/config.sparse.php b/tests/Config/resources/config.sparse.php new file mode 100644 index 0000000..78bca52 --- /dev/null +++ b/tests/Config/resources/config.sparse.php @@ -0,0 +1,23 @@ + 'src/CustomDomainFolder', + 'domain_namespace' => 'CustomDomainNamespace', + 'application_objects' => [ + 'keepthis', + ], + 'namespaces' => [ + 'model' => 'CustomModels', + 'data_transfer_object' => 'CustomData', + 'view_model' => 'CustomViewModels', + 'value_object' => 'CustomValueObjects', + 'action' => 'CustomActions', + ], + 'base_model' => 'Domain\Shared\Models\CustomBaseModel', + 'base_dto' => 'Spatie\LaravelData\Data', + 'base_view_model' => 'Domain\Shared\ViewModels\CustomViewModel', + 'base_action' => null, + 'autoload' => [ + 'migrations' => false, + ], +]; diff --git a/tests/Datasets/GeneratorSchemas.php b/tests/Datasets/GeneratorSchemas.php new file mode 100644 index 0000000..c899f7e --- /dev/null +++ b/tests/Datasets/GeneratorSchemas.php @@ -0,0 +1,27 @@ + [ + 'ddd:model', + 'Invoice', + 'Invoicing', + [ + 'name' => 'Invoice', + 'namespace' => 'Domain\Invoicing\Models', + 'fullyQualifiedName' => 'Domain\Invoicing\Models\Invoice', + 'path' => 'src/Domain/Invoicing/Models/Invoice.php', + ], + ], + + 'ddd:model Invoicing:Invoice' => [ + 'ddd:model', + 'InvoicingEntry', + 'Invoicing', + [ + 'name' => 'Invoice', + 'namespace' => 'Domain\Invoicing\Models', + 'fullyQualifiedName' => 'Domain\Invoicing\Models\Invoice', + 'path' => 'src/Domain/Invoicing/Models/Invoice.php', + ], + ], +]); diff --git a/tests/Datasets/resources/config.0.10.0.php b/tests/Datasets/resources/config.0.10.0.php index 59e9940..ea52a19 100644 --- a/tests/Datasets/resources/config.0.10.0.php +++ b/tests/Datasets/resources/config.0.10.0.php @@ -2,41 +2,41 @@ return [ /* - |-------------------------------------------------------------------------- - | Domain Path - |-------------------------------------------------------------------------- - | - | The path to the domain folder relative to the application root. - | - */ + |-------------------------------------------------------------------------- + | Domain Path + |-------------------------------------------------------------------------- + | + | The path to the domain folder relative to the application root. + | + */ 'domain_path' => 'src/CustomDomainFolder', /* - |-------------------------------------------------------------------------- - | Domain Namespace - |-------------------------------------------------------------------------- - | - | The root domain namespace. - | - */ + |-------------------------------------------------------------------------- + | 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/* - | - */ + |-------------------------------------------------------------------------- + | 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', @@ -46,51 +46,51 @@ ], /* - |-------------------------------------------------------------------------- - | 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 + |-------------------------------------------------------------------------- + | + | 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 + |-------------------------------------------------------------------------- + | + | 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 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 + |-------------------------------------------------------------------------- + | + | 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/Expectations.php b/tests/Expectations.php index f2cdd6d..8e4d405 100644 --- a/tests/Expectations.php +++ b/tests/Expectations.php @@ -15,6 +15,10 @@ return $this->toContain(Path::normalize($path)); }); +expect()->extend('toEqualPath', function ($path) { + return $this->toEqual(Path::normalize($path)); +}); + expect()->extend('toGenerateFileWithNamespace', function ($expectedPath, $expectedNamespace) { $command = $this->value; diff --git a/tests/Factory/DomainFactoryTest.php b/tests/Factory/DomainFactoryTest.php index 7c44d46..217ad26 100644 --- a/tests/Factory/DomainFactoryTest.php +++ b/tests/Factory/DomainFactoryTest.php @@ -1,9 +1,12 @@ setupTestApplication(); @@ -26,8 +29,19 @@ ]); it('can instantiate a domain model factory', function ($domainParameter, $modelName, $modelClass) { - Config::set('ddd.base_model', 'Lunarstorm\LaravelDDD\Models\DomainModel'); + $this->setupTestApplication(); + + $this->afterApplicationRefreshed(function () { + app('ddd.autoloader')->boot(); + }); + + $this->refreshApplicationWithConfig([ + 'ddd.base_model' => 'Lunarstorm\LaravelDDD\Models\DomainModel', + 'ddd.autoload.factories' => true, + ]); + 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 21c2aa1..d43587c 100644 --- a/tests/Fixtures/Enums/Feature.php +++ b/tests/Fixtures/Enums/Feature.php @@ -6,7 +6,10 @@ enum Feature: string { case PromptForMissingInput = '9.49.0'; case IncludeFilepathInGeneratorCommandOutput = '9.32.0'; + case Laravel11 = '11.0.0'; case LaravelPromptsPackage = '10.17'; + case LaravelPackageOptimizeCommands = '11.27.1'; + case PromptMultiSearchAssertion = '11.30.0'; public function exists(): bool { diff --git a/tests/Generator/MakeActionTest.php b/tests/Generator/ActionMakeTest.php similarity index 96% rename from tests/Generator/MakeActionTest.php rename to tests/Generator/ActionMakeTest.php index 2bdb1f2..52148fb 100644 --- a/tests/Generator/MakeActionTest.php +++ b/tests/Generator/ActionMakeTest.php @@ -57,7 +57,7 @@ Artisan::call("ddd:action {$domain}:{$given}"); - expect(file_exists($expectedPath))->toBeTrue(); + expect(file_exists($expectedPath))->toBeTrue("ddd:action {$domain}:{$given} -> expected {$expectedPath} to exist."); })->with('makeActionInputs'); it('extends a base action if specified in config', function ($baseAction) { 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..c447f0b --- /dev/null +++ b/tests/Generator/ControllerMakeTest.php @@ -0,0 +1,293 @@ +cleanSlate(); + $this->setupTestApplication(); + + Config::set([ + 'ddd.domain_path' => 'src/Domain', + 'ddd.domain_namespace' => 'Domain', + 'ddd.application_path' => 'app/Modules', + 'ddd.application_namespace' => 'App\Modules', + 'ddd.application_objects' => ['controller', 'request'], + ]); +}); + +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_exists(app_path('Http/Controllers/Controller.php')))->toBeTrue(); + + $contents = file_get_contents($expectedPath); + + expect($contents) + ->toContain("namespace {$expectedNamespace};") + ->toContain("use App\Http\Controllers\Controller;\nuse Illuminate\Http\Request;") + ->toContain('extends Controller'); +})->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(); + + $modelExists = class_exists($modelClass); + + $command = $this->artisan('ddd:controller', [ + 'name' => $controllerName, + '--domain' => $domainName, + '--model' => $modelName, + ]); + + if (! $modelExists) { + $command->expectsQuestion("A {$modelClass} model does not exist. Do you want to generate it?", false); + } + + $command->assertSuccessful()->execute(); + + 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); + } + } + + $modelExists = class_exists($modelClass); + + $command = $this->artisan('ddd:controller', [ + 'name' => $controllerName, + '--domain' => $domainName, + '--model' => $modelName, + '--requests' => true, + ]); + + if (! $modelExists) { + $command->expectsQuestion("A {$modelClass} model does not exist. Do you want to generate it?", false); + } + + foreach ($generatedPaths as $path) { + if (Feature::IncludeFilepathInGeneratorCommandOutput->exists()) { + $command->expectsOutputToContain(Path::normalize($path)); + } + } + + $command->assertSuccessful()->execute(); + + foreach ($generatedPaths as $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', + ], + ], +]); + +it('does not extend base controller if base controller not found', function ($domainName, $controllerName, $relativePath, $expectedNamespace) { + $expectedPath = base_path($relativePath); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + expect(file_exists($expectedPath))->toBeFalse(); + + // Remove the base controller + $baseControllerPath = app_path('Http/Controllers/Controller.php'); + + if (file_exists($baseControllerPath)) { + unlink($baseControllerPath); + } + + expect(file_exists($baseControllerPath))->toBeFalse(); + + $this->artisan("ddd:controller {$domainName}:{$controllerName}") + ->assertSuccessful() + ->execute(); + + expect(file_exists($expectedPath))->toBeTrue(); + + expect($contents = file_get_contents($expectedPath)) + ->toContain("namespace {$expectedNamespace};"); + + expect($contents) + ->not->toContain("use App\Http\Controllers\Controller;") + ->not->toContain('extends Controller'); +})->with([ + 'Invoicing:InvoiceController' => [ + 'Invoicing', + 'InvoiceController', + 'app/Modules/Invoicing/Controllers/InvoiceController.php', + 'App\Modules\Invoicing\Controllers', + ], +]); + +it('does not attempt to extend base controller when using custom stubs', function ($domainName, $controllerName, $relativePath, $expectedNamespace, $stubFolder) { + $expectedPath = base_path($relativePath); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + expect(file_exists($expectedPath))->toBeFalse(); + + $baseControllerPath = app()->basePath('app/Http/Controllers/Controller.php'); + + expect(file_exists($baseControllerPath))->toBeTrue(); + + // Publish a custom controller.stub + $customStub = <<<'STUB' +basePath($stubFolder)); + file_put_contents(app()->basePath($stubFolder.'/controller.plain.stub'), $customStub); + expect(file_exists(app()->basePath($stubFolder.'/controller.plain.stub')))->toBeTrue(); + + $this->artisan("ddd:controller {$domainName}:{$controllerName}") + ->assertSuccessful() + ->execute(); + + expect(file_exists($expectedPath))->toBeTrue(); + + expect(file_get_contents($expectedPath)) + ->toContain("namespace {$expectedNamespace};") + ->toContain('use CustomControllerTrait;') + ->not->toContain("use App\Http\Controllers\Controller;") + ->not->toContain('extends Controller'); + + $this->cleanStubs(); +})->with([ + 'Invoicing:InvoiceController' => [ + 'Invoicing', + 'InvoiceController', + 'app/Modules/Invoicing/Controllers/InvoiceController.php', + 'App\Modules\Invoicing\Controllers', + ], +])->with([ + 'stubs', + 'stubs/ddd', +]); diff --git a/tests/Generator/CustomLayerTest.php b/tests/Generator/CustomLayerTest.php new file mode 100644 index 0000000..8c470ca --- /dev/null +++ b/tests/Generator/CustomLayerTest.php @@ -0,0 +1,92 @@ +cleanSlate(); + + Config::set('ddd.layers', [ + 'CustomLayer' => 'src/CustomLayer', + ]); +}); + +it('can generate objects into custom layers', function ($type, $objectName, $expectedNamespace, $expectedPath) { + if (in_array($type, ['class', 'enum', 'interface', 'trait'])) { + skipOnLaravelVersionsBelow('11'); + } + + $relativePath = $expectedPath; + $expectedPath = base_path($relativePath); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + expect(file_exists($expectedPath))->toBeFalse(); + + $command = "ddd:{$type} CustomLayer:{$objectName}"; + + Artisan::call($command); + + expect(Artisan::output())->toContainFilepath($relativePath); + + expect(file_exists($expectedPath))->toBeTrue(); + + expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); +})->with([ + 'action' => ['action', 'CustomLayerAction', 'CustomLayer\Actions', 'src/CustomLayer/Actions/CustomLayerAction.php'], + 'cast' => ['cast', 'CustomLayerCast', 'CustomLayer\Casts', 'src/CustomLayer/Casts/CustomLayerCast.php'], + 'channel' => ['channel', 'CustomLayerChannel', 'CustomLayer\Channels', 'src/CustomLayer/Channels/CustomLayerChannel.php'], + 'command' => ['command', 'CustomLayerCommand', 'CustomLayer\Commands', 'src/CustomLayer/Commands/CustomLayerCommand.php'], + 'event' => ['event', 'CustomLayerEvent', 'CustomLayer\Events', 'src/CustomLayer/Events/CustomLayerEvent.php'], + 'exception' => ['exception', 'CustomLayerException', 'CustomLayer\Exceptions', 'src/CustomLayer/Exceptions/CustomLayerException.php'], + 'job' => ['job', 'CustomLayerJob', 'CustomLayer\Jobs', 'src/CustomLayer/Jobs/CustomLayerJob.php'], + 'listener' => ['listener', 'CustomLayerListener', 'CustomLayer\Listeners', 'src/CustomLayer/Listeners/CustomLayerListener.php'], + 'mail' => ['mail', 'CustomLayerMail', 'CustomLayer\Mail', 'src/CustomLayer/Mail/CustomLayerMail.php'], + 'notification' => ['notification', 'CustomLayerNotification', 'CustomLayer\Notifications', 'src/CustomLayer/Notifications/CustomLayerNotification.php'], + 'observer' => ['observer', 'CustomLayerObserver', 'CustomLayer\Observers', 'src/CustomLayer/Observers/CustomLayerObserver.php'], + 'policy' => ['policy', 'CustomLayerPolicy', 'CustomLayer\Policies', 'src/CustomLayer/Policies/CustomLayerPolicy.php'], + 'provider' => ['provider', 'CustomLayerServiceProvider', 'CustomLayer\Providers', 'src/CustomLayer/Providers/CustomLayerServiceProvider.php'], + 'resource' => ['resource', 'CustomLayerResource', 'CustomLayer\Resources', 'src/CustomLayer/Resources/CustomLayerResource.php'], + 'rule' => ['rule', 'CustomLayerRule', 'CustomLayer\Rules', 'src/CustomLayer/Rules/CustomLayerRule.php'], + 'scope' => ['scope', 'CustomLayerScope', 'CustomLayer\Scopes', 'src/CustomLayer/Scopes/CustomLayerScope.php'], + 'seeder' => ['seeder', 'CustomLayerSeeder', 'CustomLayer\Database\Seeders', 'src/CustomLayer/Database/Seeders/CustomLayerSeeder.php'], + 'class' => ['class', 'CustomLayerClass', 'CustomLayer', 'src/CustomLayer/CustomLayerClass.php'], + 'enum' => ['enum', 'CustomLayerEnum', 'CustomLayer\Enums', 'src/CustomLayer/Enums/CustomLayerEnum.php'], + 'interface' => ['interface', 'CustomLayerInterface', 'CustomLayer', 'src/CustomLayer/CustomLayerInterface.php'], + 'trait' => ['trait', 'CustomLayerTrait', 'CustomLayer', 'src/CustomLayer/CustomLayerTrait.php'], +]); + +it('ignores custom layer if object belongs in the application layer', function ($type, $objectName, $expectedNamespace, $expectedPath) { + Config::set([ + 'ddd.application_namespace' => 'Application', + 'ddd.application_path' => 'src/Application', + 'ddd.application_objects' => [ + $type, + ], + ]); + + $relativePath = $expectedPath; + $expectedPath = base_path($relativePath); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + + expect(file_exists($expectedPath))->toBeFalse(); + + $command = "ddd:{$type} CustomLayer:{$objectName}"; + + Artisan::call($command); + + expect(Artisan::output())->toContainFilepath($relativePath); + + expect(file_exists($expectedPath))->toBeTrue(); + + expect(file_get_contents($expectedPath))->toContain("namespace {$expectedNamespace};"); +})->with([ + 'request' => ['request', 'CustomLayerRequest', 'Application\CustomLayer\Requests', 'src/Application/CustomLayer/Requests/CustomLayerRequest.php'], + 'controller' => ['controller', 'CustomLayerController', 'Application\CustomLayer\Controllers', 'src/Application/CustomLayer/Controllers/CustomLayerController.php'], + 'middleware' => ['middleware', 'CustomLayerMiddleware', 'Application\CustomLayer\Middleware', 'src/Application/CustomLayer/Middleware/CustomLayerMiddleware.php'], +]); diff --git a/tests/Generator/MakeDataTransferObjectTest.php b/tests/Generator/DtoMakeTest.php similarity index 100% rename from tests/Generator/MakeDataTransferObjectTest.php rename to tests/Generator/DtoMakeTest.php diff --git a/tests/Generator/ExtendedCommandsTest.php b/tests/Generator/ExtendedMakeTest.php similarity index 86% rename from tests/Generator/ExtendedCommandsTest.php rename to tests/Generator/ExtendedMakeTest.php index 4c0819f..ce8a0be 100644 --- a/tests/Generator/ExtendedCommandsTest.php +++ b/tests/Generator/ExtendedMakeTest.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Config; use Lunarstorm\LaravelDDD\Support\Domain; -it('can generate extended objects', function ($type, $objectName, $domainPath, $domainRoot) { +it('can generate other objects', function ($type, $objectName, $domainPath, $domainRoot) { if (in_array($type, ['class', 'enum', 'interface', 'trait'])) { skipOnLaravelVersionsBelow('11'); } @@ -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 69% rename from tests/Generator/MakeModelTest.php rename to tests/Generator/Model/MakeTest.php index 8772f20..5367043 100644 --- a/tests/Generator/MakeModelTest.php +++ b/tests/Generator/Model/MakeTest.php @@ -2,9 +2,15 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\File; use Lunarstorm\LaravelDDD\Support\Domain; use Lunarstorm\LaravelDDD\Tests\Fixtures\Enums\Feature; +beforeEach(function () { + $this->cleanSlate(); + $this->setupTestApplication(); +}); + it('can generate domain models', function ($domainPath, $domainRoot) { Config::set('ddd.domain_path', $domainPath); Config::set('ddd.domain_namespace', $domainRoot); @@ -42,7 +48,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 +186,84 @@ ['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'], +]); + +it('does not attempt to extend custom base models when using custom stubs', function ($baseModelClass, $baseModelName, $stubFolder) { + 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); + } + + // Publish a custom stub + $customStub = <<<'STUB' +basePath($stubFolder)); + file_put_contents(app()->basePath($stubFolder.'/model.stub'), $customStub); + expect(file_exists(app()->basePath($stubFolder.'/model.stub')))->toBeTrue(); + + Artisan::call("ddd:model {$domain}:{$modelName}"); + + expect(file_exists($expectedModelPath))->toBeTrue(); + + expect(file_get_contents($expectedModelPath)) + ->toContain('use CustomModelTrait;') + ->not->toContain("use {$baseModelClass};") + ->not->toContain("extends {$baseModelName}"); + + $this->cleanStubs(); +})->with([ + ['Domain\Shared\Models\BaseModel', 'BaseModel'], +])->with([ + 'stubs', + 'stubs/ddd', +]); diff --git a/tests/Generator/Model/MakeWithControllerTest.php b/tests/Generator/Model/MakeWithControllerTest.php new file mode 100644 index 0000000..2418ad3 --- /dev/null +++ b/tests/Generator/Model/MakeWithControllerTest.php @@ -0,0 +1,84 @@ + 'src/Domain', + 'ddd.domain_namespace' => 'Domain', + 'ddd.base_model' => DomainModel::class, + 'ddd.application_namespace' => 'App\Modules', + 'ddd.application_path' => 'app/Modules', + 'ddd.application_objects' => [ + 'controller', + ], + ]); + + $this->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', + ], + ], +]); diff --git a/tests/Generator/Model/MakeWithOptionsTest.php b/tests/Generator/Model/MakeWithOptionsTest.php new file mode 100644 index 0000000..aae2c98 --- /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..5a584b1 --- /dev/null +++ b/tests/Generator/RequestMakeTest.php @@ -0,0 +1,53 @@ + 'src/Domain', + 'ddd.domain_namespace' => 'Domain', + 'ddd.application_path' => 'app/Modules', + 'ddd.application_namespace' => 'App\Modules', + 'ddd.application_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/Pest.php b/tests/Pest.php index 1691c65..da75808 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -9,7 +9,16 @@ function skipOnLaravelVersionsBelow($minimumVersion) $version = app()->version(); if (version_compare($version, $minimumVersion, '<')) { - test()->markTestSkipped("Only relevant from Laravel {$minimumVersion} onwards (Current version: {$version})."); + test()->markTestSkipped("Only available on Laravel {$minimumVersion}+ (Current version: {$version})."); + } +} + +function onlyOnLaravelVersionsBelow($minimumVersion) +{ + $version = app()->version(); + + if (! version_compare($version, $minimumVersion, '<')) { + test()->markTestSkipped("Does not apply to Laravel {$minimumVersion}+ (Current version: {$version})."); } } diff --git a/tests/Setup/PublishTest.php b/tests/Setup/PublishTest.php index ac0410e..c84afdd 100644 --- a/tests/Setup/PublishTest.php +++ b/tests/Setup/PublishTest.php @@ -17,10 +17,13 @@ ]); expect(file_exists($expectedPath))->toBeTrue(); + + // Delete it + unlink($expectedPath); }); it('can publish stubs', function () { - $dir = resource_path('stubs/ddd'); + $dir = base_path('stubs/ddd'); if (File::exists($dir)) { File::deleteDirectory($dir); @@ -34,4 +37,4 @@ expect(File::exists($dir))->toBeTrue(); expect(File::isEmptyDirectory($dir))->toBeFalse(); -}); +})->markTestSkipped('Deprecated'); diff --git a/tests/Support/AutoloaderTest.php b/tests/Support/AutoloaderTest.php index aec470b..7f381df 100644 --- a/tests/Support/AutoloaderTest.php +++ b/tests/Support/AutoloaderTest.php @@ -1,13 +1,48 @@ setupTestApplication(); }); -it('can run', function () { - $autoloader = new DomainAutoloader; +beforeEach(function () { + config([ + 'ddd.domain_path' => 'src/Domain', + 'ddd.domain_namespace' => 'Domain', + 'ddd.application_namespace' => 'Application', + 'ddd.application_path' => 'src/Application', + 'ddd.application_objects' => [ + 'controller', + 'request', + 'middleware', + ], + 'ddd.layers' => [ + 'Infrastructure' => 'src/Infrastructure', + 'Support' => 'src/Support', + 'Library' => 'lib', + ], + 'ddd.autoload_ignore' => [ + 'Tests', + 'Database/Migrations', + ], + 'cache.default' => 'file', + ]); + + $this->expectedPaths = collect([ + app()->basePath('src/Domain'), + app()->basePath('src/Application'), + app()->basePath('src/Infrastructure'), + app()->basePath('src/Support'), + app()->basePath('lib'), + ])->map(fn ($path) => Path::normalize($path))->toArray(); - $autoloader->autoload(); -})->throwsNoExceptions(); + $this->setupTestApplication(); +}); + +it('can discover paths to all layers', function () { + $autoloader = app(AutoloadManager::class); + + expect($autoloader->getAllLayerPaths())->toEqualCanonicalizing($this->expectedPaths); +}); diff --git a/tests/Support/BlueprintTest.php b/tests/Support/BlueprintTest.php new file mode 100644 index 0000000..23b72c0 --- /dev/null +++ b/tests/Support/BlueprintTest.php @@ -0,0 +1,123 @@ +set([ + 'ddd.domain_path' => 'src/Domain', + 'ddd.domain_namespace' => 'Domain', + 'ddd.application_namespace' => 'Application', + 'ddd.application_path' => 'src/Application', + 'ddd.application_objects' => [ + 'controller', + 'request', + 'middleware', + ], + 'ddd.layers' => [ + 'Infrastructure' => 'src/Infrastructure', + 'NestedLayer' => 'src/Nested/Layer', + 'AppNested' => 'app/Nested', + ], + ]); +}); + +it('handles nested objects', function ($nameInput, $normalized) { + $blueprint = new GeneratorBlueprint( + commandName: 'ddd:model', + nameInput: $nameInput, + domainName: 'SomeDomain', + ); + + expect($blueprint->schema) + ->name->toBe($normalized) + ->namespace->toBe('Domain\SomeDomain\Models'); +})->with([ + ['Nested\\Thing', 'Nested\\Thing'], + ['Nested/Thing', 'Nested\\Thing'], + ['Nested/Thing/Deeply', 'Nested\\Thing\\Deeply'], + ['Nested\\Thing/Deeply', 'Nested\\Thing\\Deeply'], +]); + +it('handles objects in the application layer', function ($command, $domainName, $nameInput, $expectedName, $expectedNamespace, $expectedFqn, $expectedPath) { + $blueprint = new GeneratorBlueprint( + commandName: $command, + nameInput: $nameInput, + domainName: $domainName, + ); + + expect($blueprint->schema) + ->name->toBe($expectedName) + ->namespace->toBe($expectedNamespace) + ->fullyQualifiedName->toBe($expectedFqn) + ->path->toEqualPath($expectedPath); +})->with([ + ['ddd:controller', 'SomeDomain', 'ApplicationController', 'ApplicationController', 'Application\\SomeDomain\\Controllers', 'Application\\SomeDomain\\Controllers\\ApplicationController', 'src/Application/SomeDomain/Controllers/ApplicationController.php'], + ['ddd:controller', 'SomeDomain', 'Application', 'Application', 'Application\\SomeDomain\\Controllers', 'Application\\SomeDomain\\Controllers\\Application', 'src/Application/SomeDomain/Controllers/Application.php'], + ['ddd:middleware', 'SomeDomain', 'CrazyMiddleware', 'CrazyMiddleware', 'Application\\SomeDomain\\Middleware', 'Application\\SomeDomain\\Middleware\\CrazyMiddleware', 'src/Application/SomeDomain/Middleware/CrazyMiddleware.php'], + ['ddd:request', 'SomeDomain', 'LazyRequest', 'LazyRequest', 'Application\\SomeDomain\\Requests', 'Application\\SomeDomain\\Requests\\LazyRequest', 'src/Application/SomeDomain/Requests/LazyRequest.php'], +]); + +it('handles objects in custom layers', function ($command, $domainName, $nameInput, $expectedName, $expectedNamespace, $expectedFqn, $expectedPath) { + $blueprint = new GeneratorBlueprint( + commandName: $command, + nameInput: $nameInput, + domainName: $domainName, + ); + + expect($blueprint->schema) + ->name->toBe($expectedName) + ->namespace->toBe($expectedNamespace) + ->fullyQualifiedName->toBe($expectedFqn) + ->path->toEqualPath($expectedPath); +})->with([ + ['ddd:model', 'Infrastructure', 'System', 'System', 'Infrastructure\\Models', 'Infrastructure\\Models\\System', 'src/Infrastructure/Models/System.php'], + ['ddd:factory', 'Infrastructure', 'System', 'SystemFactory', 'Infrastructure\\Database\\Factories', 'Infrastructure\\Database\\Factories\\SystemFactory', 'src/Infrastructure/Database/Factories/SystemFactory.php'], + ['ddd:provider', 'Infrastructure', 'InfrastructureServiceProvider', 'InfrastructureServiceProvider', 'Infrastructure\\Providers', 'Infrastructure\\Providers\\InfrastructureServiceProvider', 'src/Infrastructure/Providers/InfrastructureServiceProvider.php'], + ['ddd:provider', 'Infrastructure', 'Infrastructure\\InfrastructureServiceProvider', 'Infrastructure\\InfrastructureServiceProvider', 'Infrastructure\\Providers', 'Infrastructure\\Providers\\Infrastructure\\InfrastructureServiceProvider', 'src/Infrastructure/Providers/Infrastructure/InfrastructureServiceProvider.php'], + ['ddd:provider', 'Infrastructure', 'InfrastructureServiceProvider', 'InfrastructureServiceProvider', 'Infrastructure\\Providers', 'Infrastructure\\Providers\\InfrastructureServiceProvider', 'src/Infrastructure/Providers/InfrastructureServiceProvider.php'], + ['ddd:provider', 'AppNested', 'CrazyServiceProvider', 'CrazyServiceProvider', 'AppNested\\Providers', 'AppNested\\Providers\\CrazyServiceProvider', 'app/Nested/Providers/CrazyServiceProvider.php'], + ['ddd:provider', 'NestedLayer', 'CrazyServiceProvider', 'CrazyServiceProvider', 'NestedLayer\\Providers', 'NestedLayer\\Providers\\CrazyServiceProvider', 'src/Nested/Layer/Providers/CrazyServiceProvider.php'], +]); + +it('handles objects whose name contains the domain name', function ($command, $domainName, $nameInput, $expectedName, $expectedNamespace, $expectedFqn, $expectedPath) { + $blueprint = new GeneratorBlueprint( + commandName: $command, + nameInput: $nameInput, + domainName: $domainName, + ); + + expect($blueprint->schema) + ->name->toBe($expectedName) + ->namespace->toBe($expectedNamespace) + ->fullyQualifiedName->toBe($expectedFqn) + ->path->toEqualPath($expectedPath); +})->with([ + ['ddd:model', 'SomeDomain', 'SomeDomain', 'SomeDomain', 'Domain\\SomeDomain\\Models', 'Domain\\SomeDomain\\Models\\SomeDomain', 'src/Domain/SomeDomain/Models/SomeDomain.php'], + ['ddd:model', 'SomeDomain', 'SomeDomainModel', 'SomeDomainModel', 'Domain\\SomeDomain\\Models', 'Domain\\SomeDomain\\Models\\SomeDomainModel', 'src/Domain/SomeDomain/Models/SomeDomainModel.php'], + ['ddd:model', 'SomeDomain', 'Nested\\SomeDomain', 'Nested\\SomeDomain', 'Domain\\SomeDomain\\Models', 'Domain\\SomeDomain\\Models\\Nested\\SomeDomain', 'src/Domain/SomeDomain/Models/Nested/SomeDomain.php'], + ['ddd:model', 'SomeDomain', 'SomeDomain\\SomeDomain', 'SomeDomain\\SomeDomain', 'Domain\\SomeDomain\\Models', 'Domain\\SomeDomain\\Models\\SomeDomain\\SomeDomain', 'src/Domain/SomeDomain/Models/SomeDomain/SomeDomain.php'], + ['ddd:model', 'SomeDomain', 'SomeDomain\\SomeDomainModel', 'SomeDomain\\SomeDomainModel', 'Domain\\SomeDomain\\Models', 'Domain\\SomeDomain\\Models\\SomeDomain\\SomeDomainModel', 'src/Domain/SomeDomain/Models/SomeDomain/SomeDomainModel.php'], + ['ddd:model', 'Infrastructure', 'Infrastructure', 'Infrastructure', 'Infrastructure\\Models', 'Infrastructure\\Models\\Infrastructure', 'src/Infrastructure/Models/Infrastructure.php'], + ['ddd:model', 'Infrastructure', 'Nested\\Infrastructure', 'Nested\\Infrastructure', 'Infrastructure\\Models', 'Infrastructure\\Models\\Nested\\Infrastructure', 'src/Infrastructure/Models/Nested/Infrastructure.php'], + ['ddd:controller', 'SomeDomain', 'SomeDomain', 'SomeDomain', 'Application\\SomeDomain\\Controllers', 'Application\\SomeDomain\\Controllers\\SomeDomain', 'src/Application/SomeDomain/Controllers/SomeDomain.php'], + ['ddd:controller', 'SomeDomain', 'SomeDomainController', 'SomeDomainController', 'Application\\SomeDomain\\Controllers', 'Application\\SomeDomain\\Controllers\\SomeDomainController', 'src/Application/SomeDomain/Controllers/SomeDomainController.php'], + ['ddd:controller', 'SomeDomain', 'SomeDomain\\SomeDomain', 'SomeDomain\\SomeDomain', 'Application\\SomeDomain\\Controllers', 'Application\\SomeDomain\\Controllers\\SomeDomain\\SomeDomain', 'src/Application/SomeDomain/Controllers/SomeDomain/SomeDomain.php'], +]); + +it('handles absolute-path names', function ($command, $domainName, $nameInput, $expectedName, $expectedNamespace, $expectedFqn, $expectedPath) { + $blueprint = new GeneratorBlueprint( + commandName: $command, + nameInput: $nameInput, + domainName: $domainName, + ); + + expect($blueprint->schema) + ->name->toBe($expectedName) + ->namespace->toBe($expectedNamespace) + ->fullyQualifiedName->toBe($expectedFqn) + ->path->toEqualPath($expectedPath); +})->with([ + ['ddd:model', 'SomeDomain', '/RootModel', 'RootModel', 'Domain\\SomeDomain', 'Domain\\SomeDomain\\RootModel', 'src/Domain/SomeDomain/RootModel.php'], + ['ddd:model', 'SomeDomain', '/CustomLocation/Thing', 'CustomLocation\\Thing', 'Domain\\SomeDomain', 'Domain\\SomeDomain\\CustomLocation\\Thing', 'src/Domain/SomeDomain/CustomLocation/Thing.php'], + ['ddd:model', 'SomeDomain', '/Custom/Nested/Thing', 'Custom\\Nested\\Thing', 'Domain\\SomeDomain', 'Domain\\SomeDomain\\Custom\\Nested\\Thing', 'src/Domain/SomeDomain/Custom/Nested/Thing.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..accef75 100644 --- a/tests/Support/DomainTest.php +++ b/tests/Support/DomainTest.php @@ -1,5 +1,6 @@ path->toBe(Path::normalize($expectedPath)); })->with([ ['Reporting', 'InvoiceReport', 'Domain\\Reporting\\Models\\InvoiceReport', 'src/Domain/Reporting/Models/InvoiceReport.php'], + ['Reporting', 'ReportingLog', 'Domain\\Reporting\\Models\\ReportingLog', 'src/Domain/Reporting/Models/ReportingLog.php'], + ['Reporting', 'Reporting\Log', 'Domain\\Reporting\\Models\\Reporting\\Log', 'src/Domain/Reporting/Models/Reporting/Log.php'], ['Reporting.Internal', 'InvoiceReport', 'Domain\\Reporting\\Internal\\Models\\InvoiceReport', 'src/Domain/Reporting/Internal/Models/InvoiceReport.php'], ]); @@ -89,3 +92,54 @@ ['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'], ]); + +describe('application layer', function () { + beforeEach(function () { + Config::set([ + 'ddd.application_path' => 'app/Modules', + 'ddd.application_namespace' => 'App\Modules', + 'ddd.application_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'], + ]); +}); + +describe('custom layers', function () { + beforeEach(function () { + Config::set('ddd.layers', [ + 'Support' => 'src/Support', + ]); + }); + + it('can map domains to custom layers', 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([ + ['Support', 'class', 'ExchangeRate', 'Support\\ExchangeRate', 'src/Support/ExchangeRate.php'], + ['Support', 'trait', 'Concerns\\HasOptions', 'Support\\Concerns\\HasOptions', 'src/Support/Concerns/HasOptions.php'], + ['Support', 'exception', 'InvalidExchangeRate', 'Support\\Exceptions\\InvalidExchangeRate', 'src/Support/Exceptions/InvalidExchangeRate.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/Support/ResolveLayerTest.php b/tests/Support/ResolveLayerTest.php new file mode 100644 index 0000000..5060177 --- /dev/null +++ b/tests/Support/ResolveLayerTest.php @@ -0,0 +1,44 @@ +setupTestApplication(); +}); + +it('can resolve domains to custom layers', function ($domainName, $namespace, $path) { + Config::set('ddd.layers', [ + 'Support' => 'src/Support', + ]); + + $layer = DomainResolver::resolveLayer($domainName); + + expect($layer) + ->namespace->toBe($namespace) + ->path->toBe(Path::normalize($path)); +})->with([ + ['Support', 'Support', 'src/Support'], + ['Invoicing', 'Domain\\Invoicing', 'src/Domain/Invoicing'], + ['Reporting\\Internal', 'Domain\\Reporting\\Internal', 'src/Domain/Reporting/Internal'], +]); + +it('resolves normally when no custom layer is found', function ($domainName, $namespace, $path) { + Config::set('ddd.layers', [ + 'SupportNotMatching' => 'src/Support', + ]); + + $layer = DomainResolver::resolveLayer($domainName); + + expect($layer) + ->namespace->toBe($namespace) + ->path->toBe(Path::normalize($path)); +})->with([ + ['Support', 'Domain\\Support', 'src/Domain/Support'], + ['Invoicing', 'Domain\\Invoicing', 'src/Domain/Invoicing'], + ['Reporting\\Internal', 'Domain\\Reporting\\Internal', 'src/Domain/Reporting/Internal'], +]); diff --git a/tests/Support/ResolveObjectSchemaUsingTest.php b/tests/Support/ResolveObjectSchemaUsingTest.php new file mode 100644 index 0000000..4d7abfe --- /dev/null +++ b/tests/Support/ResolveObjectSchemaUsingTest.php @@ -0,0 +1,53 @@ +setupTestApplication(); + + Config::set('ddd.domain_path', 'src/Domain'); + Config::set('ddd.domain_namespace', 'Domain'); +}); + +it('can register a custom object schema resolver', function () { + Config::set([ + 'ddd.application_path' => 'src/App', + 'ddd.application_namespace' => 'App', + ]); + + DDD::resolveObjectSchemaUsing(function (string $domainName, string $nameInput, string $type, CommandContext $command): ?ObjectSchema { + if ($type === 'controller' && $command->option('api')) { + return new ObjectSchema( + name: $name = str($nameInput)->replaceEnd('Controller', '')->finish('ApiController')->toString(), + namespace: "App\\Api\\Controllers\\{$domainName}", + fullyQualifiedName: "App\\Api\\Controllers\\{$domainName}\\{$name}", + path: "src/App/Api/Controllers/{$domainName}/{$name}.php", + ); + } + + return null; + }); + + Artisan::call('ddd:controller', [ + 'name' => 'PaymentController', + '--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;", + 'class PaymentApiController', + ]); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index fb69cab..2c723c9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,8 +5,10 @@ use Illuminate\Contracts\Config\Repository; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Artisan; 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; @@ -14,27 +16,30 @@ class TestCase extends Orchestra { public static $configValues = []; + public $appConfig = []; + + protected $originalComposerContents; + protected function setUp(): void { + $this->originalComposerContents = $this->getComposerFileContents(); + $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' ); + + DomainCache::clear(); + + config()->set('data.structure_caching.enabled', false); + + Artisan::command('data:cache-structures', function () {}); + }); + + $this->afterApplicationRefreshed(function () { + config()->set('data.structure_caching.enabled', false); }); $this->beforeApplicationDestroyed(function () { @@ -44,33 +49,99 @@ protected function setUp(): void parent::setUp(); } + protected function tearDown(): void + { + $basePath = $this->getBasePath(); + + $this->cleanSlate(); + + file_put_contents($basePath.'/composer.json', $this->originalComposerContents); + + parent::tearDown(); + } + public static function configValues(array $values) { static::$configValues = $values; } + public static function resetConfig() + { + static::$configValues = []; + } + protected function defineEnvironment($app) { + if (in_array(BootsTestApplication::class, class_uses_recursive($this))) { + static::$configValues = [ + 'ddd.domain_path' => 'src/Domain', + 'ddd.domain_namespace' => 'Domain', + 'ddd.application_path' => 'src/Application', + 'ddd.application_namespace' => 'Application', + 'ddd.application_objects' => [ + 'controller', + 'request', + 'middleware', + ], + 'ddd.layers' => [ + 'Infrastructure' => 'src/Infrastructure', + ], + 'ddd.autoload' => [ + 'providers' => true, + 'commands' => true, + 'policies' => true, + 'factories' => true, + 'migrations' => true, + ], + 'ddd.autoload_ignore' => [ + 'Tests', + 'Database/Migrations', + ], + 'ddd.cache_directory' => 'bootstrap/cache/ddd', + 'cache.default' => 'file', + 'data.structure_caching.enabled' => false, + ...static::$configValues, + ]; + } + tap($app['config'], function (Repository $config) { foreach (static::$configValues as $key => $value) { $config->set($key, $value); } + + foreach ($this->appConfig as $key => $value) { + $config->set($key, $value); + } + + $config->set('data.structure_caching.enabled', false); }); + } + + protected function refreshApplicationWithConfig(array $config) + { + $this->appConfig = $config; + + // $this->afterApplicationRefreshed(fn () => $this->appConfig = []); + + $this->reloadApplication(); + + $this->appConfig = []; - // $this->updateComposer( - // set: [ - // [['autoload', 'psr-4', 'App\\'], 'vendor/orchestra/testbench-core/laravel/app'], - // ], - // forget: [ - // ['autoload', 'psr-4', 'Domains\\'], - // ['autoload', 'psr-4', 'Domain\\'], - // ] - // ); + return $this; + } + + protected function withConfig(array $config) + { + $this->appConfig = $config; + + return $this; } protected function getComposerFileContents() { - return file_get_contents(base_path('composer.json')); + $basePath = $this->getBasePath(); + + return file_get_contents($basePath.'/composer.json'); } protected function getComposerFileAsArray() @@ -136,42 +207,85 @@ protected function composerReload() (new Process($command, base_path(), ['COMPOSER_MEMORY_LIMIT' => '-1'])) ->setTimeout(null) ->run(function ($type, $output) {}); + + return $this; } protected function cleanSlate() { - File::copy(__DIR__.'/.skeleton/composer.json', base_path('composer.json')); + $basePath = $this->getBasePath(); + + File::delete($basePath.'/config/ddd.php'); - File::delete(base_path('config/ddd.php')); + File::cleanDirectory($basePath.'/app/Models'); + File::cleanDirectory($basePath.'/database/factories'); + File::cleanDirectory($basePath.'/bootstrap/cache'); + File::cleanDirectory($basePath.'/bootstrap/cache/ddd'); - File::cleanDirectory(app_path()); - File::cleanDirectory(base_path('database/factories')); + File::deleteDirectory($basePath.'/src'); + File::deleteDirectory($basePath.'/resources/stubs/ddd'); + File::deleteDirectory($basePath.'/stubs'); + File::deleteDirectory($basePath.'/Custom'); + File::deleteDirectory($basePath.'/Other'); + File::deleteDirectory($basePath.'/app/Policies'); + File::deleteDirectory($basePath.'/app/Modules'); - File::deleteDirectory(resource_path('stubs/ddd')); - File::deleteDirectory(base_path('Custom')); - File::deleteDirectory(base_path('src/Domain')); - File::deleteDirectory(base_path('src/Domains')); - File::deleteDirectory(app_path('Models')); + // File::copy(__DIR__.'/.skeleton/composer.json', $basePath.'/composer.json'); + + return $this; + } + + protected function cleanStubs() + { + File::cleanDirectory(base_path('stubs')); - File::deleteDirectory(base_path('bootstrap/cache/ddd')); + return $this; } protected function setupTestApplication() { - File::copyDirectory(__DIR__.'/.skeleton/app', app_path()); + $this->cleanSlate(); + + $basePath = $this->getBasePath(); + + File::ensureDirectoryExists(app_path()); + File::ensureDirectoryExists(app_path('Models')); + File::ensureDirectoryExists(database_path('factories')); + File::ensureDirectoryExists($basePath.'/bootstrap/cache/ddd'); + + $skeletonAppFolders = glob(__DIR__.'/.skeleton/app/*', GLOB_ONLYDIR); + + foreach ($skeletonAppFolders as $folder) { + File::copyDirectory($folder, app_path(basename($folder))); + } + + File::ensureDirectoryExists(app_path('Http/Controllers')); + File::copy(__DIR__.'/.skeleton/app/Http/Controllers/Controller.php', app_path('Http/Controllers/Controller.php')); + File::copyDirectory(__DIR__.'/.skeleton/database', base_path('database')); - File::copyDirectory(__DIR__.'/.skeleton/src/Domain', base_path('src/Domain')); + File::copyDirectory(__DIR__.'/.skeleton/src', base_path('src')); File::copy(__DIR__.'/.skeleton/bootstrap/providers.php', base_path('bootstrap/providers.php')); - File::ensureDirectoryExists(app_path('Models')); + File::copy(__DIR__.'/.skeleton/config/ddd.php', config_path('ddd.php')); + File::copy(__DIR__.'/.skeleton/composer.json', $basePath.'/composer.json'); + + $this->composerReload(); + + // $this->setAutoloadPathInComposer('Domain', 'src/Domain'); + // $this->setAutoloadPathInComposer('Application', 'src/Application'); + // $this->setAutoloadPathInComposer('Infrastructure', 'src/Infrastructure'); - $this->setDomainPathInComposer('Domain', 'src/Domain'); + DomainCache::clear(); + + config()->set('data.structure_caching.enabled', false); + + return $this; } - protected function setDomainPathInComposer($domainNamespace, $domainPath, bool $reload = true) + protected function setAutoloadPathInComposer($namespace, $path, bool $reload = true) { $this->updateComposer( set: [ - [['autoload', 'psr-4', $domainNamespace.'\\'], $domainPath], + [['autoload', 'psr-4', $namespace.'\\'], $path], ], );