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 0a7dfed..80cc295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,32 +4,42 @@ All notable changes to `laravel-ddd` will be documented in this file. ## [Unreleased] ### Breaking -- Stubs are now published to `/stubs/ddd/*` instead of `resources/stubs/ddd/*`. If you have ddd stubs published from a prior version, they should be relocated. +- 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 -- Ability to configure the Application Layer, to generate domain objects that don't typically belong inside the domain layer. +- 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', - 'namespace' => 'App\Modules', - 'objects' => [ - 'controller', - 'request', - 'middleware', - ], + 'application_path' => 'app/Modules', + 'application_namespace' => 'App\Modules', + 'application_objects' => [ + 'controller', + 'request', + 'middleware', ], ``` -- Added `ddd:controller` to generate domain-specific controllers in the application layer. -- Added `ddd:request` to generate domain-spefic requests in the application layer. -- Added `ddd:middleware` to generate domain-specific middleware in the application layer. +- 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 list, search, and publish one or more stubs as needed. +- 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 internally extends Laravel's native `make:model` and inherits all standard options: +- `ddd:model` now extends Laravel's native `make:model` and inherits all standard options: - `--migration|-m` - `--factory|-f` - `--seed|-s` @@ -39,10 +49,11 @@ All notable changes to `laravel-ddd` will be documented in this file. - `--all|-a` - `--pivot|-p` - `ddd:cache` is now `ddd:optimize` (`ddd:cache` is still available as an alias). -- For Laravel 11.27.1+, the framework's `optimize` and `optimize:clear` commands will automatically invoke `ddd:optimize` and `ddd:clear` respectively. +- 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 diff --git a/README.md b/README.md index 0906314..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: @@ -21,24 +21,17 @@ 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) -Data Transfer Objects: [spatie/laravel-data](https://github.com/spatie/laravel-data) -```bash -composer require spatie/laravel-data -``` - -Actions: [lorisleiva/laravel-actions](https://github.com/lorisleiva/laravel-actions) -```bash -composer require lorisleiva/laravel-actions -``` -The default DTO and Action stubs of this package reference classes from these packages. If this doesn't apply to your application, you may [customize the stubs](#publishing-stubs-advanced) accordingly. +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:optimize` during the deployment process to [optimize autoloading](#autoloading-in-production). ```bash 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. +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 | | @@ -47,7 +40,7 @@ Since Laravel 11.27.1, `php artisan optimize` automatically invokes `ddd:optimiz 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. @@ -104,6 +97,36 @@ The following generators are currently available: 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 +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 +``` + ### Other Commands ```bash # Show a summary of current domains in the domain folder @@ -121,14 +144,12 @@ php artisan ddd:clear Some objects interact with the domain layer, but are not part of the domain layer themselves. By default, these include: `controller`, `request`, `middleware`. You may customize the path, namespace, and which `ddd:*` objects belong in the application layer. ```php // In config/ddd.php -'application' => [ - 'path' => 'app/Modules', - 'namespace' => 'App\Modules', - 'objects' => [ - 'controller', - 'request', - 'middleware', - ], +'application_path' => 'app/Modules', +'application_namespace' => 'App\Modules', +'application_objects' => [ + 'controller', + 'request', + 'middleware', ], ``` The configuration above will result in the following: @@ -151,6 +172,35 @@ Output: └─ 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 @@ -177,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. @@ -198,48 +260,85 @@ 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. -```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 +### 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", + ); + } -# (supported by all commands where a domain option is accepted) + // Return null to fall back to the default + return null; +}); +``` +The example above will result in the following: +```bash +php artisan ddd:controller Invoicing:PaymentController --api +# Controller [src/App/Api/Controllers/Invoicing/PaymentApiController.php] created successfully. ``` -## Customization -### Config File -This package ships with opinionated (but sensible) configuration defaults. You may customize by publishing the [config file](#config-file) and generator stubs as needed: + +## 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:publish --config -php artisan ddd:publish --stubs +php artisan ddd:stub --list ``` -### Publishing Stubs (Advanced) -For more granular management of stubs, you may use the `ddd:stub` command: +### 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 -# Publish one or more stubs interactively via prompts 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 and overwrite only the files that have already been published -php artisan ddd:stub --all --existing - -# Overwrite any existing files -php artisan ddd:stub --all --force - -# Publish one or more stubs specified as arguments +# 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 related stubs at once, use `*` or `.` as a wildcard ending. +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. ``` @@ -250,10 +349,6 @@ Publishing /stubs/ddd/listener.queued.stub Publishing /stubs/ddd/listener.typed.stub Publishing /stubs/ddd/listener.stub ``` -For a quick reference of available stubs, use the `--list` option: -```bash -php artisan ddd:stub --list -``` ## 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. @@ -336,60 +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', + ], /* |-------------------------------------------------------------------------- - | Application Layer + | Custom Layers |-------------------------------------------------------------------------- | - | Configure objects that belong in the application layer. + | Additional top-level namespaces and paths that should be recognized as + | layers when generating ddd:* objects. | - | e.g., App\Modules\Invoicing\Controllers\* - | App\Modules\Invoicing\Requests\* + | e.g., 'Infrastructure' => 'src/Infrastructure', | */ - 'application' => [ - 'path' => 'app/Modules', - 'namespace' => 'App\Modules', - - // Specify which ddd:* objects belong in the application layer - 'objects' => [ - 'controller', - 'request', - 'middleware', - ], + 'layers' => [ + 'Infrastructure' => 'src/Infrastructure', + // 'Integrations' => 'src/Integrations', + // 'Support' => 'src/Support', ], /* |-------------------------------------------------------------------------- - | Generator Object Namespaces + | Object Namespaces |-------------------------------------------------------------------------- | - | This array maps the default relative namespaces of generated objects - | relative to their domain's root namespace. - | - | e.g., Domain\Invoicing\Models\* - | Domain\Invoicing\Data\* - | Domain\Invoicing\ViewModels\* - | Domain\Invoicing\ValueObjects\* - | Domain\Invoicing\Actions\* + | This value contains the default namespaces of ddd:* generated + | objects relative to the layer of which the object belongs to. | */ 'namespaces' => [ 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 fd4f4e5..882a0f7 100644 --- a/composer.json +++ b/composer.json @@ -20,13 +20,14 @@ "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", @@ -34,7 +35,11 @@ "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", "phpstan/phpstan-phpunit": "^1.0", - "spatie/laravel-data": "^4.10" + "spatie/laravel-data": "^4.11.1" + }, + "suggest": { + "spatie/laravel-data": "Recommended for Data Transfer Objects.", + "lorisleiva/laravel-actions": "Recommended for Actions." }, "autoload": { "psr-4": { @@ -48,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": { @@ -56,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" }, @@ -72,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 dfa4eda..20321d8 100644 --- a/config/ddd.php +++ b/config/ddd.php @@ -4,60 +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', + ], /* |-------------------------------------------------------------------------- - | Application Layer + | Custom Layers |-------------------------------------------------------------------------- | - | Configure objects that belong in the application layer. + | Additional top-level namespaces and paths that should be recognized as + | layers when generating ddd:* objects. | - | e.g., App\Modules\Invoicing\Controllers\* - | App\Modules\Invoicing\Requests\* + | e.g., 'Infrastructure' => 'src/Infrastructure', | */ - 'application' => [ - 'path' => 'app/Modules', - 'namespace' => 'App\Modules', - - // Specify which ddd:* objects belong in the application layer - 'objects' => [ - 'controller', - 'request', - 'middleware', - ], + 'layers' => [ + 'Infrastructure' => 'src/Infrastructure', + // 'Integrations' => 'src/Integrations', + // 'Support' => 'src/Support', ], /* |-------------------------------------------------------------------------- - | Generator Object Namespaces + | Object Namespaces |-------------------------------------------------------------------------- | - | This array maps the default relative namespaces of generated objects - | relative to their domain's root namespace. - | - | e.g., Domain\Invoicing\Models\* - | Domain\Invoicing\Data\* - | Domain\Invoicing\ViewModels\* - | Domain\Invoicing\ValueObjects\* - | Domain\Invoicing\Actions\* + | This value contains the default namespaces of ddd:* generated + | objects relative to the layer of which the object belongs to. | */ 'namespaces' => [ diff --git a/config/ddd.php.stub b/config/ddd.php.stub index 612b1d8..911a351 100644 --- a/config/ddd.php.stub +++ b/config/ddd.php.stub @@ -4,95 +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}}, /* |-------------------------------------------------------------------------- - | Application Layer + | Custom Layers |-------------------------------------------------------------------------- | - | Configure objects that belong in the application layer. + | Additional top-level namespaces and paths that should be recognized as + | layers when generating ddd:* objects. | - | e.g., App\Modules\Invoicing\Controllers\* - | App\Modules\Invoicing\Requests\* + | e.g., 'Infrastructure' => 'src/Infrastructure', | */ - 'application' => [ - 'path' => 'app/Modules', - 'namespace' => 'App\Modules', + 'layers' => {{layers}}, - // Specify which ddd:* objects belong in the application layer - 'objects' => [ - 'controller', - 'request', - 'middleware', - ], - ], /* |-------------------------------------------------------------------------- - | Domain Object Namespaces + | 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\* + | This value contains the default namespaces of ddd:* generated + | objects relative to the layer of which the object belongs to. | */ - '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', - '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' => '', - ], + 'namespaces' => {{namespaces}}, /* |-------------------------------------------------------------------------- @@ -152,13 +109,7 @@ return [ | should be auto-discovered and registered. | */ - 'autoload' => [ - 'providers' => true, - 'commands' => true, - 'policies' => true, - 'factories' => true, - 'migrations' => true, - ], + 'autoload' => {{autoload}}, /* |-------------------------------------------------------------------------- @@ -175,10 +126,7 @@ return [ | the AppServiceProvider's boot method. | */ - 'autoload_ignore' => [ - 'Tests', - 'Database/Migrations', - ], + 'autoload_ignore' => {{autoload_ignore}}, /* |-------------------------------------------------------------------------- @@ -189,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/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 index 6586bd7..2b753f9 100644 --- a/src/Commands/Concerns/ForwardsToDomainCommands.php +++ b/src/Commands/Concerns/ForwardsToDomainCommands.php @@ -18,42 +18,42 @@ public function call($command, array $arguments = []) 'make:request' => $this->runCommand('ddd:request', [ ...$arguments, 'name' => $nameWithSubfolder, - '--domain' => $this->domain->dotName, + '--domain' => $this->blueprint->domain->dotName, ], $this->output), 'make:model' => $this->runCommand('ddd:model', [ ...$arguments, 'name' => $nameWithSubfolder, - '--domain' => $this->domain->dotName, + '--domain' => $this->blueprint->domain->dotName, ], $this->output), 'make:factory' => $this->runCommand('ddd:factory', [ ...$arguments, 'name' => $nameWithSubfolder, - '--domain' => $this->domain->dotName, + '--domain' => $this->blueprint->domain->dotName, ], $this->output), 'make:policy' => $this->runCommand('ddd:policy', [ ...$arguments, 'name' => $nameWithSubfolder, - '--domain' => $this->domain->dotName, + '--domain' => $this->blueprint->domain->dotName, ], $this->output), 'make:migration' => $this->runCommand('ddd:migration', [ ...$arguments, - '--domain' => $this->domain->dotName, + '--domain' => $this->blueprint->domain->dotName, ], $this->output), 'make:seeder' => $this->runCommand('ddd:seeder', [ ...$arguments, 'name' => $nameWithSubfolder, - '--domain' => $this->domain->dotName, + '--domain' => $this->blueprint->domain->dotName, ], $this->output), 'make:controller' => $this->runCommand('ddd:controller', [ ...$arguments, 'name' => $nameWithSubfolder, - '--domain' => $this->domain->dotName, + '--domain' => $this->blueprint->domain->dotName, ], $this->output), default => $this->runCommand($command, $arguments, $this->output), 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 @@ +domain) { + if ($domain = $this->blueprint->domain) { $domainModel = $domain->model($model); return $domainModel->fullyQualifiedName; diff --git a/src/Commands/Concerns/ResolvesDomainFromInput.php b/src/Commands/Concerns/ResolvesDomainFromInput.php index 9192aee..efe35a0 100644 --- a/src/Commands/Concerns/ResolvesDomainFromInput.php +++ b/src/Commands/Concerns/ResolvesDomainFromInput.php @@ -5,19 +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, - HandleHooks, + use HandleHooks, + HasGeneratorBlueprint, QualifiesDomainModels; protected $nameIsAbsolute = false; - protected ?Domain $domain = null; - protected function getOptions() { return [ @@ -28,47 +28,50 @@ protected function getOptions() protected function rootNamespace() { - $type = $this->guessObjectType(); - - return Str::finish(DomainResolver::resolveRootNamespace($type), '\\'); + 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', - 'ddd:migration' => 'migration', - 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; } protected function beforeHandle() @@ -84,33 +87,26 @@ protected function beforeHandle() $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); - app('ddd')->captureCommandContext($this, $this->domain, $this->guessObjectType()); + $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/DomainControllerMakeCommand.php b/src/Commands/DomainControllerMakeCommand.php index 47f03dc..1cba0c9 100644 --- a/src/Commands/DomainControllerMakeCommand.php +++ b/src/Commands/DomainControllerMakeCommand.php @@ -53,7 +53,7 @@ protected function buildFormRequestReplacements(array $replace, $modelClass) ]; if ($this->option('requests')) { - $namespace = $this->domain->namespaceFor('request', $this->getNameInput()); + $namespace = $this->blueprint->getNamespaceFor('request', $this->getNameInput()); [$storeRequestClass, $updateRequestClass] = $this->generateFormRequests( $modelClass, @@ -92,11 +92,6 @@ protected function buildClass($name) $replace = []; - // Todo: these were attempted tweaks to counteract failing CI tests - // on Laravel 10, and should be revisited at some point. - // $replace["use {$this->rootNamespace()}Http\Controllers\Controller;\n"] = ''; - // $replace[' extends Controller'] = ''; - $appRootNamespace = $this->laravel->getNamespace(); $pathToAppBaseController = parent::getPath("Http\Controllers\Controller"); diff --git a/src/Commands/DomainFactoryMakeCommand.php b/src/Commands/DomainFactoryMakeCommand.php index 1c53382..646d036 100644 --- a/src/Commands/DomainFactoryMakeCommand.php +++ b/src/Commands/DomainFactoryMakeCommand.php @@ -22,12 +22,12 @@ protected function getStub() protected function getNamespace($name) { - return $this->domain->namespaceFor('factory'); + return $this->blueprint->getNamespaceFor('factory'); } protected function preparePlaceholders(): array { - $domain = $this->domain; + $domain = $this->blueprint->domain; $name = $this->getNameInput(); @@ -51,6 +51,6 @@ protected function guessModelName($name) $name = substr($name, 0, -7); } - return $this->domain->model(class_basename($name))->name; + return $this->blueprint->domain->model(class_basename($name))->name; } } diff --git a/src/Commands/DomainGeneratorCommand.php b/src/Commands/DomainGeneratorCommand.php index 9dc8dd2..9d5dcd2 100644 --- a/src/Commands/DomainGeneratorCommand.php +++ b/src/Commands/DomainGeneratorCommand.php @@ -15,48 +15,11 @@ abstract class DomainGeneratorCommand extends GeneratorCommand 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/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/DomainModelMakeCommand.php b/src/Commands/DomainModelMakeCommand.php index 1bc72b3..9739576 100644 --- a/src/Commands/DomainModelMakeCommand.php +++ b/src/Commands/DomainModelMakeCommand.php @@ -38,7 +38,7 @@ protected function buildFactoryReplacements() $replacements = parent::buildFactoryReplacements(); if ($this->option('factory')) { - $factoryNamespace = Str::start($this->domain->factory($this->getNameInput())->fullyQualifiedName, '\\'); + $factoryNamespace = Str::start($this->blueprint->getFactoryFor($this->getNameInput())->fullyQualifiedName, '\\'); $factoryCode = << */ @@ -92,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, diff --git a/src/Commands/DomainRequestMakeCommand.php b/src/Commands/DomainRequestMakeCommand.php index 88cad30..30f7b88 100644 --- a/src/Commands/DomainRequestMakeCommand.php +++ b/src/Commands/DomainRequestMakeCommand.php @@ -17,8 +17,6 @@ class DomainRequestMakeCommand extends RequestMakeCommand protected function rootNamespace() { - $type = $this->guessObjectType(); - - return Str::finish(DomainResolver::resolveRootNamespace($type), '\\'); + return Str::finish(DomainResolver::resolveRootNamespace($this->blueprint->type), '\\'); } } diff --git a/src/Commands/DomainViewModelMakeCommand.php b/src/Commands/DomainViewModelMakeCommand.php index c457405..e75700b 100644 --- a/src/Commands/DomainViewModelMakeCommand.php +++ b/src/Commands/DomainViewModelMakeCommand.php @@ -2,7 +2,6 @@ namespace Lunarstorm\LaravelDDD\Commands; -use Illuminate\Support\Str; use Lunarstorm\LaravelDDD\Commands\Concerns\HasDomainStubs; use Lunarstorm\LaravelDDD\Support\DomainResolver; @@ -55,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 e37d186..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,54 +14,15 @@ class InstallCommand extends Command public function handle(): int { - $this->comment('Publishing config...'); - $this->call('vendor:publish', [ - '--tag' => 'ddd-config', - ]); + $this->call('ddd:publish', ['--config' => true]); - $this->comment('Ensuring domain path is registered in composer.json...'); - $this->registerDomainAutoload(); + $this->comment('Updating composer.json...'); + $this->callSilently('ddd:config', ['action' => 'composer']); - if ($this->confirm('Would you like to publish 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/DomainMigrateMakeCommand.php b/src/Commands/Migration/DomainMigrateMakeCommand.php index e0dcb73..4dcca4b 100644 --- a/src/Commands/Migration/DomainMigrateMakeCommand.php +++ b/src/Commands/Migration/DomainMigrateMakeCommand.php @@ -18,8 +18,8 @@ class DomainMigrateMakeCommand extends BaseMigrateMakeCommand */ protected function getMigrationPath() { - if ($this->domain) { - return $this->laravel->basePath($this->domain->migrationPath); + if ($this->blueprint) { + return $this->laravel->basePath($this->blueprint->getMigrationPath()); } return $this->laravel->databasePath().DIRECTORY_SEPARATOR.'migrations'; diff --git a/src/Commands/OptimizeCommand.php b/src/Commands/OptimizeCommand.php index e73462e..58b2245 100644 --- a/src/Commands/OptimizeCommand.php +++ b/src/Commands/OptimizeCommand.php @@ -3,7 +3,7 @@ namespace Lunarstorm\LaravelDDD\Commands; use Illuminate\Console\Command; -use Lunarstorm\LaravelDDD\Support\DomainAutoloader; +use Lunarstorm\LaravelDDD\Facades\Autoload; use Lunarstorm\LaravelDDD\Support\DomainMigration; class OptimizeCommand extends Command @@ -24,11 +24,9 @@ protected function configure() public function handle() { $this->components->info('Caching DDD providers, commands, migration paths.'); - - $this->components->task('domain providers', fn () => DomainAutoloader::cacheProviders()); - $this->components->task('domain commands', fn () => DomainAutoloader::cacheCommands()); + $this->components->task('domain 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/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 317c453..2eb2ea3 100755 --- a/src/DomainManager.php +++ b/src/DomainManager.php @@ -2,10 +2,9 @@ namespace Lunarstorm\LaravelDDD; -use Illuminate\Console\Command; -use Lunarstorm\LaravelDDD\Support\Domain; +use Lunarstorm\LaravelDDD\Support\AutoloadManager; +use Lunarstorm\LaravelDDD\Support\GeneratorBlueprint; use Lunarstorm\LaravelDDD\Support\Path; -use Lunarstorm\LaravelDDD\ValueObjects\DomainCommandContext; class DomainManager { @@ -24,23 +23,44 @@ class DomainManager protected $applicationLayerFilter; /** - * The application layer object resolver callback. + * The object schema resolver callback. * * @var callable|null */ - protected $namespaceResolver; + protected $objectSchemaResolver; - protected ?DomainCommandContext $commandContext; + /** + * Resolved custom objects. + */ + protected array $resolvedObjects = []; - protected StubManager $stubs; + protected ?GeneratorBlueprint $commandContext; public function __construct() { $this->autoloadFilter = null; $this->applicationLayerFilter = null; - $this->namespaceResolver = null; $this->commandContext = null; - $this->stubs = new StubManager; + } + + 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 @@ -63,24 +83,14 @@ public function getApplicationLayerFilter(): ?callable return $this->applicationLayerFilter; } - public function resolveNamespaceUsing(callable $resolver): void - { - $this->namespaceResolver = $resolver; - } - - public function getNamespaceResolver(): ?callable - { - return $this->namespaceResolver; - } - - public function captureCommandContext(Command $command, ?Domain $domain, ?string $type): void + public function resolveObjectSchemaUsing(callable $resolver): void { - $this->commandContext = DomainCommandContext::fromCommand($command, $domain, $type); + $this->objectSchemaResolver = $resolver; } - public function getCommandContext(): ?DomainCommandContext + public function getObjectSchemaResolver(): ?callable { - return $this->commandContext; + return $this->objectSchemaResolver; } public function packagePath($path = ''): string @@ -92,9 +102,4 @@ public function laravelVersion($value) { return version_compare(app()->version(), $value, '>='); } - - public function stubs(): StubManager - { - return $this->stubs; - } } 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 @@ +app->scoped(DomainManager::class, function () { - return new DomainManager; - }); - - $this->app->bind('ddd', DomainManager::class); - /* * This class is a Package Service Provider * @@ -27,6 +23,7 @@ public function configurePackage(Package $package): void ->hasConfigFile() ->hasCommands([ Commands\InstallCommand::class, + Commands\ConfigCommand::class, Commands\PublishCommand::class, Commands\StubCommand::class, Commands\UpgradeCommand::class, @@ -70,10 +67,11 @@ public function configurePackage(Package $package): void $package->hasCommand(Commands\DomainTraitMakeCommand::class); } - // if ($this->laravelVersion('11.30.0')) { - // $package->hasCommand(Commands\PublishCommand::class); - // $package->hasCommand(Commands\StubCommand::class); - // } + if ($this->app->runningUnitTests()) { + $package->hasRoutes(['testing']); + } + + $this->registerBindings(); } protected function laravelVersion($value) @@ -83,6 +81,10 @@ protected function laravelVersion($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 @@ -94,13 +96,44 @@ protected function registerMigrations() }); $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') => $this->app->basePath("stubs/{$this->package->shortName()}"), - ], "{$this->package->shortName()}-stubs"); + Autoload::run(); if ($this->app->runningInConsole() && method_exists($this, 'optimizes')) { $this->optimizes( @@ -113,8 +146,6 @@ public function packageBooted() public function packageRegistered() { - (new DomainAutoloader)->autoload(); - $this->registerMigrations(); } } 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 e36fab8..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 @@ -19,7 +18,7 @@ class Domain public readonly string $domainWithSubdomain; - public readonly DomainNamespaces $namespace; + public readonly Layer $layer; public static array $objects = []; @@ -56,9 +55,9 @@ 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 = Path::join(DomainResolver::domainPath(), $this->domainWithSubdomain); + $this->path = $this->layer->path; $this->migrationPath = Path::join($this->path, config('ddd.namespaces.migration', 'Database/Migrations')); } @@ -74,13 +73,13 @@ public function path(?string $path = null): string return $this->path; } - $path = str($path) - ->replace($this->namespace->root, '') + $resolvedPath = str($path) + ->replace($this->layer->namespace, '') ->replace(['\\', '/'], DIRECTORY_SEPARATOR) ->append('.php') ->toString(); - return Path::join($this->path, $path); + return Path::join($this->path, $resolvedPath); } public function pathInApplicationLayer(?string $path = null): string @@ -103,6 +102,16 @@ public function relativePath(string $path = ''): string return collect([$this->domain, $path])->filter()->implode(DIRECTORY_SEPARATOR); } + public function rootNamespace(): string + { + return $this->layer->namespace; + } + + public function intendedLayerFor(string $type) + { + return DomainResolver::resolveLayer($this->domainWithSubdomain, $type); + } + public function namespaceFor(string $type, ?string $name = null): string { return DomainResolver::getDomainObjectNamespace($this->domainWithSubdomain, $type, $name); @@ -121,20 +130,12 @@ public function guessNamespaceFromName(string $name): string public function object(string $type, string $name, bool $absolute = false): DomainObject { - $resolvedNamespace = null; - - if (DomainResolver::isApplicationLayer($type)) { - $resolver = app('ddd')->getNamespaceResolver(); - - $resolvedNamespace = is_callable($resolver) - ? $resolver($this->domainWithSubdomain, $type, app('ddd')->getCommandContext()) - : null; - } + $layer = $this->intendedLayerFor($type); - $namespace = $resolvedNamespace ?? match (true) { - $absolute => $this->namespace->root, - str($name)->startsWith('\\') => $this->guessNamespaceFromName($name), - default => $this->namespaceFor($type), + $namespace = match (true) { + $absolute => $layer->namespace, + str($name)->startsWith('\\') => $layer->guessNamespaceFromName($name), + default => $layer->namespaceFor($type), }; $baseName = str($name)->replace($namespace, '') @@ -150,9 +151,7 @@ public function object(string $type, string $name, bool $absolute = false): Doma domain: $this->domain, namespace: $namespace, fullyQualifiedName: $fullyQualifiedName, - path: DomainResolver::isApplicationLayer($type) - ? $this->pathInApplicationLayer($fullyQualifiedName) - : $this->path($fullyQualifiedName), + path: $layer->path($fullyQualifiedName), type: $type ); } diff --git a/src/Support/DomainAutoloader.php b/src/Support/DomainAutoloader.php deleted file mode 100644 index 9e812d9..0000000 --- a/src/Support/DomainAutoloader.php +++ /dev/null @@ -1,216 +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 index 9f3a939..b6cd0ba 100644 --- a/src/Support/DomainMigration.php +++ b/src/Support/DomainMigration.php @@ -31,7 +31,7 @@ public static function paths(): array : static::discoverPaths(); } - protected static function normalizePaths($path): array + protected static function filterDirectories($path): array { return collect($path) ->filter(fn ($path) => is_dir($path)) @@ -46,7 +46,7 @@ public static function discoverPaths(): array return []; } - $paths = static::normalizePaths([ + $paths = static::filterDirectories([ app()->basePath(DomainResolver::domainPath()), ]); diff --git a/src/Support/DomainResolver.php b/src/Support/DomainResolver.php index 0d9110c..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 { @@ -40,7 +41,7 @@ public static function domainRootNamespace(): ?string */ public static function applicationLayerPath(): ?string { - return config('ddd.application.path'); + return config('ddd.application_path'); } /** @@ -48,7 +49,7 @@ public static function applicationLayerPath(): ?string */ public static function applicationLayerRootNamespace(): ?string { - return config('ddd.application.namespace'); + return config('ddd.application_namespace'); } /** @@ -67,7 +68,7 @@ public static function getRelativeObjectNamespace(string $type): string public static function isApplicationLayer(string $type): bool { $filter = app('ddd')->getApplicationLayerFilter() ?? function (string $type) { - $applicationObjects = config('ddd.application.objects', ['controller', 'request']); + $applicationObjects = config('ddd.application_objects', ['controller', 'request']); return in_array($type, $applicationObjects); }; @@ -87,6 +88,34 @@ public static function resolveRootNamespace(string $type): ?string : static::domainRootNamespace(); } + /** + * Resolve the intended layer of a specified domain name keyword. + */ + public static function resolveLayer(string $domain, ?string $type = null): ?Layer + { + $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 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. * @@ -96,20 +125,11 @@ public static function resolveRootNamespace(string $type): ?string */ public static function getDomainObjectNamespace(string $domain, string $type, ?string $name = null): string { - $customResolver = app('ddd')->getNamespaceResolver(); - - $resolved = is_callable($customResolver) - ? $customResolver($domain, $type, app('ddd')->getCommandContext()) - : null; - - if (! is_null($resolved)) { - return $resolved; - } - $resolver = function (string $domain, string $type, ?string $name) { + $layer = static::resolveLayer($domain, $type); + $namespace = collect([ - static::resolveRootNamespace($type), - $domain, + $layer->namespace, static::getRelativeObjectNamespace($type), ])->filter()->implode('\\'); 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 7fbdb86..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( 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/DomainCommandContext.php b/src/ValueObjects/DomainCommandContext.php deleted file mode 100644 index 692ceed..0000000 --- a/src/ValueObjects/DomainCommandContext.php +++ /dev/null @@ -1,51 +0,0 @@ -getName(), - domain: $domain?->domainWithSubdomain, - type: $type, - resource: $command->argument('name'), - arguments: $command->arguments(), - options: $command->options(), - ); - } - - public function hasOption(string $key): bool - { - return array_key_exists($key, $this->options); - } - - public function option(string $key): mixed - { - return data_get($this->options, $key); - } - - public function hasArgument(string $key): bool - { - return array_key_exists($key, $this->arguments); - } - - public function argument(string $key): mixed - { - return data_get($this->arguments, $key); - } -} diff --git a/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/Providers/InvoiceServiceProvider.php b/tests/.skeleton/src/Domain/Invoicing/Providers/InvoiceServiceProvider.php index 5e257e5..7ddb52a 100644 --- a/tests/.skeleton/src/Domain/Invoicing/Providers/InvoiceServiceProvider.php +++ b/tests/.skeleton/src/Domain/Invoicing/Providers/InvoiceServiceProvider.php @@ -5,12 +5,13 @@ use Domain\Invoicing\Models\Invoice; use Illuminate\Foundation\Application; use Illuminate\Support\ServiceProvider; +use Infrastructure\Support\Clipboard; class InvoiceServiceProvider extends ServiceProvider { public function register() { - $this->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 @@ +setupTestApplication(); -}); - -it('can register a custom namespace resolver', function () { - Config::set('ddd.application', [ - 'path' => 'src/App', - 'namespace' => 'App', - ]); - - DDD::resolveNamespaceUsing(function (string $domain, string $type, ?DomainCommandContext $context): ?string { - if ($type == 'controller' && $context->option('api')) { - return "App\\Api\\Controllers\\{$domain}"; - } - - return null; - }); - - Artisan::call('ddd:controller', [ - 'name' => 'PaymentApiController', - '--domain' => 'Invoicing', - '--api' => true, - ]); - - $output = Artisan::output(); - - expect($output) - ->toContainFilepath('src/App/Api/Controllers/Invoicing/PaymentApiController.php'); - - $expectedPath = base_path('src/App/Api/Controllers/Invoicing/PaymentApiController.php'); - - expect(file_get_contents($expectedPath)) - ->toContain("namespace App\Api\Controllers\Invoicing;"); -}); diff --git a/tests/Autoload/CommandTest.php b/tests/Autoload/CommandTest.php index 57650e7..3202d83 100644 --- a/tests/Autoload/CommandTest.php +++ b/tests/Autoload/CommandTest.php @@ -1,102 +1,81 @@ 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(); + + // $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 6a1443b..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', @@ -38,9 +41,10 @@ $this ->artisan('ddd:list') - ->expectsTable([ + ->expectsOutputToContain(...[ 'Domain', 'Namespace', 'Path', - ], $expectedTableContent); + ...Arr::flatten($expectedTableContent), + ]); }); diff --git a/tests/Command/CacheTest.php b/tests/Command/OptimizeTest.php similarity index 76% rename from tests/Command/CacheTest.php rename to tests/Command/OptimizeTest.php index f7aec3f..f2db8a4 100644 --- a/tests/Command/CacheTest.php +++ b/tests/Command/OptimizeTest.php @@ -1,28 +1,34 @@ setupTestApplication(); - config(['cache.default' => 'file']); - DomainCache::clear(); + + $this->originalComposerContents = file_get_contents(base_path('composer.json')); }); afterEach(function () { - $this->artisan('optimize:clear')->execute(); + DomainCache::clear(); + + file_put_contents(base_path('composer.json'), $this->originalComposerContents); + + $this->artisan('optimize:clear')->assertSuccessful()->execute(); }); -it('can cache discovered domain providers, commands, migrations', function () { +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:cache') + ->artisan('ddd:optimize') ->expectsOutputToContain('Caching DDD providers, commands, migration paths.') ->expectsOutputToContain('domain providers') ->expectsOutputToContain('domain commands') @@ -41,7 +47,7 @@ }); it('can clear the cache', function () { - Artisan::call('ddd:cache'); + $this->artisan('ddd:optimize')->assertSuccessful()->execute(); expect(DomainCache::get('domain-providers'))->not->toBeNull(); expect(DomainCache::get('domain-commands'))->not->toBeNull(); @@ -62,20 +68,20 @@ expect(DomainCache::get('domain-commands'))->toBeNull(); expect(DomainCache::get('domain-migration-paths'))->toBeNull(); - $this->artisan('ddd:cache')->execute(); + $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')->execute(); + $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')->execute(); + $this->artisan('optimize:clear')->assertSuccessful()->execute(); expect(DomainCache::get('domain-providers'))->not->toBeNull(); expect(DomainCache::get('domain-commands'))->not->toBeNull(); @@ -84,33 +90,33 @@ }); describe('laravel optimize', function () { - test('optimize will include ddd:cache', function () { - config(['cache.default' => 'file']); - + 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')->execute(); + $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 () { - config(['cache.default' => 'file']); - - $this->artisan('ddd:cache')->execute(); + $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')->execute(); + $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/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 82e6e54..217ad26 100644 --- a/tests/Factory/DomainFactoryTest.php +++ b/tests/Factory/DomainFactoryTest.php @@ -3,9 +3,10 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Config; use Lunarstorm\LaravelDDD\Factories\DomainFactory; -use Lunarstorm\LaravelDDD\Support\DomainAutoloader; +use Lunarstorm\LaravelDDD\Tests\BootsTestApplication; + +uses(BootsTestApplication::class); it('can resolve the factory name of a domain model', function ($modelClass, $expectedFactoryClass) { $this->setupTestApplication(); @@ -28,13 +29,17 @@ ]); it('can instantiate a domain model factory', function ($domainParameter, $modelName, $modelClass) { - $this->afterApplicationCreated(function () { - (new DomainAutoloader)->autoload(); + $this->setupTestApplication(); + + $this->afterApplicationRefreshed(function () { + app('ddd.autoloader')->boot(); }); - $this->setupTestApplication(); + $this->refreshApplicationWithConfig([ + 'ddd.base_model' => 'Lunarstorm\LaravelDDD\Models\DomainModel', + 'ddd.autoload.factories' => true, + ]); - Config::set('ddd.base_model', 'Lunarstorm\LaravelDDD\Models\DomainModel'); Artisan::call("ddd:model -f {$domainParameter}:{$modelName}"); expect(class_exists($modelClass))->toBeTrue(); diff --git a/tests/Generator/ActionMakeTest.php b/tests/Generator/ActionMakeTest.php index 2bdb1f2..52148fb 100644 --- a/tests/Generator/ActionMakeTest.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/ControllerMakeTest.php b/tests/Generator/ControllerMakeTest.php index e814913..8f41808 100644 --- a/tests/Generator/ControllerMakeTest.php +++ b/tests/Generator/ControllerMakeTest.php @@ -9,13 +9,12 @@ $this->cleanSlate(); $this->setupTestApplication(); - Config::set('ddd.domain_path', 'src/Domain'); - Config::set('ddd.domain_namespace', 'Domain'); - - Config::set('ddd.application', [ - 'path' => 'app/Modules', - 'namespace' => 'App\Modules', - 'objects' => ['controller', 'request'], + 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'], ]); }); 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/DtoMakeTestTest.php b/tests/Generator/DtoMakeTest.php similarity index 100% rename from tests/Generator/DtoMakeTestTest.php rename to tests/Generator/DtoMakeTest.php diff --git a/tests/Generator/ExtendedCommandsTest.php b/tests/Generator/ExtendedMakeTest.php similarity index 95% rename from tests/Generator/ExtendedCommandsTest.php rename to tests/Generator/ExtendedMakeTest.php index 52d8959..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'); } diff --git a/tests/Generator/Model/MakeWithControllerTest.php b/tests/Generator/Model/MakeWithControllerTest.php index c5f1077..2418ad3 100644 --- a/tests/Generator/Model/MakeWithControllerTest.php +++ b/tests/Generator/Model/MakeWithControllerTest.php @@ -1,15 +1,21 @@ '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(); }); diff --git a/tests/Generator/Model/MakeWithOptionsTest.php b/tests/Generator/Model/MakeWithOptionsTest.php index 09cb201..aae2c98 100644 --- a/tests/Generator/Model/MakeWithOptionsTest.php +++ b/tests/Generator/Model/MakeWithOptionsTest.php @@ -10,47 +10,47 @@ Config::set('ddd.domain_namespace', 'Domain'); }); -// it('can generate a domain model with factory', function () { -// $domainName = 'Invoicing'; -// $modelName = 'Record'; +it('can generate a domain model with factory', function () { + $domainName = 'Invoicing'; + $modelName = 'Record'; -// $domain = new Domain($domainName); + $domain = new Domain($domainName); -// $factoryName = "{$modelName}Factory"; + $factoryName = "{$modelName}Factory"; -// $domainModel = $domain->model($modelName); + $domainModel = $domain->model($modelName); -// $domainFactory = $domain->factory($factoryName); + $domainFactory = $domain->factory($factoryName); -// $expectedModelPath = base_path($domainModel->path); + $expectedModelPath = base_path($domainModel->path); -// if (file_exists($expectedModelPath)) { -// unlink($expectedModelPath); -// } + if (file_exists($expectedModelPath)) { + unlink($expectedModelPath); + } -// $expectedFactoryPath = base_path($domainFactory->path); + $expectedFactoryPath = base_path($domainFactory->path); -// if (file_exists($expectedFactoryPath)) { -// unlink($expectedFactoryPath); -// } + if (file_exists($expectedFactoryPath)) { + unlink($expectedFactoryPath); + } -// Artisan::call('ddd:model', [ -// 'name' => $modelName, -// '--domain' => $domain->dotName, -// '--factory' => true, -// ]); + Artisan::call('ddd:model', [ + 'name' => $modelName, + '--domain' => $domain->dotName, + '--factory' => true, + ]); -// $output = Artisan::output(); + $output = Artisan::output(); -// expect($output)->toContainFilepath($domainModel->path); + 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_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;"); -// }); + 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'; diff --git a/tests/Generator/RequestMakeTest.php b/tests/Generator/RequestMakeTest.php index cc24949..5a584b1 100644 --- a/tests/Generator/RequestMakeTest.php +++ b/tests/Generator/RequestMakeTest.php @@ -5,13 +5,12 @@ use Lunarstorm\LaravelDDD\Tests\Fixtures\Enums\Feature; beforeEach(function () { - Config::set('ddd.domain_path', 'src/Domain'); - Config::set('ddd.domain_namespace', 'Domain'); - - Config::set('ddd.application', [ - 'path' => 'app/Modules', - 'namespace' => 'App\Modules', - 'objects' => ['controller', 'request'], + 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'], ]); $this->setupTestApplication(); diff --git a/tests/Pest.php b/tests/Pest.php index d31d21e..da75808 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,6 +13,15 @@ function skipOnLaravelVersionsBelow($minimumVersion) } } +function onlyOnLaravelVersionsBelow($minimumVersion) +{ + $version = app()->version(); + + if (! version_compare($version, $minimumVersion, '<')) { + test()->markTestSkipped("Does not apply to Laravel {$minimumVersion}+ (Current version: {$version})."); + } +} + function setConfigValues(array $values) { TestCase::configValues($values); diff --git a/tests/Setup/PublishTest.php b/tests/Setup/PublishTest.php index 69858bc..c84afdd 100644 --- a/tests/Setup/PublishTest.php +++ b/tests/Setup/PublishTest.php @@ -17,6 +17,9 @@ ]); expect(file_exists($expectedPath))->toBeTrue(); + + // Delete it + unlink($expectedPath); }); it('can publish stubs', function () { @@ -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/DomainTest.php b/tests/Support/DomainTest.php index 8a701ae..accef75 100644 --- a/tests/Support/DomainTest.php +++ b/tests/Support/DomainTest.php @@ -28,6 +28,8 @@ ->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'], ]); @@ -93,10 +95,10 @@ describe('application layer', function () { beforeEach(function () { - Config::set('ddd.application', [ - 'path' => 'app/Modules', - 'namespace' => 'App\Modules', - 'objects' => ['controller', 'request'], + Config::set([ + 'ddd.application_path' => 'app/Modules', + 'ddd.application_namespace' => 'App\Modules', + 'ddd.application_objects' => ['controller', 'request'], ]); }); @@ -113,6 +115,25 @@ ]); }); +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); 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 f2baed7..a7ba0a9 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() @@ -142,23 +213,24 @@ protected function composerReload() protected function cleanSlate() { - File::copy(__DIR__.'/.skeleton/composer.json', base_path('composer.json')); + $basePath = $this->getBasePath(); - File::delete(base_path('config/ddd.php')); + File::delete($basePath.'/config/ddd.php'); - File::cleanDirectory(app_path()); - File::cleanDirectory(base_path('database/factories')); + File::cleanDirectory($basePath.'/app/Models'); + File::cleanDirectory($basePath.'/database/factories'); + File::cleanDirectory($basePath.'/bootstrap/cache'); + File::cleanDirectory($basePath.'/bootstrap/cache/ddd'); - File::deleteDirectory(resource_path('stubs/ddd')); - File::deleteDirectory(base_path('stubs')); - File::deleteDirectory(base_path('Custom')); - File::deleteDirectory(base_path('src/Domain')); - File::deleteDirectory(base_path('src/Domains')); - File::deleteDirectory(base_path('src/App')); - File::deleteDirectory(app_path('Modules')); - File::deleteDirectory(app_path('Models')); + 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(base_path('bootstrap/cache/ddd')); + // File::copy(__DIR__.'/.skeleton/composer.json', $basePath.'/composer.json'); return $this; } @@ -172,22 +244,45 @@ protected function cleanStubs() 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::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'); + + DomainCache::clear(); - $this->setDomainPathInComposer('Domain', 'src/Domain'); + 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], ], );