diff --git a/README.md b/README.md index becd7f1..dbe8714 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,98 @@ Currently this installs the following dependencies Besides the dependencies this package also includes a `vendor/bin/dev` script which is a helper script to manage a project's docker compose setup + +# Domain Boilerplate Code Generation +A few Artisan commands are available for automatic domain code generation. These can be used to setup a new domain more quickly. + +| command | description | +|-----------------------------------------------------|--------------------------------------------------------------------------------| +| `make:domain {domain} {model}` | main entry to create entire domain | +| `make:domain-abstract-event {domain} {model}` | generates common event super class | +| `make:domain-event {domain} {model} {type}` | generate specific event (used by actions) | +| `make:domain-action {domain} {model} {type}` | generate specific action | +| `make:domain-dto {domain} {model} {type}` | generate DTO to be used for either insterting or updating models in repository | +| `make:domain-model {domain} {model}` | generate the Eloquent model | +| `make:domain-exception {domain} {model}` | generate the generic exception | +| `make:domain-repository-interface {domain} {model}` | generate the repository interface | + +## Force option +``` + -f, --force force generation when class already exists +``` +All commands allow you to pass the `--force` option. using this option you can overwrite existing files in your domain. This might be required when upgrading current installments with newer definitions. By default this option is `false`, and commands will fail by saying that the file already exists. + +## `make:domain` +This is the main entry point for generating everything. It takes a number of (optional) arguments: +``` + -a, --all Generate all related files + --namespace The root namespace the domain should be part of. Default: Domain + --model generate model + --repository generate repository interface + --exception generate exception + --actions generate all actions + --create-action generate create-action + --update-action generate update-action + --delete-action generate delete-action + --events generate model + --abstract-event generate abstract-event + --creating-event generate creating-event + --created-event generate created-event + --updating-event generate updating-event + --updated-event generate updated-event + --deleting-event generate deleting-event + --deleted-event generate deleted-event + --dtos generate all dtos + --create-dto generate create-dto + --update-dto generate update-dto +``` + +This means you can either pass `--all` to generate everything in 1 command, or specify what class you want to generate specifically by using one or more of the other options. + +E.g. to generate only 2 specific actions and a repository for the `Bar` model in the `Foo` domain, run the following command: +``` shell +$ ./artisan make:domain Foo Bar --repository --create-action --delete-action +``` + +Please beware that all generated code has some expectations about other classes being available. e.g. the `Action`-classes all assume the existance of the `RepositoryInterface`, the `Model` and both the appropriate `Dto` and `Event` classes. This means you can run this command for a single class, but you might need to modify the code afterwards. + +The actions, events and dtos created by `make:domain` are limited to `create(d)`,`udpate(d)` and `delete(d)`. You can create more types, but you'll have to call the `make:domain-* {domain} {model} {type}` manually. e.g. +``` shell +$ ./artisan make:domain-action Foo Bar update-email +$ ./artisan make:domain-event Foo Bar update-email +$ ./artisan make:domain-dto Foo Bar update-email +``` + +This will generate `Bar\UpdateEmailBarAction`, `Bar\BarUpdateEmailEvent`, and `Bar\UpdateEmailBarDto` classes. + +Running the entire suite will result in the following generated files: +``` +app/Domain/{domain}/ + Actions/ + {model}/ + Create{model}Action + Delete{model}Action + Update{model}Action + Dtos/ + {model}/ + Create{model}Dto + Update{model}Dto + Events/ + {model}/ + Abstract{model}Event + {model}CreatingEvent + {model}CreatedEvent + {model}UpdatingEvent + {model}UpdatedEvent + {model}DeletingEvent + {model}DeletedEvent + Exceptions/ + {model}Exception + Models/ + {model} + Repositories/ + {model}RepositoryInterface +``` + +## Namespace option +providing the `--namespace` option allows you to change the default `Domain` root namespace to something else. e.g. `--namespace "ThisIsDevelopment\\LaravelDomain"` will generate the following Product model in the Order domain: `ThisIsDevelopment\LaravelDomain\Order\Models\Product`. diff --git a/assets/bin/dev b/assets/bin/dev index df6eb82..e831790 100755 --- a/assets/bin/dev +++ b/assets/bin/dev @@ -2,8 +2,17 @@ set -eu -cd $(dirname "$0")/../../ -COMPOSE="docker-compose" +if [ -n "${COMPOSER_RUNTIME_BIN_DIR:-}" ]; then + cd ${COMPOSER_RUNTIME_BIN_DIR}/../../ +else + cd $(dirname "$0")/../../ +fi +COMPOSE="docker compose" +COMPOSE_SERVICE="app" + +if [ ! -f "docker-compose.yml" ]; then + COMPOSE_SERVICE=$(basename $(pwd)) +fi function app_require_env() { if [ ! -f .env ]; then @@ -14,7 +23,7 @@ function app_require_env() { } function app_require_up() { - if [ -z $($COMPOSE ps -q app) ] || [ -z $(docker ps -q --no-trunc | grep $($COMPOSE ps -q app)) ]; then + if [ -z $($COMPOSE ps -q $COMPOSE_SERVICE) ] || [ -z $(docker ps -q --no-trunc | grep $($COMPOSE ps -q $COMPOSE_SERVICE)) ]; then echo -e "\033[0;31mApp container not up!\033[0m, Please run:" echo "$0 up" exit 1 @@ -38,9 +47,9 @@ function app_exec() { app_require_env app_require_up if [ -n "${COMPOSER_BINARY:-}" ]; then - exec $COMPOSE exec -T app $@ + exec $COMPOSE exec -T $COMPOSE_SERVICE $@ else - exec $COMPOSE exec app $@ + exec $COMPOSE exec $COMPOSE_SERVICE $@ fi } @@ -48,9 +57,9 @@ function app_run() { app_require_env app_require_up if [ -n "${COMPOSER_BINARY:-}" ]; then - $COMPOSE exec -T app $@ + $COMPOSE exec -T $COMPOSE_SERVICE $@ else - $COMPOSE exec app $@ + $COMPOSE exec $COMPOSE_SERVICE $@ fi } @@ -62,36 +71,39 @@ function app_logs() { function package_dev_init() { package=$1 package_name=$(basename "$package") - info=$($COMPOSE run --no-deps --rm app bash -c "composer show -l -f json \"$package\" 2>/dev/null") - + info=$($COMPOSE run --no-deps --rm $COMPOSE_SERVICE bash -c "composer show -l -f json \"$package\" 2>/dev/null") + #TODO: check if package exists giturl=$(jq -r '.source | select(.type == "git") | .url'<<<"$info") latest=$(jq -r '.latest'<<<"$info") next=$(echo "$latest" | awk -F. -v OFS=. '{$NF+=1;print}') git clone $giturl ./packages/${package_name} - + composer=$(jq ".version = \"$next\"" ./packages/${package_name}/composer.json) echo -n "$composer" > "./packages/${package_name}/composer.json" - - $COMPOSE run --no-deps --rm app composer update "$package" + + $COMPOSE run --no-deps --rm $COMPOSE_SERVICE composer update "$package" } function package_dev_done() { package=$1 package_name=$(basename "$package") - + if [ ! -d "packages/${package_name}" ]; then echo "Package $package not found in packages dir" exit 1; fi - + #TODO: check if changes committed - + rm -rf "vendor/${package}" "packages/${package_name}" - + + # Remove dangling symlinks + find ./app -lname "*/${package_name}" -type l -exec rm {} + + #TODO: increment minimal version in composer.json - $COMPOSE run --no-deps --rm app composer update "$package" + $COMPOSE run --no-deps --rm $COMPOSE_SERVICE composer update "$package" } function usage() { @@ -154,7 +166,7 @@ APP_URL="http://\${APP_DOMAIN}" APP_KEY="${APP_KEY}" EOF - egrep -v "^(USER_ID|GROUP_ID|APP_DOMAIN|APP_NAME|APP_KEY|APP_URL|COMPOSER_PROJECT_NAME)=" .env >> .env.new + egrep -v "^(USER_ID|GROUP_ID|APP_DOMAIN|APP_NAME|APP_KEY|APP_URL|COMPOSE_PROJECT_NAME)=" .env >> .env.new mv .env.new .env } diff --git a/assets/stubs/domain-abstract-action.stub b/assets/stubs/domain-abstract-action.stub new file mode 100644 index 0000000..356128d --- /dev/null +++ b/assets/stubs/domain-abstract-action.stub @@ -0,0 +1,15 @@ +{{ modelClassVariable }} = ${{ modelClassVariable }}; - } } diff --git a/assets/stubs/domain-create-action.stub b/assets/stubs/domain-create-action.stub index 1391d31..2e1a1b6 100644 --- a/assets/stubs/domain-create-action.stub +++ b/assets/stubs/domain-create-action.stub @@ -4,29 +4,27 @@ declare(strict_types=1); namespace {{ namespace }}; -use {{ eventClass }}; -use {{ exceptionClass }}; -use {{ modelClass }}; -use {{ repositoryInterface }}; +use {{ dtoFqn }}; +use {{ exceptionFqn }}; +use {{ modelFqn }}; +use {{ postEventFqn }}; +use {{ preEventFqn }}; -class {{ class }} +class {{ className }} extends {{ parentClass }} { - private {{ repositoryInterface }} $repository; - - public function __construct({{ repositoryInterface }} $repository) - { - $this->repository = $repository; - } - /** * @throws {{ exceptionClass }} */ - public function execute({{ dtoClass }} $dto): {{ modelClass }} + public function execute({{ dtoClass }} $dto): {{ modelClass }}|bool { - ${{ modelClassVar } = $this->repository->create($dto); + if (false === event(new {{ preEventClass }}($dto))) { + return false; + } + + $model = $this->repository->create($dto); - event(new {{ eventClass }}(${{ modelClassVar })); + event(new {{ postEventClass }}($model, $dto)); - return ${{ modelClassVar }; + return $model; } } diff --git a/assets/stubs/domain-created-event.stub b/assets/stubs/domain-created-event.stub new file mode 100644 index 0000000..381f4c5 --- /dev/null +++ b/assets/stubs/domain-created-event.stub @@ -0,0 +1,17 @@ +repository = $repository; - } - /** * @throws {{ exceptionClass }} */ - public function execute({{ modelClass }} ${{ modelClassVar }}) + public function execute({{ modelClass }} $model): bool { - $this->repository->delete(${{ modelClassVar }}); + if (false === event(new {{ preEventClass }}($model))) { + return false; + } + + $modelId = $model->id; + + $this->repository->delete($model); + + event(new {{ postEventClass }}($modelId)); - event(new {{ eventClass }}(${{ modelClassVar })); + return true; } } diff --git a/assets/stubs/domain-deleted-event.stub b/assets/stubs/domain-deleted-event.stub new file mode 100644 index 0000000..5f1e381 --- /dev/null +++ b/assets/stubs/domain-deleted-event.stub @@ -0,0 +1,13 @@ +repository = $repository; - } - /** * @throws {{ exceptionClass }} */ - public function execute({{ modelClass }} ${{ modelClassVar }}, {{ dtoClass }} $dto): {{ modelClass }} + public function execute({{ modelClass }} $model, {{ dtoClass }} $dto): {{ modelClass }}|bool { - ${{ modelClassVar } = $this->repository->update(${{ modelClassVar }}, $dto); + if (false === event(new {{ preEventClass }}($model, $dto))) { + return false; + } + + $model = $this->repository->{{ repositoryAction }}($model, $dto); - event(new {{ eventClass }}(${{ modelClassVar })); + event(new {{ postEventClass }}($model, $dto)); - return ${{ modelClassVar }; + return $model; } } diff --git a/assets/stubs/domain-update-event.stub b/assets/stubs/domain-update-event.stub new file mode 100644 index 0000000..381f4c5 --- /dev/null +++ b/assets/stubs/domain-update-event.stub @@ -0,0 +1,17 @@ +=7.2", + "php": "^8.1", "squizlabs/php_codesniffer": "^3.5", "barryvdh/laravel-debugbar": "^3.2", - "barryvdh/laravel-ide-helper": "^2.6", - "thisisdevelopment/laravel-test-snapshot": "^0.3.0", + "barryvdh/laravel-ide-helper": "^2.6 | ^3.5", "vlucas/phpdotenv": "^4.0|^5.0", - "illuminate/console": "6.x | 7.x | 8.x", - "illuminate/support": "6.x | 7.x | 8.x" + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0" + }, + "suggest": { + "thisisdevelopment/laravel-test-snapshot": "Speed up tests by snapshotting migrations." }, "minimum-stability": "dev", "prefer-stable": true, - "require-dev": { - }, + "require-dev": {}, "bin": [ "assets/bin/dev", "assets/bin/wait-db", @@ -39,6 +49,5 @@ "psr-4": { "ThisIsDevelopment\\LaravelBaseDev\\": "src/" } - }, - "version": "0.5.4" + } } diff --git a/src/Commands/AbstractDomainGeneratorCommand.php b/src/Commands/AbstractDomainGeneratorCommand.php index 459354d..d3867e3 100644 --- a/src/Commands/AbstractDomainGeneratorCommand.php +++ b/src/Commands/AbstractDomainGeneratorCommand.php @@ -5,49 +5,167 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; +use ThisIsDevelopment\LaravelBaseDev\Helpers\FqnHelper; abstract class AbstractDomainGeneratorCommand extends GeneratorCommand { - protected $hasMultiplePerModel = true; + public const ROOT_NAMESPACE_DEFAULT = 'Domain'; + + /** + * Optional stub path override + * + * @var string + **/ + protected ?string $stub = null; + + /** + * FQN Helper class for easy fqn/classname generation based on + * current domain/model + * + * @var FqnHelper + **/ + private FqnHelper $fqnHelper; + + public function handle(): bool|null + { + // if type is not set, automatically set it to the full class + // FQN for more descriptive console output + $this->type = $this->type ?: $this->getClassFqn(); + + // let parent handle actual command + return parent::handle(); + } public function rootNamespace(): string { - return '\\Domain'; + return trim( + str_replace('/', '\\', $this->option('namespace') ?: self::ROOT_NAMESPACE_DEFAULT), + '\\' + ); } + /** Stub related **/ protected function getStub(): string { - return $this->resolveStubPath(sprintf("/stubs/%s.stub", Str::slug(class_basename($this)))); + return $this->resolveStubPath( + $this->getStubName() + ); + } + + protected function getStubName(): string + { + return $this->stub ?: $this->generateStubName(); + } + + protected function generateStubName(): string + { + return "stubs/" . Str::kebab( + str_replace('Make', '', class_basename($this)) + ) . '.stub'; } protected function resolveStubPath($stub): string { return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) ? $customPath - : __DIR__.'/../../assets' . $stub; + : __DIR__ . '/../../assets/' . ltrim($stub, '/'); + } + + protected function getPath($name) + { + return implode('/', [ + $this->laravel->basePath('app'), + 'Domain', + ...explode('\\', trim(str_replace($this->rootNamespace(), '', $name), '\\')) + ]) . '.php'; + } + + /** + * @inheritdoc + * + * The desired classname will be determined by the + * getClassFqn() method instead of by a command argument + **/ + protected function getNameInput(): string + { + return $this->getClassFqn(); + } + + protected function fqnHelper(): FqnHelper + { + return $this->fqnHelper + ?? $this->fqnHelper = new FqnHelper( + $this->rootNamespace(), + $this->argument('domain'), + $this->argument('model') + ); + } + + protected function getOptions(): array + { + return [ + ['force', null, InputOption::VALUE_NONE, 'force generation when class already exists'], + ['namespace', null, InputOption::VALUE_OPTIONAL, 'The domain namespace', self::ROOT_NAMESPACE_DEFAULT], + ]; } protected function getArguments(): array { return array_filter([ ['domain', InputArgument::REQUIRED, 'The name of the domain'], - $this->type === 'Model' ? null : ['model', InputArgument::REQUIRED, 'The name of the model'], - ['name', InputArgument::REQUIRED, 'The name of the ' . $this->type], + ['model', InputArgument::REQUIRED, 'The name of the model'], ]); } - protected function getNameInput(): string + protected function buildClass($name) { - return trim($this->argument('name')); + $file = $this->files->get($this->getStub()); + + foreach ($this->getVariables() as $key => $value) { + // `str_replace`...? Yeah i know, but this is how laravel + // also does it themselves and i did not want to deviate + // to much... a nice improvement might be using Blade or + // Mustache instead, which will also greatly improve + // flexibility + $file = str_replace($this->convertKey($key), $value, $file); + } + + return $file; } - protected function getDefaultNamespace($rootNamespace): string + /** + * Allow a variaty of key naming by generating a list of possible + * writing options for given key. + * + * The keys generated are based slightly on the names used by laravels own generator + * + * @return string[] + **/ + private function convertKey($key): array { - return sprintf( - "%s\\%s%s", - $rootNamespace, - Str::plural($this->type), - $this->hasMultiplePerModel ? '\\' . $this->input->getArgument('model') : '' - ); + return [ + sprintf('{{ %s }}', $key), + sprintf('{{ %s}}', $key), + sprintf('{{%s }}', $key), + sprintf('{{%s}}', $key), + Str::studly("dummy_{$key}"), + ]; + } + + /** + * The FQN of the class to be generated. + * + * @return string + **/ + abstract protected function getClassFqn(): string; + + /** + * provide list of variables to replace in stubs in the form of an associative array. + * + **/ + protected function getVariables(): array + { + return []; } } diff --git a/src/Commands/MakeDomain.php b/src/Commands/MakeDomain.php new file mode 100644 index 0000000..a3e54c4 --- /dev/null +++ b/src/Commands/MakeDomain.php @@ -0,0 +1,207 @@ +wantsOneOf(['all', 'model'])) { + $this->createModel(); + } + + // exception + if ($this->wantsOneOf(['all', 'exception'])) { + $this->createException(); + } + + // dtos + if ($this->wantsOneOf(['all', 'dtos', 'create-dto'])) { + $this->createDto('create'); + } + if ($this->wantsOneOf(['all', 'dtos', 'update-dto'])) { + $this->createDto('update'); + } + + // repository interface + if ($this->wantsOneOf(['all', 'repository'])) { + $this->createRepositoryInterface(); + } + + // actions + if ($this->wantsOneOf(['all', 'actions', 'abstract-action'])) { + $this->createAbstractAction(); + } + if ($this->wantsOneOf(['all', 'actions', 'create-action'])) { + $this->createAction('create'); + } + if ($this->wantsOneOf(['all', 'actions', 'update-action'])) { + $this->createAction('update'); + } + if ($this->wantsOneOf(['all', 'actions', 'delete-action'])) { + $this->createAction('delete'); + } + + // events + if ($this->wantsOneOf(['all', 'events', 'abstract-event'])) { + $this->createAbstractEvent(); + } + if ($this->wantsOneOf(['all', 'events', 'creating-event'])) { + $this->createEvent('creating'); + } + if ($this->wantsOneOf(['all', 'events', 'created-event'])) { + $this->createEvent('created'); + } + if ($this->wantsOneOf(['all', 'events', 'updating-event'])) { + $this->createEvent('updating'); + } + if ($this->wantsOneOf(['all', 'events', 'updated-event'])) { + $this->createEvent('updated'); + } + if ($this->wantsOneOf(['all', 'events', 'deleting-event'])) { + $this->createEvent('deleting'); + } + if ($this->wantsOneOf(['all', 'events', 'deleted-event'])) { + $this->createEvent('deleted'); + } + + return null; + } + + private function wantsOneOf(array|string $options): bool + { + return in_array(true, array_filter((array)$options, fn ($opt) => $this->option($opt))); + } + + protected function getOptions(): array + { + return [ + ['force', 'f', InputOption::VALUE_NONE, 'force generation when class already exists'], + ['all', 'a', InputOption::VALUE_NONE, 'Generate all related files'], + ['namespace', null, InputOption::VALUE_OPTIONAL, 'The domain namespace', AbstractDomainGeneratorCommand::ROOT_NAMESPACE_DEFAULT], + + ['model', null, InputOption::VALUE_NONE, 'Generate model'], + ['repository', null, InputOption::VALUE_NONE, 'Generate repository interface'], + ['exception', null, InputOption::VALUE_NONE, 'Generate exception'], + + ['actions', null, InputOption::VALUE_NONE, 'Generate all actions'], + ['abstract-action', null, InputOption::VALUE_NONE, 'Generate abstract-action'], + ['create-action', null, InputOption::VALUE_NONE, 'Generate create-action'], + ['update-action', null, InputOption::VALUE_NONE, 'Generate update-action'], + ['delete-action', null, InputOption::VALUE_NONE, 'Generate delete-action'], + + ['events', null, InputOption::VALUE_NONE, 'Generate all common events'], + ['abstract-event', null, InputOption::VALUE_NONE, 'Generate abstract-event'], + ['creating-event', null, InputOption::VALUE_NONE, 'Generate creating-event'], + ['created-event', null, InputOption::VALUE_NONE, 'Generate created-event'], + ['updating-event', null, InputOption::VALUE_NONE, 'Generate updating-event'], + ['updated-event', null, InputOption::VALUE_NONE, 'Generate updated-event'], + ['deleting-event', null, InputOption::VALUE_NONE, 'Generate deleting-event'], + ['deleted-event', null, InputOption::VALUE_NONE, 'Generate deleted-event'], + + ['dtos', null, InputOption::VALUE_NONE, 'Generate all dtos'], + ['create-dto', null, InputOption::VALUE_NONE, 'Generate create-dto'], + ['update-dto', null, InputOption::VALUE_NONE, 'Generate update-dto'], + ]; + } + + protected function getArguments(): array + { + return array_filter([ + ['domain', InputArgument::REQUIRED, 'The name of the domain'], + ['model', InputArgument::REQUIRED, 'The name of the model'], + ]); + } + + private function createModel(): int + { + return $this->call(MakeDomainModel::class, [ + 'domain' => $this->argument('domain'), + 'model' => $this->argument('model'), + '--force' => $this->option('force'), + '--namespace' => $this->option('namespace'), + ]); + } + + private function createException(): int + { + return $this->call(MakeDomainException::class, [ + 'domain' => $this->argument('domain'), + 'model' => $this->argument('model'), + '--force' => $this->option('force'), + '--namespace' => $this->option('namespace'), + ]); + } + + private function createDto(string $type): int + { + return $this->call(MakeDomainDto::class, [ + 'domain' => $this->argument('domain'), + 'model' => $this->argument('model'), + 'type' => $type, + '--force' => $this->option('force'), + '--namespace' => $this->option('namespace'), + ]); + } + + private function createRepositoryInterface(): int + { + return $this->call(MakeDomainRepositoryInterface::class, [ + 'domain' => $this->argument('domain'), + 'model' => $this->argument('model'), + '--force' => $this->option('force'), + '--namespace' => $this->option('namespace'), + ]); + } + + private function createAbstractAction(): int + { + return $this->call(MakeDomainAbstractAction::class, [ + 'domain' => $this->argument('domain'), + 'model' => $this->argument('model'), + '--force' => $this->option('force'), + '--namespace' => $this->option('namespace'), + ]); + } + + private function createAction(string $type): int + { + return $this->call(MakeDomainAction::class, [ + 'domain' => $this->argument('domain'), + 'model' => $this->argument('model'), + 'type' => $type, + '--force' => $this->option('force'), + '--namespace' => $this->option('namespace'), + ]); + } + + private function createAbstractEvent(): int + { + return $this->call(MakeDomainAbstractEvent::class, [ + 'domain' => $this->argument('domain'), + 'model' => $this->argument('model'), + '--force' => $this->option('force'), + '--namespace' => $this->option('namespace'), + ]); + } + + private function createEvent(string $event): int + { + return $this->call(MakeDomainEvent::class, [ + 'domain' => $this->argument('domain'), + 'model' => $this->argument('model'), + 'type' => $event, + '--force' => $this->option('force'), + '--namespace' => $this->option('namespace'), + ]); + } +} diff --git a/src/Commands/MakeDomainAbstractAction.php b/src/Commands/MakeDomainAbstractAction.php new file mode 100644 index 0000000..5f7d182 --- /dev/null +++ b/src/Commands/MakeDomainAbstractAction.php @@ -0,0 +1,32 @@ +fqnHelper()->fqn('action', Str::studly("abstract_{$this->fqnHelper()->getModel()}_action"), true); + } + + protected function getVariables(): array + { + $h = $this->fqnHelper(); + + $actionFqn = $this->getClassFqn(); + $repositoryFqn = $h->repositoryInterfaceFqn(); + + return [ + 'namespace' => $h->getModelNamespace('action', true), + 'className' => $h->baseClass($actionFqn), + 'repositoryInterfaceFqn' => $repositoryFqn, + 'repositoryInterface' => $h->baseClass($repositoryFqn), + ]; + } +} diff --git a/src/Commands/MakeDomainAbstractEvent.php b/src/Commands/MakeDomainAbstractEvent.php new file mode 100644 index 0000000..99cac0d --- /dev/null +++ b/src/Commands/MakeDomainAbstractEvent.php @@ -0,0 +1,29 @@ +fqnHelper()->fqn('event', Str::studly("abstract_{$this->fqnHelper()->getModel()}_event"), true); + } + + protected function getVariables(): array + { + $h = $this->fqnHelper(); + + $eventFqn = $this->getClassFqn(); + + return [ + 'namespace' => $h->getModelNamespace('event', true), + 'className' => $h->baseClass($eventFqn), + ]; + } +} diff --git a/src/Commands/MakeDomainAction.php b/src/Commands/MakeDomainAction.php index 25498fc..23e7537 100644 --- a/src/Commands/MakeDomainAction.php +++ b/src/Commands/MakeDomainAction.php @@ -2,11 +2,93 @@ namespace ThisIsDevelopment\LaravelBaseDev\Commands; +use Illuminate\Support\Str; +use Symfony\Component\Console\Input\InputArgument; + class MakeDomainAction extends AbstractDomainGeneratorCommand { protected $name = 'make:domain-action'; protected $description = 'Create a new domain-action class'; - protected $type = 'Action'; + protected function getStubName(): string + { + return match ($this->argument('type')) { + 'create' => 'stubs/domain-create-action.stub', + 'delete' => 'stubs/domain-delete-action.stub', + + // catch all. This template is problably the most suitable + // for _any_ action that will be performed on the model + default => 'stubs/domain-update-action.stub', + }; + } + + protected function getClassFqn(): string + { + return $this->fqnHelper()->actionFqn($this->argument('type')); + } + + protected function getVariables(): array + { + $h = $this->fqnHelper(); + + $actionFqn = $h->actionFqn($this->argument('type')); + + $preEventFqn = $h->eventFqn(match ($this->argument('type')) { + 'create' => 'creating', + 'delete' => 'deleting', + default => 'updating', + }); + + $postEventFqn = $h->eventFqn(match ($this->argument('type')) { + 'create' => 'created', + 'delete' => 'deleted', + default => 'updated', + }); + + $exceptionFqn = $h->exceptionFqn(); + $modelFqn = $h->modelFqn(); + $parentFqn = $h->fqn( + 'action', + "abstract_{$this->argument('model')}_action", + hasMultiplePerModel: true + ); + + $context = [ + 'namespace' => $h->getModelNamespace('action', true), + 'className' => $h->baseClass($actionFqn), + + 'preEventFqn' => $preEventFqn, + 'preEventClass' => $h->baseClass($preEventFqn), + 'postEventFqn' => $postEventFqn, + 'postEventClass' => $h->baseClass($postEventFqn), + + 'exceptionFqn' => $exceptionFqn, + 'exceptionClass' => $h->baseClass($exceptionFqn), + 'modelFqn' => $modelFqn, + 'modelClass' => $h->baseClass($modelFqn), + 'parentClass' => $h->baseClass($parentFqn), + + 'repositoryAction' => Str::camel($this->argument('type')), + ]; + + // When deleting, dto's do not apply + if ('delete' !== $this->argument('type')) { + $dtoFqn = $h->dtoFqn($this->argument('type')); + $context += [ + 'dtoFqn' => $dtoFqn, + 'dtoClass' => $h->baseClass($dtoFqn) + ]; + } + + return $context; + } + + protected function getArguments(): array + { + return [ + ...parent::getArguments(), + ['type', InputArgument::REQUIRED, 'the type of action to generate'] + ]; + } } diff --git a/src/Commands/MakeDomainDto.php b/src/Commands/MakeDomainDto.php new file mode 100644 index 0000000..6af6f37 --- /dev/null +++ b/src/Commands/MakeDomainDto.php @@ -0,0 +1,37 @@ +fqnHelper()->dtoFqn($this->argument('type')); + } + + protected function getVariables(): array + { + $h = $this->fqnHelper(); + + $modelFqn = $h->dtoFqn($this->argument('type')); + + return [ + 'namespace' => $h->getModelNamespace('dto', true), + 'className' => $h->baseClass($modelFqn), + ]; + } + + protected function getArguments(): array + { + return [ + ...parent::getArguments(), + ['type', InputArgument::REQUIRED, 'the type of event to generate'] + ]; + } +} diff --git a/src/Commands/MakeDomainEvent.php b/src/Commands/MakeDomainEvent.php index ac36824..a5a1512 100644 --- a/src/Commands/MakeDomainEvent.php +++ b/src/Commands/MakeDomainEvent.php @@ -2,11 +2,78 @@ namespace ThisIsDevelopment\LaravelBaseDev\Commands; +use Illuminate\Support\Str; +use Symfony\Component\Console\Input\InputArgument; + class MakeDomainEvent extends AbstractDomainGeneratorCommand { protected $name = 'make:domain-event'; protected $description = 'Create a new domain-event class'; - protected $type = 'Event'; + protected function getStubName(): string + { + return match ($this->argument('type')) { + 'creating' => 'stubs/domain-creating-event.stub', + 'created' => 'stubs/domain-created-event.stub', + 'deleting' => 'stubs/domain-deleting-event.stub', + 'deleted' => 'stubs/domain-deleted-event.stub', + + // catch all. This template is problably the most suitable + // for _any_ update event that will be performed on the model + default => 'stubs/domain-update-event.stub', + }; + } + + protected function getClassFqn(): string + { + return $this->fqnHelper()->eventFqn($this->argument('type')); + } + + protected function getVariables(): array + { + $h = $this->fqnHelper(); + + $eventFqn = $h->eventFqn($this->argument('type')); + $parentFqn = $h->fqn( + 'events', + Str::studly("abstract_{$h->getModel()}_event"), + hasMultiplePerModel: true + ); + + $modelFqn = $h->modelFqn(); + + if (0 === preg_match('/(?:ed|ing)$/i', $this->argument('type'))) { + $this->warn(<<argument('type')}` does not end with `ing` or `ed`) + which could lead to invalid Dto references. Please verify generated code! + TXT); + } + + $dtoFqn = $h->dtoFqn(preg_replace( + '/(?:ed|ing)$/i', + 'e', + $this->argument('type') + )); + + return [ + 'namespace' => $h->getModelNamespace('event', true), + 'className' => $h->baseClass($eventFqn), + 'extends' => $h->baseClass($parentFqn), + 'modelFqn' => $modelFqn, + 'modelClass' => $h->baseClass($modelFqn), + 'modelVar' => $h->asVariable($modelFqn), + 'dtoFqn' => $dtoFqn, + 'dtoClass' => $h->baseClass($dtoFqn), + 'dtoVar' => $h->asVariable($dtoFqn), + ]; + } + + protected function getArguments(): array + { + return [ + ...parent::getArguments(), + ['type', InputArgument::REQUIRED, 'the type of event to generate'] + ]; + } } diff --git a/src/Commands/MakeDomainException.php b/src/Commands/MakeDomainException.php index b000ce3..ffe007c 100644 --- a/src/Commands/MakeDomainException.php +++ b/src/Commands/MakeDomainException.php @@ -8,7 +8,20 @@ class MakeDomainException extends AbstractDomainGeneratorCommand protected $description = 'Create a new domain-exception class'; - protected $type = 'Exception'; + protected function getClassFqn(): string + { + return $this->fqnHelper()->exceptionFqn(); + } - protected $hasMultiplePerModel = false; + protected function getVariables(): array + { + $h = $this->fqnHelper(); + + $exceptionFqn = $h->exceptionFqn(); + + return [ + 'namespace' => $h->getModelNamespace('exception', false), + 'className' => $h->baseClass($exceptionFqn), + ]; + } } diff --git a/src/Commands/MakeDomainModel.php b/src/Commands/MakeDomainModel.php index 6c0bad6..df25ba0 100644 --- a/src/Commands/MakeDomainModel.php +++ b/src/Commands/MakeDomainModel.php @@ -2,50 +2,26 @@ namespace ThisIsDevelopment\LaravelBaseDev\Commands; -use Symfony\Component\Console\Input\InputOption; - class MakeDomainModel extends AbstractDomainGeneratorCommand { protected $name = 'make:domain-model'; protected $description = 'Create a new domain-model class'; - protected $type = 'Model'; - - protected $hasMultiplePerModel = false; - - public function handle() + protected function getClassFqn(): string { - if (parent::handle() === false && !$this->option('force')) { - return false; - } - - if ($this->option('all')) { - $this->createException(); - $this->createRepositoryInterface(); - $this->createAction('create'); - $this->createAction('update'); - $this->createAction('delete'); - } - - return null; + return $this->fqnHelper()->modelFqn(); } - protected function getOptions() + protected function getVariables(): array { - return [ - ['all', 'a', InputOption::VALUE_NONE, 'Generate all related files'], - ['force', null, InputOption::VALUE_NONE, 'Create related files even if the model already exists'], - ]; - } + $h = $this->fqnHelper(); - private function createAction(string $action) - { - $this->call('make:domain-action', [ - 'domain' => $this->input->getArgument('domain'), - 'model' => $this->input->getArgument('name'), - 'name' => $action, - '--all' => true, - ]); + $modelFqn = $h->modelFqn(); + + return [ + 'namespace' => $h->getModelNamespace('model', false), + 'className' => $h->baseClass($modelFqn), + ]; } } diff --git a/src/Commands/MakeDomainRepositoryInterface.php b/src/Commands/MakeDomainRepositoryInterface.php index 0289315..189c854 100644 --- a/src/Commands/MakeDomainRepositoryInterface.php +++ b/src/Commands/MakeDomainRepositoryInterface.php @@ -8,7 +8,35 @@ class MakeDomainRepositoryInterface extends AbstractDomainGeneratorCommand protected $description = 'Create a new domain-repository interface'; - protected $type = 'Repository'; + protected function getClassFqn(): string + { + return $this->fqnHelper()->repositoryInterfaceFqn(); + } - protected $hasMultiplePerModel = false; + protected function getVariables(): array + { + $h = $this->fqnHelper(); + + $exceptionFqn = $h->exceptionFqn(); + $modelFqn = $h->modelFqn(); + $repositoryInterfaceFqn = $h->repositoryInterfaceFqn(); + $createDtoFqn = $h->dtoFqn('create'); + $updateDtoFqn = $h->dtoFqn('update'); + + return [ + 'namespace' => $h->getModelNamespace('repository', false), + 'className' => $h->baseClass($repositoryInterfaceFqn), + + 'exceptionFqn' => $exceptionFqn, + 'exceptionClass' => $h->baseClass($exceptionFqn), + + 'modelFqn' => $modelFqn, + 'modelClass' => $h->baseClass($modelFqn), + + 'createDtoFqn' => $createDtoFqn, + 'createDtoClass' => $h->baseClass($createDtoFqn), + 'updateDtoFqn' => $updateDtoFqn, + 'updateDtoClass' => $h->baseClass($updateDtoFqn) + ]; + } } diff --git a/src/Helpers/FqnHelper.php b/src/Helpers/FqnHelper.php new file mode 100644 index 0000000..da3660d --- /dev/null +++ b/src/Helpers/FqnHelper.php @@ -0,0 +1,110 @@ +domain = Str::studly(trim($domain)); + $this->model = Str::studly(trim($model)); + } + + public function getDomain(): string + { + return $this->domain; + } + + public function getModel(): string + { + return $this->model; + } + + public function modelFqn(): string + { + return $this->fqn( + 'model', + $this->model, + ); + } + + public function exceptionFqn(): string + { + return $this->fqn( + 'exception', + Str::studly("{$this->model}_exception"), + ); + } + + public function eventFqn(string $event): string + { + return $this->fqn( + 'event', + Str::studly("{$this->model}_{$event}_Event"), + hasMultiplePerModel: true + ); + } + + + public function actionFqn(string $action): string + { + return $this->fqn( + 'action', + Str::studly("{$action}_{$this->model}_Action"), + hasMultiplePerModel: true + ); + } + + public function dtoFqn(string $prefix = ''): string + { + return $this->fqn( + 'dto', + Str::studly(ltrim("{$prefix}_{$this->model}_dto", '_')), + hasMultiplePerModel: true + ); + } + + public function repositoryInterfaceFqn(): string + { + return $this->fqn( + 'repositories', + Str::studly("{$this->model}_repository_interface") + ); + } + + public function baseClass(string $fqn): string + { + return class_basename($fqn); + } + + public function asVariable(string $fqn): string + { + return Str::camel($this->baseClass($fqn)); + } + + + public function getModelNamespace(string $type, bool $hasMultiplePerModel = false): string + { + return implode('\\', array_filter([ + trim($this->rootNamespace, '\\'), + $this->domain, + Str::plural(Str::studly($type)), + $hasMultiplePerModel ? $this->model : '' + ])); + } + + public function fqn(string $type, string $className, bool $hasMultiplePerModel = false): string + { + return implode('\\', array_map(fn ($e) => trim($e, '\\'), array_filter([ + $this->getModelNamespace($type, $hasMultiplePerModel), + Str::studly($className), + ]))); + } +} diff --git a/src/Helpers/SQLiteConnection.php b/src/Helpers/SQLiteConnection.php new file mode 100644 index 0000000..68f7b05 --- /dev/null +++ b/src/Helpers/SQLiteConnection.php @@ -0,0 +1,31 @@ +getPdo()->sqliteCreateFunction('JSON_CONTAINS', function ($json, $val, $path = null) { + $array = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + // trim double quotes from around the value to match MySQL behaviour + $val = trim($val, '"'); + // this will work for a single dimension JSON value, if more dimensions + // something more sophisticated will be required + // that is left as an exercise for the reader + if ($path) { + return $array[$path] == $val; + } + + return in_array($val, $array, true); + }); + } + + protected function getDefaultQueryGrammar() + { + return new SQLiteGrammar($this) + ->setTablePrefix($this->tablePrefix); // here comes our custom Grammar object + } +} diff --git a/src/Helpers/SQLiteGrammar.php b/src/Helpers/SQLiteGrammar.php new file mode 100644 index 0000000..7e17bcc --- /dev/null +++ b/src/Helpers/SQLiteGrammar.php @@ -0,0 +1,14 @@ +wrapJsonFieldAndPath($column); + return 'json_contains(' . $field . ', ' . $value . $path . ')'; + } +} diff --git a/src/Providers/BaseDevServiceProvider.php b/src/Providers/BaseDevServiceProvider.php index 0d926a6..5076bdd 100644 --- a/src/Providers/BaseDevServiceProvider.php +++ b/src/Providers/BaseDevServiceProvider.php @@ -3,11 +3,17 @@ namespace ThisIsDevelopment\LaravelBaseDev\Providers; use Illuminate\Support\ServiceProvider; +use Illuminate\Database\Connection; +use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomain; +use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomainAbstractAction; +use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomainAbstractEvent; use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomainAction; +use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomainDto; use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomainEvent; use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomainException; use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomainModel; use ThisIsDevelopment\LaravelBaseDev\Commands\MakeDomainRepositoryInterface; +use ThisIsDevelopment\LaravelBaseDev\Helpers\SQLiteConnection; class BaseDevServiceProvider extends ServiceProvider { @@ -16,12 +22,31 @@ public function boot() // Register the command if we are using the application via the CLI if ($this->app->runningInConsole()) { $this->commands([ + MakeDomain::class, + MakeDomainAbstractAction::class, + MakeDomainAbstractEvent::class, MakeDomainAction::class, + MakeDomainDto::class, MakeDomainEvent::class, MakeDomainException::class, MakeDomainModel::class, - MakeDomainRepositoryInterface::class + MakeDomainRepositoryInterface::class, ]); } } + + public function register() + { + /** + * SQlite does not natively support JSON_CONTAINS, however it allows to define a UDF in + * php which implements that functionality. + * In order to "convince" laravel that our SQLite now does support JSON_CONTAINS we need a + * custom connection + grammar, our SQLiteConnection does exactly that. + */ + Connection::resolverFor('sqlite', function ($connection, $database, $prefix, $config) { + $conn = new SQLiteConnection($connection, $database, $prefix, $config); + $conn->addJsonContainsFunction(); + return $conn; + }); + } }