diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fb115d5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,14 @@ +name: Tests + +on: [push] + +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: php-actions/composer@v6 + - name: PHPUnit Tests + uses: php-actions/phpunit@v9 + with: + configuration: tests/phpunit.xml diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 0f734fb..2d363d1 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -1,7 +1,10 @@ exclude(['var', 'vendor', 'migrations', 'fixtures']) + ->exclude([ + 'src/Resources/skeleton', + 'vendor' + ]) ->in(__DIR__) ; diff --git a/README.md b/README.md index e9129bf..150d62f 100644 --- a/README.md +++ b/README.md @@ -153,3 +153,36 @@ class Logger extends Facade ``` Although facades are better testable than regular singletons, it is highly recommended to only use them sparringly and always prefer normal dependency injection when possible. + +## Generator Commands + +This bundle adds several [maker bundle](https://symfony.com/bundles/SymfonyMakerBundle/current/index.html) commands to generate commonly used components. + +### Model / Repository + +This command can be used to generate: + +- The domain model class. +- A repository class for the model. +- The model's identity class as value object (optional). +- A Doctrine database entity configuration, either as annotation or separate config file (optional). +- A custom Doctrine type for the model's identity class (optional). + +#### Command Output + +```bash +Description: + Creates a new domain model class + +Usage: + make:ddd:model [options] [--] [] + +Arguments: + name The name of the model class (e.g. Customer) + +Options: + --aggregate-root Marks the model as aggregate root + --entity=ENTITY Use this model as Doctrine entity + --with-identity=WITH-IDENTITY Whether an identity value object should be created + --with-suffix Adds the suffix "Model" to the model class name +``` diff --git a/composer.json b/composer.json index 8cb7fac..e1c6c85 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ }, "autoload": { "psr-4": { - "GeekCell\\DddBundle\\": "src" + "GeekCell\\DddBundle\\": "src/" } }, "autoload-dev": { @@ -36,7 +36,8 @@ "mockery/mockery": "^1.5", "phpstan/phpstan": "^1.9", "phpstan/phpstan-mockery": "^1.1", - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.5", + "symfony/maker-bundle": "^1.48" }, "scripts": { "gc:tests": "phpunit --testdox --colors=always", @@ -44,4 +45,4 @@ "gc:cs-fix": "php-cs-fixer fix --config .php-cs-fixer.php -vvv || true", "gc:phpstan": "phpstan analyse --memory-limit=2G --no-progress --level=8" } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index 4374b71..a5dbc6e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "46885e9569c3d37adfb7198a73bbcb7b", + "content-hash": "93cb43bd0e2b4d469005556b1c18fa2c", "packages": [ { "name": "beberlei/assert", @@ -5463,6 +5463,181 @@ ], "time": "2020-09-28T06:39:44+00:00" }, + { + "name": "symfony/cache", + "version": "v6.2.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "ddd1a70bfdf4ed19facad0db689c7bca979d322e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/ddd1a70bfdf4ed19facad0db689c7bca979d322e", + "reference": "ddd1a70bfdf4ed19facad0db689c7bca979d322e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^1.1.7|^2|^3", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/var-exporter": "^6.2" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/dependency-injection": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/var-dumper": "<5.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^2.13.1|^3.0", + "predis/predis": "^1.1", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/messenger": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v6.2.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-12-29T16:29:13+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "e8d1a5fc43534063204b74c080ebe36307d12271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/e8d1a5fc43534063204b74c080ebe36307d12271", + "reference": "e8d1a5fc43534063204b74c080ebe36307d12271", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "suggest": { + "symfony/cache-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.3-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-25T10:21:52+00:00" + }, { "name": "symfony/finder", "version": "v6.2.3", @@ -5527,6 +5702,250 @@ ], "time": "2022-12-22T17:55:15+00:00" }, + { + "name": "symfony/framework-bundle", + "version": "v6.2.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "c26ddbb2c2d8e5eaebbb1297b833be0967725fbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/c26ddbb2c2d8e5eaebbb1297b833be0967725fbc", + "reference": "c26ddbb2c2d8e5eaebbb1297b833be0967725fbc", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.1", + "symfony/cache": "^5.4|^6.0", + "symfony/config": "^6.1", + "symfony/dependency-injection": "^6.2", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/error-handler": "^6.1", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/finder": "^5.4|^6.0", + "symfony/http-foundation": "^6.2", + "symfony/http-kernel": "^6.2.1", + "symfony/polyfill-mbstring": "~1.0", + "symfony/routing": "^5.4|^6.0" + }, + "conflict": { + "doctrine/annotations": "<1.13.1", + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "phpunit/phpunit": "<5.4.3", + "symfony/asset": "<5.4", + "symfony/console": "<5.4", + "symfony/dom-crawler": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/form": "<5.4", + "symfony/http-client": "<5.4", + "symfony/lock": "<5.4", + "symfony/mailer": "<5.4", + "symfony/messenger": "<6.2", + "symfony/mime": "<6.2", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4", + "symfony/security-core": "<5.4", + "symfony/security-csrf": "<5.4", + "symfony/serializer": "<6.1", + "symfony/stopwatch": "<5.4", + "symfony/translation": "<5.4", + "symfony/twig-bridge": "<5.4", + "symfony/twig-bundle": "<5.4", + "symfony/validator": "<5.4", + "symfony/web-profiler-bundle": "<5.4", + "symfony/workflow": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.13.1|^2", + "doctrine/persistence": "^1.3|^2|^3", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/asset": "^5.4|^6.0", + "symfony/browser-kit": "^5.4|^6.0", + "symfony/console": "^5.4.9|^6.0.9", + "symfony/css-selector": "^5.4|^6.0", + "symfony/dom-crawler": "^5.4|^6.0", + "symfony/dotenv": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/form": "^5.4|^6.0", + "symfony/html-sanitizer": "^6.1", + "symfony/http-client": "^5.4|^6.0", + "symfony/lock": "^5.4|^6.0", + "symfony/mailer": "^5.4|^6.0", + "symfony/messenger": "^6.2", + "symfony/mime": "^6.2", + "symfony/notifier": "^5.4|^6.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^5.4|^6.0", + "symfony/property-info": "^5.4|^6.0", + "symfony/rate-limiter": "^5.4|^6.0", + "symfony/security-bundle": "^5.4|^6.0", + "symfony/semaphore": "^5.4|^6.0", + "symfony/serializer": "^6.1", + "symfony/stopwatch": "^5.4|^6.0", + "symfony/string": "^5.4|^6.0", + "symfony/translation": "^5.4|^6.0", + "symfony/twig-bundle": "^5.4|^6.0", + "symfony/uid": "^5.4|^6.0", + "symfony/validator": "^5.4|^6.0", + "symfony/web-link": "^5.4|^6.0", + "symfony/workflow": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0", + "twig/twig": "^2.10|^3.0" + }, + "suggest": { + "ext-apcu": "For best performance of the system caches", + "symfony/console": "For using the console commands", + "symfony/form": "For using forms", + "symfony/property-info": "For using the property_info service", + "symfony/serializer": "For using the serializer service", + "symfony/validator": "For using validation", + "symfony/web-link": "For using web links, features such as preloading, prefetching or prerendering", + "symfony/yaml": "For using the debug:config and lint:yaml commands" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v6.2.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-12-20T16:41:15+00:00" + }, + { + "name": "symfony/maker-bundle", + "version": "v1.48.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/maker-bundle.git", + "reference": "2e428e8432e9879187672fe08f1cc335e2a31dd6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/2e428e8432e9879187672fe08f1cc335e2a31dd6", + "reference": "2e428e8432e9879187672fe08f1cc335e2a31dd6", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "nikic/php-parser": "^4.11", + "php": ">=8.0", + "symfony/config": "^5.4.7|^6.0", + "symfony/console": "^5.4.7|^6.0", + "symfony/dependency-injection": "^5.4.7|^6.0", + "symfony/deprecation-contracts": "^2.2|^3", + "symfony/filesystem": "^5.4.7|^6.0", + "symfony/finder": "^5.4.3|^6.0", + "symfony/framework-bundle": "^5.4.7|^6.0", + "symfony/http-kernel": "^5.4.7|^6.0" + }, + "conflict": { + "doctrine/doctrine-bundle": "<2.4", + "doctrine/orm": "<2.10", + "symfony/doctrine-bridge": "<5.4" + }, + "require-dev": { + "composer/semver": "^3.0", + "doctrine/doctrine-bundle": "^2.4", + "doctrine/orm": "^2.10.0", + "symfony/http-client": "^5.4.7|^6.0", + "symfony/phpunit-bridge": "^5.4.7|^6.0", + "symfony/polyfill-php80": "^1.16.0", + "symfony/process": "^5.4.7|^6.0", + "symfony/security-core": "^5.4.7|^6.0", + "symfony/yaml": "^5.4.3|^6.0", + "twig/twig": "^2.0|^3.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MakerBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.", + "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", + "keywords": [ + "code generator", + "generator", + "scaffold", + "scaffolding" + ], + "support": { + "issues": "https://github.com/symfony/maker-bundle/issues", + "source": "https://github.com/symfony/maker-bundle/tree/v1.48.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-14T10:48:46+00:00" + }, { "name": "symfony/options-resolver", "version": "v6.2.0", @@ -5734,6 +6153,94 @@ ], "time": "2022-11-02T09:08:04+00:00" }, + { + "name": "symfony/routing", + "version": "v6.2.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "35fec764f3e2c8c08fb340d275c84bc78ca7e0c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/35fec764f3e2c8c08fb340d275c84bc78ca7e0c9", + "reference": "35fec764f3e2c8c08fb340d275c84bc78ca7e0c9", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "symfony/config": "<6.2", + "symfony/dependency-injection": "<5.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.2", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/http-foundation": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0" + }, + "suggest": { + "symfony/config": "For using the all-in-one router or any loader", + "symfony/expression-language": "For using expression matching", + "symfony/http-foundation": "For using a Symfony Request object", + "symfony/yaml": "For using the YAML loader" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v6.2.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-12-20T16:41:15+00:00" + }, { "name": "symfony/stopwatch", "version": "v6.2.0", diff --git a/config/services.yaml b/config/services.yaml index ee31b39..ea0884e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -16,3 +16,19 @@ services: arguments: - '@Symfony\Component\Messenger\MessageBusInterface' public: true + + GeekCell\DddBundle\Maker\MakeModel: + class: GeekCell\DddBundle\Maker\MakeModel + arguments: + - '@GeekCell\DddBundle\Maker\Doctrine\DoctrineConfigUpdater' + - '@maker.file_manager' + tags: + - { name: maker.command } + + maker.maker.make_model: + alias: GeekCell\DddBundle\Maker\MakeModel + public: true + + GeekCell\DddBundle\Maker\Doctrine\DoctrineConfigUpdater: + class: GeekCell\DddBundle\Maker\Doctrine\DoctrineConfigUpdater + public: false diff --git a/phpstan.neon b/phpstan.neon index db38942..d2370f1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,4 +8,5 @@ parameters: - src - tests excludePaths: + - src/Resources/skeleton - vendor/ diff --git a/src/Maker/Doctrine/DoctrineConfigUpdater.php b/src/Maker/Doctrine/DoctrineConfigUpdater.php new file mode 100644 index 0000000..a2404f4 --- /dev/null +++ b/src/Maker/Doctrine/DoctrineConfigUpdater.php @@ -0,0 +1,82 @@ +createYamlSourceManipulator($yamlSource); + $data['doctrine']['dbal']['types'][$identifier] = $mappingClass; + + return $this->getYamlContentsFromData($data); + } + + /** + * Updates the default entity mapping configuration. + * + * @param string $yamlSource The contents of current doctrine.yaml + * @param string $mappingType The type of the mapping (xml or annotation) + * @param string $directory The directory where the mapping files are located + * + * @return string The updated doctrine.yaml contents + */ + public function updateORMDefaultEntityMapping(string $yamlSource, string $mappingType, string $directory): string + { + Assert\Assertion::inArray($mappingType, ['xml', 'attribute'], 'Invalid mapping type: %s'); + + $data = $this->createYamlSourceManipulator($yamlSource); + $data['doctrine']['orm']['mappings']['App']['type'] = $mappingType; + $data['doctrine']['orm']['mappings']['App']['dir'] = $directory; + $data['doctrine']['orm']['mappings']['App']['prefix'] = 'App\Domain\Model'; + $data['doctrine']['orm']['mappings']['App']['alias'] = 'App'; + $data['doctrine']['orm']['mappings']['App']['is_bundle'] = false; + + return $this->getYamlContentsFromData($data); + } + + /** + * Creates a YamlSourceManipulator from a YAML source. + * + * @param string $yamlSource + * @return array + */ + private function createYamlSourceManipulator(string $yamlSource): array + { + $this->manipulator = new YamlSourceManipulator($yamlSource); + return $this->manipulator->getData(); + } + + /** + * Returns the updated YAML contents for the given data. + * + * @param array $yamlData + * @return string + */ + private function getYamlContentsFromData(array $yamlData): string + { + Assert\Assertion::notNull($this->manipulator); + $this->manipulator->setData($yamlData); + + return $this->manipulator->getContents(); + } +} diff --git a/src/Maker/MakeModel.php b/src/Maker/MakeModel.php new file mode 100644 index 0000000..3682056 --- /dev/null +++ b/src/Maker/MakeModel.php @@ -0,0 +1,578 @@ +> + */ + private $classesToImport = []; + + /** + * @var array + */ + private $templateVariables = []; + + + /** + * Constructor. + * + * @param DoctrineConfigUpdater $doctrineUpdater + * @param FileManager $fileManager + */ + public function __construct( + private DoctrineConfigUpdater $doctrineUpdater, + private FileManager $fileManager, + ) {} + + /** + * @inheritDoc + */ + public static function getCommandName(): string + { + return 'make:ddd:model'; + } + + /** + * @inheritDoc + */ + public static function getCommandDescription(): string + { + return 'Creates a new domain model class'; + } + + /** + * @inheritDoc + */ + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument( + 'name', + InputArgument::REQUIRED, + 'The name of the model class (e.g. Customer)', + ) + ->addOption( + 'aggregate-root', + null, + InputOption::VALUE_REQUIRED, + 'Marks the model as aggregate root', + ) + ->addOption( + 'entity', + null, + InputOption::VALUE_REQUIRED, + 'Use this model as Doctrine entity', + ) + ->addOption( + 'with-identity', + null, + InputOption::VALUE_REQUIRED, + 'Whether an identity value object should be created', + ) + ->addOption( + 'with-suffix', + null, + InputOption::VALUE_REQUIRED, + 'Adds the suffix "Model" to the model class name', + ) + ; + } + + /** + * @inheritDoc + */ + public function configureDependencies(DependencyBuilder $dependencies, InputInterface $input = null): void + { + if (null === $input || !$this->shouldGenerateEntity($input)) { + return; + } + + ORMDependencyBuilder::buildDependencies($dependencies); + } + + /** + * @inheritDoc + */ + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + if (!$this->fileManager->fileExists(DOCTRINE_CONFIG_PATH)) { + throw new RuntimeCommandException('The file "' . DOCTRINE_CONFIG_PATH . '" does not exist. This command requires that file to exist so that it can be updated.'); + } + + /** @var string $modelName */ + $modelName = $input->getArgument('name'); + + if (false === $input->getOption('with-suffix')) { + $useSuffix = $io->confirm( + sprintf( + 'Do you want to suffix the model class name? (%sModel)', + $modelName, + ), + false, + ); + $input->setOption('with-suffix', $useSuffix); + } + + if (false === $input->getOption('aggregate-root')) { + $asAggregateRoot = $io->confirm( + sprintf( + 'Do you want create %s%s as aggregate root?', + $modelName, + $useSuffix ? 'Model' : '', + ), + true, + ); + $input->setOption('aggregate-root', $asAggregateRoot); + } + + if (null === $input->getOption('with-identity')) { + $withIdentity = $io->choice( + sprintf( + 'How do you want to identify %s%s?', + $modelName, + $useSuffix ? 'Model' : '', + ), + [ + 'id' => sprintf( + 'Numeric identity representation (%sId)', + $modelName, + ), + 'uuid' => sprintf( + 'UUID representation (%sUuid)', + $modelName, + ), + 'n/a' => 'I\'ll take care later myself', + ], + ); + $input->setOption('with-identity', $withIdentity); + } + + if (null === $input->getOption('entity')) { + $asEntity = $io->choice( + sprintf( + 'Do you want %s%s to be a (Doctrine) database entity?', + $modelName, + $useSuffix ? 'Model' : '', + ), + [ + 'attributes' => 'Yes, via PHP attributes', + 'xml' => 'Yes, via XML mapping', + 'n/a' => 'No, I\'ll handle it separately', + ], + ); + $input->setOption('entity', $asEntity); + } + } + + /** + * @inheritDoc + */ + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + /** @var string $modelName */ + $modelName = $input->getArgument('name'); + $suffix = $input->getOption('with-suffix') ? 'Model' : ''; + + $modelClassNameDetails = $generator->createClassNameDetails( + $modelName, + 'Domain\\Model\\', + $suffix, + ); + + $this->templateVariables['class_name'] = $modelClassNameDetails->getShortName(); + + $this->generateIdentity($modelName, $input, $io, $generator); + $this->generateEntityMappings($modelClassNameDetails, $input, $io, $generator); + $this->generateEntity($modelClassNameDetails, $input, $io, $generator); + $this->generateRepository($modelClassNameDetails, $input, $io, $generator); + + $this->writeSuccessMessage($io); + } + + /** + * Optionally, generate the identity value object for the model. + * + * @param string $modelName + * @param InputInterface $input + * @param ConsoleStyle $io + * @param Generator $generator + */ + private function generateIdentity( + string $modelName, + InputInterface $input, + ConsoleStyle $io, + Generator $generator + ): void { + if (!$this->shouldGenerateIdentity($input)) { + return; + } + + // 1. Generate the identity value object. + + /** @var string $identityType */ + $identityType = $input->getOption('with-identity'); + $identityClassNameDetails = $generator->createClassNameDetails( + $modelName, + 'Domain\\Model\\ValueObject\\Identity\\', + ucfirst($identityType), + ); + + $extendsAlias = match ($identityType) { + 'id' => 'AbstractId', + 'uuid' => 'AbstractUuid', + default => null, + }; + + $baseClass = match ($identityType) { + 'id' => [Id::class => $extendsAlias], + 'uuid' => [Uuid::class => $extendsAlias], + default => null, + }; + + if (!$extendsAlias || !$baseClass) { + throw new \InvalidArgumentException(sprintf('Unknown identity type "%s"', $identityType)); + } + + // @phpstan-ignore-next-line + $useStatements = new UseStatementGenerator([$baseClass]); + + $generator->generateClass( + $identityClassNameDetails->getFullName(), + __DIR__.'/../Resources/skeleton/model/Identity.tpl.php', + [ + 'identity_class' => $identityClassNameDetails->getShortName(), + 'extends_alias' => $extendsAlias, + 'use_statements' => $useStatements, + ], + ); + + $this->classesToImport[] = $identityClassNameDetails->getFullName(); + $this->templateVariables['identity_type'] = $identityType; + $this->templateVariables['identity_class'] = $identityClassNameDetails->getShortName(); + + if (!$this->shouldGenerateEntity($input)) { + return; + } + + // 2. Generate custom Doctrine mapping type for the identity. + + $mappingTypeClassNameDetails = $generator->createClassNameDetails( + $modelName.ucfirst($identityType), + 'Infrastructure\\Doctrine\\DBAL\\Type\\', + 'Type', + ); + + $baseTypeClass = match ($identityType) { + 'id' => AbstractIdType::class, + 'uuid' => AbstractUuidType::class, + default => null, + }; + + if (!$baseTypeClass) { + throw new \InvalidArgumentException(sprintf('Unknown identity type "%s"', $identityType)); + } + + $useStatements = new UseStatementGenerator([ + $identityClassNameDetails->getFullName(), + $baseTypeClass + ]); + + $typeName = u($identityClassNameDetails->getShortName())->snake()->toString(); + $generator->generateClass( + $mappingTypeClassNameDetails->getFullName(), + __DIR__.'/../Resources/skeleton/model/DoctrineMappingType.tpl.php', + [ + 'type_name' => $typeName, + 'type_class' => $mappingTypeClassNameDetails->getShortName(), + 'extends_type_class' => sprintf('Abstract%sType', ucfirst($identityType)), + 'identity_class' => $identityClassNameDetails->getShortName(), + 'use_statements' => $useStatements, + ], + ); + + $configPath = 'config/packages/doctrine.yaml'; + if (!$this->fileManager->fileExists($configPath)) { + $io->error(sprintf('Doctrine configuration at path "%s" does not exist.', $configPath)); + return; + } + + // 2.1 Add the custom mapping type to the Doctrine configuration. + + $newYaml = $this->doctrineUpdater->addCustomDBALMappingType( + $this->fileManager->getFileContents($configPath), + $typeName, + $mappingTypeClassNameDetails->getFullName(), + ); + $generator->dumpFile($configPath, $newYaml); + + $this->classesToImport[] = $mappingTypeClassNameDetails->getFullName(); + $this->templateVariables['type_class'] = $mappingTypeClassNameDetails->getShortName(); + $this->templateVariables['type_name'] = $typeName; + + // Write out the changes. + $generator->writeChanges(); + } + + /** + * Optionally, generate entity mappings for the model. + * + * @param ClassNameDetails $modelClassNameDetails + * @param InputInterface $input + * @param ConsoleStyle $io + * @param Generator $generator + */ + private function generateEntityMappings( + ClassNameDetails $modelClassNameDetails, + InputInterface $input, + ConsoleStyle $io, + Generator $generator + ): void { + if (!$this->shouldGenerateEntity($input)) { + return; + } + + $modelName = $modelClassNameDetails->getShortName(); + + if ($this->shouldGenerateEntityAttributes($input)) { + try { + $newYaml = $this->doctrineUpdater->updateORMDefaultEntityMapping( + $this->fileManager->getFileContents(DOCTRINE_CONFIG_PATH), + 'attribute', + '%kernel.project_dir%/src/Domain/Model', + ); + $generator->dumpFile(DOCTRINE_CONFIG_PATH, $newYaml); + $this->classesToImport[] = ['Doctrine\\ORM\\Mapping' => 'ORM']; + $this->templateVariables['as_entity'] = true; + } catch (YamlManipulationFailedException $e) { + $io->error($e->getMessage()); + $this->templateVariables['as_entity'] = false; + } + + return; + } + + if ($this->shouldGenerateEntityXml($input)) { + $tableName = u($modelClassNameDetails->getShortName())->before('Model')->snake()->toString(); + $hasIdentity = $this->shouldGenerateIdentity($input); + if ($hasIdentity && !isset($this->templateVariables['type_name'])) { + throw new \LogicException( + 'Cannot generate entity XML mapping without identity type (which should have been generated).' + ); + } + + $this->templateVariables['as_entity'] = false; + + try { + $mappingsDirectory = '/src/Infrastructure/Doctrine/ORM/Mapping'; + $newYaml = $this->doctrineUpdater->updateORMDefaultEntityMapping( + $this->fileManager->getFileContents(DOCTRINE_CONFIG_PATH), + 'xml', + '%kernel.project_dir%'.$mappingsDirectory, + ); + $generator->dumpFile(DOCTRINE_CONFIG_PATH, $newYaml); + + $targetPath = sprintf( + '%s%s/%s.orm.xml', + $this->fileManager->getRootDirectory(), + $mappingsDirectory, + $modelName + ); + $generator->generateFile( + $targetPath, + __DIR__.'/../Resources/skeleton/doctrine/Mapping.tpl.xml.php', + [ + 'model_class' => $modelClassNameDetails->getFullName(), + 'has_identity' => $hasIdentity, + 'type_name' => $this->templateVariables['type_name'], + 'table_name' => $tableName, + 'identity_column_name' => $this->templateVariables['identity_type'], + ], + ); + } catch (YamlManipulationFailedException $e) { + $io->error($e->getMessage()); + } + } + + // Write out the changes. + $generator->writeChanges(); + } + + /** + * Generate model entity + * + * @param ClassNameDetails $modelClassNameDetails + * @param InputInterface $input + * @param ConsoleStyle $io + * @param Generator $generator + */ + private function generateEntity( + ClassNameDetails $modelClassNameDetails, + InputInterface $input, + ConsoleStyle $io, + Generator $generator + ): void { + if ($input->getOption('aggregate-root')) { + $this->classesToImport[] = AggregateRoot::class; + $this->templateVariables['extends_aggregate_root'] = true; + } + + // @phpstan-ignore-next-line + $this->templateVariables['use_statements'] = new UseStatementGenerator($this->classesToImport); + + $templatePath = __DIR__.'/../Resources/skeleton/model/Model.tpl.php'; + $generator->generateClass( + $modelClassNameDetails->getFullName(), + $templatePath, + $this->templateVariables, + ); + + $generator->writeChanges(); + } + + /** + * Generate model repository + * + * @param InputInterface $input + * @param ConsoleStyle $io + * @param Generator $generator + */ + private function generateRepository( + ClassNameDetails $modelClassNameDetails, + InputInterface $input, + ConsoleStyle $io, + Generator $generator + ): void { + $classNameDetails = $generator->createClassNameDetails( + $input->getArgument('name'), + 'Repository\\', + 'Repository', + ); + + $templateVars = [ + 'use_statements' => new UseStatementGenerator([ + $modelClassNameDetails->getFullName(), + ServiceEntityRepository::class, + ManagerRegistry::class, + QueryBuilder::class + ]), + 'entity_class_name' => $modelClassNameDetails->getShortName() + ]; + + $templatePath = __DIR__.'/../Resources/skeleton/model/Repository.tpl.php'; + $generator->generateClass( + $classNameDetails->getFullName(), + $templatePath, + $templateVars, + ); + + $generator->writeChanges(); + } + + // Helper methods + + /** + * Returns whether the user wants to generate entity mappings as PHP attributes. + * + * @param InputInterface $input + * @return bool + */ + private function shouldGenerateEntityAttributes(InputInterface $input): bool + { + return 'attributes' === $input->getOption('entity'); + } + + /** + * Returns whether the user wants to generate entity mappings as XML. + * + * @param InputInterface $input + * @return bool + */ + private function shouldGenerateEntityXml(InputInterface $input): bool + { + return 'xml' === $input->getOption('entity'); + } + + /** + * Returns whether the user wants to generate entity mappings. + * + * @param InputInterface $input + * @return bool + */ + private function shouldGenerateEntity(InputInterface $input): bool + { + return ( + $this->shouldGenerateEntityAttributes($input) || + $this->shouldGenerateEntityXml($input) + ); + } + + /** + * Returns whether the user wants to generate an identity value object for the model. + * + * @param InputInterface $input + * @return bool + */ + private function shouldGenerateId(InputInterface $input): bool + { + return 'id' === $input->getOption('with-identity'); + } + + /** + * Returns whether the user wants to generate a UUID value object for the model. + * + * @param InputInterface $input + * @return bool + */ + private function shouldGenerateUuid(InputInterface $input): bool + { + return 'uuid' === $input->getOption('with-identity'); + } + + /** + * Returns whether the user wants to generate an identity value object for the model. + * + * @param InputInterface $input + * @return bool + */ + private function shouldGenerateIdentity(InputInterface $input): bool + { + return ( + $this->shouldGenerateId($input) || + $this->shouldGenerateUuid($input) + ); + } +} diff --git a/src/Resources/skeleton/doctrine/Mapping.tpl.xml.php b/src/Resources/skeleton/doctrine/Mapping.tpl.xml.php new file mode 100644 index 0000000..b8fbf60 --- /dev/null +++ b/src/Resources/skeleton/doctrine/Mapping.tpl.xml.php @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/Resources/skeleton/model/DoctrineMappingType.tpl.php b/src/Resources/skeleton/model/DoctrineMappingType.tpl.php new file mode 100644 index 0000000..2e8dd0f --- /dev/null +++ b/src/Resources/skeleton/model/DoctrineMappingType.tpl.php @@ -0,0 +1,20 @@ + + +namespace ; + + + +class extends +{ + public const NAME = ''; + + public function getName(): string + { + return self::NAME; + } + + protected function getIdType(): string + { + return ::class; + } +} diff --git a/src/Resources/skeleton/model/Identity.tpl.php b/src/Resources/skeleton/model/Identity.tpl.php new file mode 100644 index 0000000..d416ee4 --- /dev/null +++ b/src/Resources/skeleton/model/Identity.tpl.php @@ -0,0 +1,9 @@ + + +namespace ; + + + +class extends +{ +} diff --git a/src/Resources/skeleton/model/Model.tpl.php b/src/Resources/skeleton/model/Model.tpl.php new file mode 100644 index 0000000..ce94ab3 --- /dev/null +++ b/src/Resources/skeleton/model/Model.tpl.php @@ -0,0 +1,43 @@ + + +namespace ; + + + + +#[ORM\Entity] + +class extends AggregateRoot +{ + + /** + * @var + */ + + #[ORM\Id] + #[ORM\Column(type: ::NAME)] + + private $; + + + public function __construct() + { + + $this->id = new (1); + + $this->uuid = ::random(); + + } + + + /** + * Get + * + * @return + */ + public function get(): + { + return $this->; + } + +} diff --git a/src/Resources/skeleton/model/Repository.tpl.php b/src/Resources/skeleton/model/Repository.tpl.php new file mode 100644 index 0000000..4a3c9d7 --- /dev/null +++ b/src/Resources/skeleton/model/Repository.tpl.php @@ -0,0 +1,63 @@ + + +namespace ; + + + +class extends ServiceEntityRepository +{ + /** + * @extends ServiceEntityRepository<> + * + * @method |null find($id, $lockMode = null, $lockVersion = null) + * @method |null findOneBy(array $criteria, array $orderBy = null) + * @method [] findAll() + * @method [] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ::class); + } + + public function save( $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove( $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + +// /** +// * @return [] Returns an array of objects +// */ +// public function findByExampleField($value): array +// { +// return $this->filter(function(QueryBuilder $queryBuilder) use ($value) { +// $queryBuilder +// ->andWhere('t.exampleField = :val') +// ->setParamter('val', $value) +// ->orderBy('t.id', 'ASC') +// ; +// }); +// } + +// public function findOneBySomeField($value): ? +// { +// return $this->createQueryBuilder('t') +// ->andWhere('t.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/tests/Integration/GeekCellMakerTestKernel.php b/tests/Integration/GeekCellMakerTestKernel.php new file mode 100644 index 0000000..cb43772 --- /dev/null +++ b/tests/Integration/GeekCellMakerTestKernel.php @@ -0,0 +1,17 @@ + + + + + . + + + +