initial commit

This commit is contained in:
boris
2025-09-30 09:24:25 +01:00
committed by boris
parent a783a12c97
commit c7770ea03b
4695 changed files with 525784 additions and 0 deletions

19
vendor/symfony/maker-bundle/LICENSE vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2004-2020 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,78 @@
{
"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",
"name": "symfony/maker-bundle",
"type": "symfony-bundle",
"license": "MIT",
"keywords": ["generator", "code generator", "scaffolding", "scaffold", "dev"],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"php": ">=8.1",
"doctrine/inflector": "^2.0",
"nikic/php-parser": "^5.0",
"symfony/config": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0",
"symfony/dependency-injection": "^6.4|^7.0",
"symfony/deprecation-contracts": "^2.2|^3",
"symfony/filesystem": "^6.4|^7.0",
"symfony/finder": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/process": "^6.4|^7.0"
},
"require-dev": {
"composer/semver": "^3.0",
"doctrine/doctrine-bundle": "^2.5.0",
"doctrine/orm": "^2.15|^3",
"symfony/http-client": "^6.4|^7.0",
"symfony/phpunit-bridge": "^6.4.1|^7.0",
"symfony/security-core": "^6.4|^7.0",
"symfony/security-http": "^6.4|^7.0",
"symfony/yaml": "^6.4|^7.0",
"twig/twig": "^3.0|^4.x-dev"
},
"config": {
"preferred-install": "dist",
"sort-packages": true
},
"conflict": {
"doctrine/orm": "<2.15",
"doctrine/doctrine-bundle": "<2.10"
},
"autoload": {
"psr-4": { "Symfony\\Bundle\\MakerBundle\\": "src/" }
},
"autoload-dev": {
"psr-4": { "Symfony\\Bundle\\MakerBundle\\Tests\\": "tests/" }
},
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"scripts": {
"tools:upgrade": [
"@tools:upgrade:php-cs-fixer",
"@tools:upgrade:phpstan",
"@tools:upgrade:twigcs"
],
"tools:upgrade:php-cs-fixer": "composer upgrade -W -d tools/php-cs-fixer",
"tools:upgrade:phpstan": "composer upgrade -W -d tools/phpstan",
"tools:upgrade:twigcs": "composer upgrade -W -d tools/twigcs",
"tools:run": [
"@tools:run:php-cs-fixer",
"@tools:run:phpstan",
"@tools:run:twigcs"
],
"tools:run:php-cs-fixer": "tools/php-cs-fixer/vendor/bin/php-cs-fixer fix",
"tools:run:phpstan": "tools/phpstan/vendor/bin/phpstan --memory-limit=1G",
"tools:run:twigcs": "tools/twigcs/vendor/bin/twigcs --config tools/twigcs/.twigcs.dist.php"
}
}

View File

@@ -0,0 +1,8 @@
The <info>%command.name%</info> command generates various authentication systems,
by asking questions.
It can provide an empty authenticator, or a full login form authentication process.
In both cases it also updates your <info>security.yaml</info>.
For the login form, it also generates a controller and the Twig template.
<info>php %command.full_name%</info>

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new command:
<info>php %command.full_name% app:do-something</info>
If the argument is missing, the command will ask for the command name interactively.

View File

@@ -0,0 +1,14 @@
The <info>%command.name%</info> command generates a new controller class.
<info>php %command.full_name% CoolStuffController</info>
If the argument is missing, the command will ask for the controller class name interactively.
If you have the <info>symfony/twig-bundle</info> installed, a Twig template will also be
generated for the controller.
<info>composer require symfony/twig-bundle</info>
You can also generate the controller alone, without template with this option:
<info>php %command.full_name% --no-template</info>

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates crud controller with templates for selected entity.
<info>php %command.full_name% BlogPost</info>
If the argument is missing, the command will ask for the entity class name interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates or updates databases services in compose.yaml
<info>php %command.full_name%</info>
Supports MySQL, MariaDB and PostgreSQL

View File

@@ -0,0 +1,24 @@
The <info>%command.name%</info> command creates or updates an entity and repository class.
<info>php %command.full_name% BlogPost</info>
If the argument is missing, the command will ask for the entity class name interactively.
You can also mark this class as an API Platform resource. A hypermedia CRUD API will
automatically be available for this entity class:
<info>php %command.full_name% --api-resource</info>
Symfony can also broadcast all changes made to the entity to the client using Symfony
UX Turbo.
<info>php %command.full_name% --broadcast</info>
You can also generate all the getter/setter/adder/remover methods
for the properties of existing entities:
<info>php %command.full_name% --regenerate</info>
You can also *overwrite* any existing methods:
<info>php %command.full_name% --regenerate --overwrite</info>

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new Doctrine fixtures class.
<info>php %command.full_name% AppFixtures</info>
If the argument is missing, the command will ask for a class interactively.

View File

@@ -0,0 +1,16 @@
The <info>%command.name%</info> command generates a new form class.
<info>php %command.full_name% UserType</info>
If the argument is missing, the command will ask for the form class interactively.
You can optionally specify the bound class in a second argument.
This can be the name of an entity like <info>User</info>
<info>php %command.full_name% UserType User</info>
You can also specify a fully qualified name to another class like <info>\App\Dto\UserData</info>.
Slashes must be escaped in the argument.
<info>php %command.full_name% UserType \\App\\Dto\\UserData</info>

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new functional test class.
<info>php %command.full_name% DefaultControllerTest</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new event subscriber class or a new event listener class.
<info>php %command.full_name% ExceptionListener</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new message class & handler.
<info>php %command.full_name% EmailMessage</info>
If the argument is missing, the command will ask for the message class interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new Middleware class.
<info>php %command.full_name% CustomMiddleware</info>
If the argument is missing, the command will ask for the message class interactively.

View File

@@ -0,0 +1,7 @@
The <info>%command.name%</info> command generates a new migration:
<info>php %command.full_name%</info>
You can also generate a formatted migration with this option:
<info>php %command.full_name% --formatted</info>

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a complete registration form, controller & template.
<info>php %command.full_name%</info>
The command will ask for several pieces of information to build your form.

View File

@@ -0,0 +1,18 @@
The <info>%command.name%</info> command generates all the files needed to implement
a fully-functional & secure password reset system.
The SymfonycastsResetPasswordBundle is required and can be added using composer:
<info>composer require symfonycasts/reset-password-bundle</info>
For more information on the <info>reset-password-bundle</info> check out:
<href=https://github.com/symfonycasts/reset-password-bundle>https://github.com/symfonycasts/reset-password-bundle</>
<info>%command.name%</info> requires a user entity with an email property,
email getter method, and a password setter method. Maker will ask for these
interactively if they cannot be guessed.
Maker will also update your <info>reset-password.yaml</info> configuration file
if one exists. If you have customized the configuration file, maker will attempt
to modify it accordingly but preserve your customizations.
<info>php %command.full_name%</info>

View File

@@ -0,0 +1,8 @@
The <info>%command.name%</info> command generates a schedule to automate repeated
tasks using Symfony's Scheduler Component.
If the Scheduler Component is not installed, <info>%command.name%</info> will
install it automatically using composer. You can of course do this manually by
running <info>composer require symfony/scheduler</info>.
<info>php %command.full_name%</info>

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new serializer encoder class.
<info>php %command.full_name% YamlEncoder</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new serializer normalizer class.
<info>php %command.full_name% UserNormalizer</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,15 @@
The <info>%command.name%</info> command generates a new Stimulus controller.
<info>php %command.full_name% hello</info>
If the argument is missing, the command will ask for the controller name interactively.
To generate a TypeScript file (instead of a JavaScript file) use the <info>--typescript</info>
(or <info>--ts</info>) option:
<info>php %command.full_name% hello --typescript</info>
It will also interactively ask for values, targets, classes to add to the Stimulus
controller (optional).
<info>php %command.full_name%</info>

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new event subscriber class.
<info>php %command.full_name% ExceptionSubscriber</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,7 @@
The <info>%command.name%</info> command generates a new test class.
<info>php %command.full_name% TestCase BlogPostTest</info>
If the first argument is missing, the command will ask for the test type interactively.
If the second argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new Twig extension with its runtime class.
<info>php %command.full_name% AppExtension</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new unit test class.
<info>php %command.full_name% UtilTest</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,7 @@
The <info>%command.name%</info> command generates a new user class for security
and updates your security.yaml file for it. It will also generate a user provider
class if your situation needs a custom class.
<info>php %command.full_name% User</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new validation constraint.
<info>php %command.full_name% EnabledValidator</info>
If the argument is missing, the command will ask for the constraint class name interactively.

View File

@@ -0,0 +1,5 @@
The <info>%command.name%</info> command generates a new security voter.
<info>php %command.full_name% BlogPostVoter</info>
If the argument is missing, the command will ask for the class name interactively.

View File

@@ -0,0 +1,8 @@
The <info>%command.name%</info> command creates a RequestParser, a WebhookHandler and adds the necessary configuration
for a new Webhook.
<info>php %command.full_name% stripe</info>
If the argument is missing, the command will ask for the webhook name interactively.
It will also interactively ask for the RequestMatchers to use for the RequestParser's getRequestMatcher function.

View File

@@ -0,0 +1,6 @@
To generate tailored PHPUnit tests, simply call:
<info>php %command.full_name% --with-tests</info>
This will generate a unit test in <info>tests/</info> for you to review then use
to test the new functionality of your app.

View File

@@ -0,0 +1,10 @@
Instead of using the default "int" type for the entity's "id", you can use the
UUID type from Symfony's Uid component.
<href=https://symfony.com/doc/current/components/uid.html#storing-uuids-in-databases>https://symfony.com/doc/current/components/uid.html#storing-uuids-in-databases</>
<info>php %command.full_name% --with-uuid</info>
Or you can use the ULID type from Symfony's Uid component.
<href=https://symfony.com/doc/current/components/uid.html#storing-ulids-in-databases>https://symfony.com/doc/current/components/uid.html#storing-ulids-in-databases</>
<info>php %command.full_name% --with-ulid</info>

View File

@@ -0,0 +1,8 @@
The <info>%command.name%</info> command generates a simple custom authenticator
class based off the example provided in:
<href=https://symfony.com/doc/current/security/custom_authenticator.html>https://symfony.com/doc/current/security/custom_authenticator.html</>
This will also update your <info>security.yaml</info> for the new custom authenticator.
<info>php %command.full_name%</info>

View File

@@ -0,0 +1,9 @@
The <info>%command.name%</info> command generates a controller and Twig template
to allow users to login using the form_login authenticator.
The controller name, and logout ability can be customized by answering the
questions asked when running <info>%command.name%</info>.
This will also update your <info>security.yaml</info> for the new authenticator.
<info>php %command.full_name%</info>

View File

@@ -0,0 +1,175 @@
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<defaults public="false" />
<service id="maker.maker.make_authenticator" class="Symfony\Bundle\MakerBundle\Maker\MakeAuthenticator">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.security_config_updater" />
<argument type="service" id="maker.generator" />
<argument type="service" id="maker.doctrine_helper" />
<argument type="service" id="maker.security_controller_builder" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_command" class="Symfony\Bundle\MakerBundle\Maker\MakeCommand">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_twig_component" class="Symfony\Bundle\MakerBundle\Maker\MakeTwigComponent">
<tag name="maker.command" />
<argument type="service" id="maker.file_manager" />
</service>
<service id="maker.maker.make_controller" class="Symfony\Bundle\MakerBundle\Maker\MakeController">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_crud" class="Symfony\Bundle\MakerBundle\Maker\MakeCrud">
<argument type="service" id="maker.doctrine_helper" />
<argument type="service" id="maker.renderer.form_type_renderer" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_docker_database" class="Symfony\Bundle\MakerBundle\Maker\MakeDockerDatabase">
<argument type="service" id="maker.file_manager" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_entity" class="Symfony\Bundle\MakerBundle\Maker\MakeEntity">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.doctrine_helper" />
<argument>null</argument>
<argument type="service" id="maker.generator" />
<argument type="service" id="maker.entity_class_generator" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_fixtures" class="Symfony\Bundle\MakerBundle\Maker\MakeFixtures">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_form" class="Symfony\Bundle\MakerBundle\Maker\MakeForm">
<argument type="service" id="maker.doctrine_helper" />
<argument type="service" id="maker.renderer.form_type_renderer" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_functional_test" class="Symfony\Bundle\MakerBundle\Maker\MakeFunctionalTest">
<tag name="maker.command" />
<deprecated package="symfony/maker-bundle" version="1.29">The "%service_id%" service is deprecated, use "maker.maker.make_test" instead.</deprecated>
</service>
<service id="maker.maker.make_listener" class="Symfony\Bundle\MakerBundle\Maker\MakeListener">
<tag name="maker.command" />
<argument type="service" id="maker.event_registry" />
</service>
<service id="maker.maker.make_message" class="Symfony\Bundle\MakerBundle\Maker\MakeMessage">
<argument type="service" id="maker.file_manager" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_messenger_middleware" class="Symfony\Bundle\MakerBundle\Maker\MakeMessengerMiddleware">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_registration_form" class="Symfony\Bundle\MakerBundle\Maker\MakeRegistrationForm">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.renderer.form_type_renderer" />
<argument type="service" id="maker.doctrine_helper" />
<argument type="service" id="router" on-invalid="ignore" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_reset_password" class="Symfony\Bundle\MakerBundle\Maker\MakeResetPassword">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.doctrine_helper" />
<argument type="service" id="maker.entity_class_generator" />
<argument type="service" id="router" on-invalid="ignore" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_schedule" class="Symfony\Bundle\MakerBundle\Maker\MakeSchedule">
<argument type="service" id="maker.file_manager" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_serializer_encoder" class="Symfony\Bundle\MakerBundle\Maker\MakeSerializerEncoder">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_serializer_normalizer" class="Symfony\Bundle\MakerBundle\Maker\MakeSerializerNormalizer">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_subscriber" class="Symfony\Bundle\MakerBundle\Maker\MakeSubscriber">
<tag name="maker.command" />
<argument type="service" id="maker.event_registry" />
<deprecated package="symfony/maker-bundle" version="1.51">The "%service_id%" service is deprecated, use "maker.maker.make_listener" instead.</deprecated>
</service>
<service id="maker.maker.make_twig_extension" class="Symfony\Bundle\MakerBundle\Maker\MakeTwigExtension">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_test" class="Symfony\Bundle\MakerBundle\Maker\MakeTest">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_unit_test" class="Symfony\Bundle\MakerBundle\Maker\MakeUnitTest">
<tag name="maker.command" />
<deprecated package="symfony/maker-bundle" version="1.29">The "%service_id%" service is deprecated, use "maker.maker.make_test" instead.</deprecated>
</service>
<service id="maker.maker.make_validator" class="Symfony\Bundle\MakerBundle\Maker\MakeValidator">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_voter" class="Symfony\Bundle\MakerBundle\Maker\MakeVoter">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_user" class="Symfony\Bundle\MakerBundle\Maker\MakeUser">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.user_class_builder" />
<argument type="service" id="maker.security_config_updater" />
<argument type="service" id="maker.entity_class_generator" />
<argument type="service" id="maker.doctrine_helper" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_migration" class="Symfony\Bundle\MakerBundle\Maker\MakeMigration">
<argument>%kernel.project_dir%</argument>
<argument type="service" id="maker.file_link_formatter" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_stimulus_controller" class="Symfony\Bundle\MakerBundle\Maker\MakeStimulusController">
<tag name="maker.command" />
</service>
<service id="maker.maker.make_form_login" class="Symfony\Bundle\MakerBundle\Maker\Security\MakeFormLogin">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.security_config_updater" />
<argument type="service" id="maker.security_controller_builder" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_custom_authenticator" class="Symfony\Bundle\MakerBundle\Maker\Security\MakeCustomAuthenticator">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.generator" />
<tag name="maker.command" />
</service>
<service id="maker.maker.make_webhook" class="Symfony\Bundle\MakerBundle\Maker\MakeWebhook">
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.generator" />
<tag name="maker.command" />
</service>
</services>
</container>

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/*
* This PHP-CS-Fixer config file is used by the TemplateLinter for userland
* code when say make:controller is run. If a user does not have a php-cs-fixer
* config file, this one is used on the generated PHP files.
*
* It should not be confused by the root level .php-cs-fixer.dist.php config
* which is used to maintain the MakerBundle codebase itself.
*/
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
'@Symfony:risky' => true,
'native_function_invocation' => false,
'blank_line_before_statement' => ['statements' => ['break', 'case', 'continue', 'declare', 'default', 'do', 'exit', 'for', 'foreach', 'goto', 'if', 'include', 'include_once', 'phpdoc', 'require', 'require_once', 'return', 'switch', 'throw', 'try', 'while', 'yield', 'yield_from']],
'array_indentation' => true,
])
->setRiskyAllowed(true)
;

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<defaults public="false" />
<service id="maker.file_manager" class="Symfony\Bundle\MakerBundle\FileManager">
<argument type="service" id="filesystem" />
<argument type="service" id="maker.autoloader_util" />
<argument type="service" id="maker.file_link_formatter" />
<argument>%kernel.project_dir%</argument>
<argument>%twig.default_path%</argument>
</service>
<service id="maker.autoloader_finder" class="Symfony\Bundle\MakerBundle\Util\ComposerAutoloaderFinder" >
<argument /> <!-- root namespace -->
</service>
<service id="maker.autoloader_util" class="Symfony\Bundle\MakerBundle\Util\AutoloaderUtil">
<argument type="service" id="maker.autoloader_finder" />
</service>
<service id="maker.file_link_formatter" class="Symfony\Bundle\MakerBundle\Util\MakerFileLinkFormatter" >
<argument type="service" id="debug.file_link_formatter" on-invalid="ignore" />
</service>
<service id="maker.event_registry" class="Symfony\Bundle\MakerBundle\EventRegistry">
<argument type="service" id="event_dispatcher" />
</service>
<service id="maker.console_error_listener" class="Symfony\Bundle\MakerBundle\Event\ConsoleErrorSubscriber">
<tag name="kernel.event_subscriber" />
</service>
<service id="maker.doctrine_helper" class="Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper">
<argument /> <!-- entity namespace -->
<argument type="service" id="doctrine" on-invalid="ignore" />
</service>
<service id="maker.template_linter" class="Symfony\Bundle\MakerBundle\Util\TemplateLinter">
<argument>%env(default::string:MAKER_PHP_CS_FIXER_BINARY_PATH)%</argument>
<argument>%env(default::string:MAKER_PHP_CS_FIXER_CONFIG_PATH)%</argument>
</service>
<service id="maker.auto_command.abstract" class="Symfony\Bundle\MakerBundle\Command\MakerCommand" abstract="true">
<argument /> <!-- maker -->
<argument type="service" id="maker.file_manager" />
<argument type="service" id="maker.generator" />
<argument type="service" id="maker.template_linter" />
</service>
<service id="maker.generator" class="Symfony\Bundle\MakerBundle\Generator">
<argument type="service" id="maker.file_manager" />
<argument /> <!-- root namespace -->
<argument>null</argument> <!-- PhpCompatUtil -->
<argument type="service" id="maker.template_component_generator" />
</service>
<service id="maker.entity_class_generator" class="Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator">
<argument type="service" id="maker.generator" />
<argument type="service" id="maker.doctrine_helper" />
</service>
<service id="maker.user_class_builder" class="Symfony\Bundle\MakerBundle\Security\UserClassBuilder" />
<service id="maker.security_config_updater" class="Symfony\Bundle\MakerBundle\Security\SecurityConfigUpdater" />
<service id="maker.renderer.form_type_renderer" class="Symfony\Bundle\MakerBundle\Renderer\FormTypeRenderer">
<argument type="service" id="maker.generator" />
</service>
<service id="maker.security_controller_builder" class="Symfony\Bundle\MakerBundle\Security\SecurityControllerBuilder">
</service>
<service id="maker.php_compat_util" class="Symfony\Bundle\MakerBundle\Util\PhpCompatUtil">
<argument type="service" id="maker.file_manager" />
</service>
<service id="maker.template_component_generator" class="Symfony\Bundle\MakerBundle\Util\TemplateComponentGenerator">
<argument /> <!-- generate_final_classes -->
<argument /> <!-- generate_final_entities -->
<argument /> <!-- root_namespace -->
</service>
</services>
</container>

View File

@@ -0,0 +1,166 @@
The Symfony MakerBundle
=======================
Symfony Maker helps you create empty commands, controllers, form classes,
tests and more so you can forget about writing boilerplate code. This bundle
assumes you're using a standard Symfony directory structure, but many
commands can generate code into any application.
Installation
------------
Run this command to install and enable this bundle in your application:
.. code-block:: terminal
$ composer require --dev symfony/maker-bundle
Usage
-----
This bundle provides several commands under the ``make:`` namespace. List them
all executing this command:
.. code-block:: terminal
$ php bin/console list make
make:command Creates a new console command class
make:controller Creates a new controller class
make:entity Creates a new Doctrine entity class
[...]
make:validator Creates a new validator and constraint class
make:voter Creates a new security voter class
The names of the commands are self-explanatory, but some of them include
optional arguments and options. Check them out with the ``--help`` option:
.. code-block:: terminal
$ php bin/console make:controller --help
.. caution::
``make:entity`` requires ``doctrine/orm`` to be installed and configured. This maker support only ORM, not ODM.
Linting Generated Code
______________________
MakerBundle uses php-cs-fixer to enforce coding standards when generating ``.php``
files. When running a ``make`` command, MakerBundle will use a ``php-cs-fixer``
version and configuration that is packaged with this bundle.
You can explicitly set a custom path to a php-cs-fixer binary and/or configuration
file by their respective environment variables:
- ``MAKER_PHP_CS_FIXER_BINARY_PATH`` e.g. tools/vendor/bin/php-cs-fixer
- ``MAKER_PHP_CS_FIXER_CONFIG_PATH`` e.g. .php-cs-fixer.config.php
.. tip::
Is PHP-CS-Fixer installed globally? To avoid needing to set these in every
project, you can instead set these on your operating system.
Configuration
-------------
This bundle doesn't require any configuration. But, you *can* override the default
configuration:
.. code-block:: yaml
# config/packages/maker.yaml
when@dev:
maker:
root_namespace: 'App'
generate_final_classes: true
generate_final_entities: false
root_namespace
~~~~~~~~~~~~~~
**type**: ``string`` **default**: ``App``
The root namespace used when generating all of your classes
(e.g. ``App\Entity\Article``, ``App\Command\MyCommand``, etc). Changing
this to ``Acme`` would cause MakerBundle to create new classes like
(e.g. ``Acme\Entity\Article``, ``Acme\Command\MyCommand``, etc).
generate_final_classes
~~~~~~~~~~~~~~~~~~~~~~
**type**: ``boolean`` **default**: ``true``
By default, MakerBundle will generate all of your classes with the
``final`` PHP keyword except for doctrine entities. Set this to ``false``
to override this behavior for all maker commands.
See https://www.php.net/manual/en/language.oop5.final.php
.. code-block:: php
final class MyVoter
{
...
}
.. versionadded:: 1.61
``generate_final_classes`` was introduced in MakerBundle 1.61
generate_final_entities
~~~~~~~~~~~~~~~~~~~~~~~
**type**: ``boolean`` **default**: ``false``
By default, MakerBundle will not generate any of your doctrine entity
classes with the ``final`` PHP keyword. Set this to ``true``
to override this behavior for all maker commands that create
entities.
See https://www.php.net/manual/en/language.oop5.final.php
.. code-block:: php
#[ORM\Entity(repositoryClass: TaskRepository::class)]
final class Task extends AbstractEntity
{
...
}
.. versionadded:: 1.61
``generate_final_entities`` was introduced in MakerBundle 1.61.
Creating your Own Makers
------------------------
In case your applications need to generate custom boilerplate code, you can
create your own ``make:...`` command reusing the tools provided by this bundle.
To do that, you should create a class that extends
`AbstractMaker`_ in your ``src/Maker/``
directory. And this is really it!
For examples of how to complete your new maker command, see the `core maker commands`_.
Make sure your class is registered as a service and tagged with ``maker.command``.
If you're using the standard Symfony ``services.yaml`` configuration, this
will be done automatically.
Overriding the Generated Code
-----------------------------
Generated code can never be perfect for everyone. The MakerBundle tries to balance
adding "extension points" with keeping the library simple so that existing commands
can be improved and new commands can be added.
For that reason, in general, the generated code cannot be modified. In many cases,
adding your *own* maker command is so easy, that we recommend that. However, if there
is some extension point that you'd like, please open an issue so we can discuss!
.. _`AbstractMaker`: https://github.com/symfony/maker-bundle/blob/main/src/Maker/AbstractMaker.php
.. _`core maker commands`: https://github.com/symfony/maker-bundle/tree/main/src/Maker

View File

@@ -0,0 +1,26 @@
parameters:
level: 6
bootstrapFiles:
- vendor/autoload.php
- tools/phpstan/includes/vendor/autoload.php
paths:
- src/Maker
- tests/Command
- tests/Docker
- tests/Maker
excludePaths:
- tests/Doctrine/fixtures
- tests/fixtures
- tests/Security/fixtures
- tests/Security/yaml_fixtures
- tests/tmp
- tests/Util/fixtures
- tests/Util/yaml_fixtures
ignoreErrors:
-
message: '#Property .+phpCompatUtil is never read, only written\.#'
path: src/Maker/*
-
message: '#Class Symfony\\Bundle\\MakerBundle\\Maker\\LegacyApiTestCase not found#'
path: src/Maker/*

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
use Symfony\Component\Console\Application;
/**
* Implement this interface if your Maker needs access to the Application.
*
* @author Ryan Weaver <ryan@knpuniversity.com>
*/
interface ApplicationAwareMakerInterface
{
public function setApplication(Application $application);
}

View File

@@ -0,0 +1,139 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Command;
use Symfony\Bundle\MakerBundle\ApplicationAwareMakerInterface;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\MakerInterface;
use Symfony\Bundle\MakerBundle\Util\TemplateLinter;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Used as the Command class for the makers.
*
* @internal
*/
final class MakerCommand extends Command
{
private InputConfiguration $inputConfig;
private ConsoleStyle $io;
private bool $checkDependencies = true;
public function __construct(
private MakerInterface $maker,
private FileManager $fileManager,
private Generator $generator,
private TemplateLinter $linter,
) {
$this->inputConfig = new InputConfiguration();
parent::__construct();
}
protected function configure(): void
{
$this->maker->configureCommand($this, $this->inputConfig);
}
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new ConsoleStyle($input, $output);
if (!$input->isInteractive()) {
$this->io->warning(\sprintf('"%s" is not meant to be run in non-interactive mode.', $this->getName()));
}
$this->fileManager->setIO($this->io);
if ($this->checkDependencies) {
$dependencies = new DependencyBuilder();
$this->maker->configureDependencies($dependencies, $input);
if ($missingPackagesMessage = $dependencies->getMissingPackagesMessage($this->getName())) {
throw new RuntimeCommandException($missingPackagesMessage);
}
}
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
if (!$this->fileManager->isNamespaceConfiguredToAutoload($this->generator->getRootNamespace())) {
$this->io->note([
\sprintf('It looks like your app may be using a namespace other than "%s".', $this->generator->getRootNamespace()),
'To configure this and make your life easier, see: https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html#configuration',
]);
}
foreach ($this->getDefinition()->getArguments() as $argument) {
if ($input->getArgument($argument->getName())) {
continue;
}
if (\in_array($argument->getName(), $this->inputConfig->getNonInteractiveArguments(), true)) {
continue;
}
$value = $this->io->ask($argument->getDescription(), $argument->getDefault(), Validator::notBlank(...));
$input->setArgument($argument->getName(), $value);
}
$this->maker->interact($input, $this->io, $this);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($output->isVerbose()) {
$this->linter->writeLinterMessage($output);
}
$this->maker->generate($input, $this->io, $this->generator);
// sanity check for custom makers
if ($this->generator->hasPendingOperations()) {
throw new \LogicException('Make sure to call the writeChanges() method on the generator.');
}
$this->linter->lintFiles($this->generator->getGeneratedFiles());
return 0;
}
public function setApplication(?Application $application = null): void
{
parent::setApplication($application);
if ($this->maker instanceof ApplicationAwareMakerInterface) {
if (null === $application) {
throw new \RuntimeException('Application cannot be null.');
}
$this->maker->setApplication($application);
}
}
/**
* @internal Used for testing commands
*/
public function setCheckDependencies(bool $checkDeps): void
{
$this->checkDependencies = $checkDeps;
}
}

View File

@@ -0,0 +1,136 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Console;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Output\OutputInterface;
class MigrationDiffFilteredOutput implements OutputInterface
{
private string $buffer = '';
private bool $previousLineWasRemoved = false;
public function __construct(
private OutputInterface $output,
) {
}
public function write($messages, bool $newline = false, $options = 0): void
{
$messages = $this->filterMessages($messages, $newline);
$this->output->write($messages, $newline, $options);
}
public function writeln($messages, int $options = 0): void
{
$messages = $this->filterMessages($messages, true);
$this->output->writeln($messages, $options);
}
public function setVerbosity(int $level): void
{
$this->output->setVerbosity($level);
}
public function setDecorated(bool $decorated): void
{
$this->output->setDecorated($decorated);
}
public function getVerbosity(): int
{
return $this->output->getVerbosity();
}
public function isQuiet(): bool
{
return $this->output->isQuiet();
}
public function isVerbose(): bool
{
return $this->output->isVerbose();
}
public function isVeryVerbose(): bool
{
return $this->output->isVeryVerbose();
}
public function isDebug(): bool
{
return $this->output->isDebug();
}
public function isDecorated(): bool
{
return $this->output->isDecorated();
}
public function setFormatter(OutputFormatterInterface $formatter): void
{
$this->output->setFormatter($formatter);
}
public function getFormatter(): OutputFormatterInterface
{
return $this->output->getFormatter();
}
public function fetch(): string
{
return $this->buffer;
}
private function filterMessages($messages, bool $newLine)
{
if (!is_iterable($messages)) {
$messages = [$messages];
}
$hiddenPhrases = [
'Generated new migration class',
'To run just this migration',
'To revert the migration you',
];
foreach ($messages as $key => $message) {
$this->buffer .= $message;
if ($newLine) {
$this->buffer .= \PHP_EOL;
}
if ($this->previousLineWasRemoved && !trim($message)) {
// hide a blank line after a filtered line
unset($messages[$key]);
$this->previousLineWasRemoved = false;
continue;
}
$this->previousLineWasRemoved = false;
foreach ($hiddenPhrases as $hiddenPhrase) {
if (str_contains($message, $hiddenPhrase)) {
$this->previousLineWasRemoved = true;
unset($messages[$key]);
break;
}
}
}
return array_values($messages);
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class ConsoleStyle extends SymfonyStyle
{
public function __construct(
InputInterface $input,
private OutputInterface $output,
) {
parent::__construct($input, $output);
}
public function success($message): void
{
$this->writeln('<fg=green;options=bold,underscore>OK</> '.$message);
}
public function comment($message): void
{
$this->text($message);
}
public function getOutput(): OutputInterface
{
return $this->output;
}
}

View File

@@ -0,0 +1,144 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
final class DependencyBuilder
{
private array $dependencies = [];
private array $devDependencies = [];
/**
* Add a dependency that will be reported if the given class is missing.
*
* If the dependency is *optional*, then it will only be reported to
* the user if other required dependencies are missing. An example
* is the "validator" when trying to work with forms.
*/
public function addClassDependency(string $class, string $package, bool $required = true, bool $devDependency = false): void
{
if ($devDependency) {
$this->devDependencies[] = [
'class' => $class,
'name' => $package,
'required' => $required,
];
} else {
$this->dependencies[] = [
'class' => $class,
'name' => $package,
'required' => $required,
];
}
}
public function requirePHP71(): void
{
trigger_deprecation('symfony/maker-bundle', 'v1.44.0', 'requirePHP71() is deprecated and will be removed in a future version.');
}
/**
* @internal
*/
public function getMissingDependencies(): array
{
return $this->calculateMissingDependencies($this->dependencies);
}
/**
* @internal
*/
public function getMissingDevDependencies(): array
{
return $this->calculateMissingDependencies($this->devDependencies);
}
/**
* @internal
*/
public function getAllRequiredDependencies(): array
{
return $this->getRequiredDependencyNames($this->dependencies);
}
/**
* @internal
*/
public function getAllRequiredDevDependencies(): array
{
return $this->getRequiredDependencyNames($this->devDependencies);
}
/**
* @internal
*/
public function getMissingPackagesMessage(string $commandName, $message = null): string
{
$packages = $this->getMissingDependencies();
$packagesDev = $this->getMissingDevDependencies();
if (empty($packages) && empty($packagesDev)) {
return '';
}
$packagesCount = \count($packages) + \count($packagesDev);
$message = \sprintf(
"Missing package%s: %s, run:\n",
$packagesCount > 1 ? 's' : '',
$message ?: \sprintf('to use the %s command', $commandName)
);
if (!empty($packages)) {
$message .= \sprintf("\ncomposer require %s", implode(' ', $packages));
}
if (!empty($packagesDev)) {
$message .= \sprintf("\ncomposer require %s --dev", implode(' ', $packagesDev));
}
return $message;
}
private function getRequiredDependencyNames(array $dependencies): array
{
$packages = [];
foreach ($dependencies as $package) {
if (!$package['required']) {
continue;
}
$packages[] = $package['name'];
}
return array_unique($packages);
}
private function calculateMissingDependencies(array $dependencies): array
{
$missingPackages = [];
$missingOptionalPackages = [];
foreach ($dependencies as $package) {
if (class_exists($package['class']) || interface_exists($package['class']) || trait_exists($package['class'])) {
continue;
}
if (true === $package['required']) {
$missingPackages[] = $package['name'];
} else {
$missingOptionalPackages[] = $package['name'];
}
}
if (empty($missingPackages)) {
return [];
}
return array_unique([...$missingPackages, ...$missingOptionalPackages]);
}
}

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass;
use Symfony\Bundle\MakerBundle\Command\MakerCommand;
use Symfony\Bundle\MakerBundle\MakerInterface;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
class MakeCommandRegistrationPass implements CompilerPassInterface
{
public const MAKER_TAG = 'maker.command';
public function process(ContainerBuilder $container): void
{
foreach ($container->findTaggedServiceIds(self::MAKER_TAG) as $id => $tags) {
$def = $container->getDefinition($id);
if ($def->isDeprecated()) {
continue;
}
$class = $container->getParameterBag()->resolveValue($def->getClass());
if (!is_subclass_of($class, MakerInterface::class)) {
throw new InvalidArgumentException(\sprintf('Service "%s" must implement interface "%s".', $id, MakerInterface::class));
}
$commandDefinition = new ChildDefinition('maker.auto_command.abstract');
$commandDefinition->setClass(MakerCommand::class);
$commandDefinition->replaceArgument(0, new Reference($id));
$tagAttributes = ['command' => $class::getCommandName()];
if (!method_exists($class, 'getCommandDescription')) {
// no-op
} elseif (class_exists(LazyCommand::class)) {
$tagAttributes['description'] = $class::getCommandDescription();
} else {
$commandDefinition->addMethodCall('setDescription', [$class::getCommandDescription()]);
}
$commandDefinition->addTag('console.command', $tagAttributes);
/*
* @deprecated remove this block when removing make:unit-test and make:functional-test
*/
if (method_exists($class, 'getCommandAliases')) {
foreach ($class::getCommandAliases() as $alias) {
$commandDefinition->addTag('console.command', ['command' => $alias, 'description' => 'Deprecated alias of "make:test"']);
}
}
/*
* @deprecated remove this block when removing make:subscriber
*/
if (method_exists($class, 'getCommandAlias')) {
$alias = $class::getCommandAlias();
$commandDefinition->addTag('console.command', ['command' => $alias, 'description' => 'Deprecated alias of "make:listener"']);
}
$container->setDefinition(\sprintf('maker.auto_command.%s', Str::asTwigVariable($class::getCommandName())), $commandDefinition);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Removes injected parameter arguments if they don't exist in this app.
*
* @author Ryan Weaver <ryan@symfonycasts.com>
*/
class RemoveMissingParametersPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasParameter('twig.default_path')) {
$container->getDefinition('maker.file_manager')
->replaceArgument(4, null);
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
class SetDoctrineAnnotatedPrefixesPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$annotatedPrefixes = null;
foreach ($container->findTaggedServiceIds('doctrine.orm.configuration') as $id => $tags) {
$metadataDriverImpl = null;
foreach ($container->getDefinition($id)->getMethodCalls() as [$method, $arguments]) {
if ('setMetadataDriverImpl' === $method) {
$metadataDriverImpl = $container->getDefinition($arguments[0]);
break;
}
}
if (null === $metadataDriverImpl || !preg_match('/^doctrine\.orm\.(.+)_configuration$/D', $id, $m)) {
continue;
}
$managerName = $m[1];
$methodCalls = $metadataDriverImpl->getMethodCalls();
foreach ($methodCalls as $i => [$method, $arguments]) {
if ('addDriver' !== $method) {
continue;
}
if ($arguments[0] instanceof Definition) {
$class = $arguments[0]->getClass();
$id = \sprintf('.%d_doctrine_metadata_driver~%s', $i, ContainerBuilder::hash($arguments));
$container->setDefinition($id, $arguments[0]);
$arguments[0] = new Reference($id);
$methodCalls[$i] = [$method, $arguments];
}
$annotatedPrefixes[$managerName][] = [
$arguments[1],
new Reference($arguments[0]),
];
}
$metadataDriverImpl->setMethodCalls($methodCalls);
}
if (null !== $annotatedPrefixes) {
$container->getDefinition('maker.doctrine_helper')->setArgument(2, $annotatedPrefixes);
}
}
}

View File

@@ -0,0 +1,106 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Docker;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
/**
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @internal
*/
final class DockerDatabaseServices
{
/**
* @throws RuntimeCommandException
*/
public static function getDatabaseSkeleton(string $name, string $version): array
{
switch ($name) {
case 'mariadb':
return [
'image' => \sprintf('mariadb:%s', $version),
'environment' => [
'MYSQL_ROOT_PASSWORD' => 'password',
'MYSQL_DATABASE' => 'main',
],
];
case 'mysql':
return [
'image' => \sprintf('mysql:%s', $version),
'environment' => [
'MYSQL_ROOT_PASSWORD' => 'password',
'MYSQL_DATABASE' => 'main',
],
];
case 'postgres':
return [
'image' => \sprintf('postgres:%s', $version),
'environment' => [
'POSTGRES_PASSWORD' => 'main',
'POSTGRES_USER' => 'main',
'POSTGRES_DB' => 'main',
],
];
}
self::throwInvalidDatabase($name);
}
/**
* @throws RuntimeCommandException
*/
public static function getDefaultPorts(string $name): array
{
switch ($name) {
case 'mariadb':
case 'mysql':
return ['3306'];
case 'postgres':
return ['5432'];
}
self::throwInvalidDatabase($name);
}
public static function getSuggestedServiceVersion(string $name): string
{
if ('postgres' === $name) {
return 'alpine';
}
return 'latest';
}
public static function getMissingExtensionName(string $name): ?string
{
$driver = match ($name) {
'mariadb', 'mysql' => 'mysql',
'postgres' => 'pgsql',
default => self::throwInvalidDatabase($name),
};
if (!\in_array($driver, \PDO::getAvailableDrivers(), true)) {
return $driver;
}
return null;
}
/**
* @throws RuntimeCommandException
*/
private static function throwInvalidDatabase(string $name): never
{
throw new RuntimeCommandException(\sprintf('%s is not a valid / supported docker database type.', $name));
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Symfony\Bundle\MakerBundle\Str;
/**
* @internal
*/
abstract class BaseCollectionRelation extends BaseRelation
{
abstract public function getTargetSetterMethodName(): string;
public function getAdderMethodName(): string
{
return 'add'.Str::asCamelCase(Str::pluralCamelCaseToSingular($this->getPropertyName()));
}
public function getRemoverMethodName(): string
{
return 'remove'.Str::asCamelCase(Str::pluralCamelCaseToSingular($this->getPropertyName()));
}
}

View File

@@ -0,0 +1,88 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
/**
* @internal
*/
abstract class BaseRelation
{
public function __construct(
private string $propertyName,
private string $targetClassName,
private ?string $targetPropertyName = null,
private bool $isSelfReferencing = false,
private bool $mapInverseRelation = true,
private bool $avoidSetter = false,
private bool $isCustomReturnTypeNullable = false,
private ?string $customReturnType = null,
private bool $isOwning = false,
private bool $orphanRemoval = false,
private bool $isNullable = false,
) {
}
public function getPropertyName(): string
{
return $this->propertyName;
}
public function getTargetClassName(): string
{
return $this->targetClassName;
}
public function getTargetPropertyName(): ?string
{
return $this->targetPropertyName;
}
public function isSelfReferencing(): bool
{
return $this->isSelfReferencing;
}
public function getMapInverseRelation(): bool
{
return $this->mapInverseRelation;
}
public function shouldAvoidSetter(): bool
{
return $this->avoidSetter;
}
public function getCustomReturnType(): ?string
{
return $this->customReturnType;
}
public function isCustomReturnTypeNullable(): bool
{
return $this->isCustomReturnTypeNullable;
}
public function isOwning(): bool
{
return $this->isOwning;
}
public function getOrphanRemoval(): bool
{
return $this->orphanRemoval;
}
public function isNullable(): bool
{
return $this->isNullable;
}
}

View File

@@ -0,0 +1,368 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
use Doctrine\ORM\Mapping\MappingException as ORMMappingException;
use Doctrine\ORM\Mapping\NamingStrategy;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\AbstractClassMetadataFactory;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Doctrine\Persistence\Mapping\Driver\MappingDriver;
use Doctrine\Persistence\Mapping\Driver\MappingDriverChain;
use Doctrine\Persistence\Mapping\MappingException as PersistenceMappingException;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Ryan Weaver <ryan@knpuniversity.com>
* @author Sadicov Vladimir <sadikoff@gmail.com>
*
* @internal
*/
final class DoctrineHelper
{
public function __construct(
private string $entityNamespace,
private ?ManagerRegistry $registry = null,
private ?array $mappingDriversByPrefix = null,
) {
$this->entityNamespace = trim($entityNamespace, '\\');
}
public function getRegistry(): ManagerRegistry
{
// this should never happen: we will have checked for the
// DoctrineBundle dependency before calling this
if (null === $this->registry) {
throw new \Exception('Somehow the doctrine service is missing. Is DoctrineBundle installed?');
}
return $this->registry;
}
private function isDoctrineInstalled(): bool
{
return null !== $this->registry;
}
public function getEntityNamespace(): string
{
return $this->entityNamespace;
}
public function doesClassUseDriver(string $className, string $driverClass): bool
{
try {
/** @var EntityManagerInterface $em */
$em = $this->getRegistry()->getManagerForClass($className);
} catch (\ReflectionException) {
// this exception will be thrown by the registry if the class isn't created yet.
// an example case is the "make:entity" command, which needs to know which driver is used for the class to determine
// if the class should be generated with attributes or annotations. If this exception is thrown, we will check based on the
// namespaces for the given $className and compare it with the doctrine configuration to get the correct MappingDriver.
// extract the new class's namespace from the full $className to check the namespace of the new class against the doctrine configuration.
$classNameComponents = explode('\\', $className);
if (1 < \count($classNameComponents)) {
array_pop($classNameComponents);
}
$classNamespace = implode('\\', $classNameComponents);
return $this->isInstanceOf($this->getMappingDriverForNamespace($classNamespace), $driverClass);
}
if (null === $em) {
throw new \InvalidArgumentException(\sprintf('Cannot find the entity manager for class "%s". Ensure entity uses attribute mapping.', $className));
}
if (null === $this->mappingDriversByPrefix) {
// doctrine-bundle <= 2.2
$metadataDriver = $em->getConfiguration()->getMetadataDriverImpl();
if (!$this->isInstanceOf($metadataDriver, MappingDriverChain::class)) {
return $this->isInstanceOf($metadataDriver, $driverClass);
}
foreach ($metadataDriver->getDrivers() as $namespace => $driver) {
if (str_starts_with($className, $namespace)) {
return $this->isInstanceOf($driver, $driverClass);
}
}
return $this->isInstanceOf($metadataDriver->getDefaultDriver(), $driverClass);
}
$managerName = array_search($em, $this->getRegistry()->getManagers(), true);
foreach ($this->mappingDriversByPrefix[$managerName] as [$prefix, $prefixDriver]) {
if (str_starts_with($className, $prefix)) {
return $this->isInstanceOf($prefixDriver, $driverClass);
}
}
return false;
}
public function doesClassUsesAttributes(string $className): bool
{
return $this->doesClassUseDriver($className, AttributeDriver::class);
}
public function isDoctrineSupportingAttributes(): bool
{
return $this->isDoctrineInstalled();
}
public function getEntitiesForAutocomplete(): array
{
$entities = [];
if ($this->isDoctrineInstalled()) {
$allMetadata = $this->getMetadata();
foreach (array_keys($allMetadata) as $classname) {
$entityClassDetails = new ClassNameDetails($classname, $this->entityNamespace);
$entities[] = $entityClassDetails->getRelativeName();
}
}
sort($entities);
return $entities;
}
public function getMetadata(?string $classOrNamespace = null, bool $disconnected = false): array|ClassMetadata
{
// Invalidating the cached AttributeDriver::$classNames to find new Entity classes
foreach ($this->mappingDriversByPrefix ?? [] as $managerName => $prefixes) {
foreach ($prefixes as [$prefix, $attributeDriver]) {
if ($attributeDriver instanceof AttributeDriver) {
$classNames = (new \ReflectionClass(AttributeDriver::class))->getProperty('classNames');
$classNames->setAccessible(true);
$classNames->setValue($attributeDriver, null);
}
}
}
$metadata = [];
/** @var EntityManagerInterface $em */
foreach ($this->getRegistry()->getManagers() as $em) {
$cmf = $em->getMetadataFactory();
if ($disconnected) {
try {
$loaded = $cmf->getAllMetadata();
} catch (ORMMappingException|PersistenceMappingException) {
$loaded = $this->isInstanceOf($cmf, AbstractClassMetadataFactory::class) ? $cmf->getLoadedMetadata() : [];
}
// Set the reflection service that was used in the now removed DisconnectedClassMetadataFactory::class
$cmf->setReflectionService(new StaticReflectionService());
foreach ($loaded as $m) {
$cmf->setMetadataFor($m->getName(), $m);
}
if (null === $this->mappingDriversByPrefix) {
// Invalidating the cached AttributeDriver::$classNames to find new Entity classes
$metadataDriver = $em->getConfiguration()->getMetadataDriverImpl();
if ($this->isInstanceOf($metadataDriver, MappingDriverChain::class)) {
foreach ($metadataDriver->getDrivers() as $driver) {
if ($this->isInstanceOf($driver, AttributeDriver::class)) {
$classNames->setValue($driver, null);
}
}
}
}
}
foreach ($cmf->getAllMetadata() as $m) {
if (null === $classOrNamespace) {
$metadata[$m->getName()] = $m;
} else {
if ($m->getName() === $classOrNamespace) {
return $m;
}
if (str_starts_with($m->getName(), $classOrNamespace)) {
$metadata[$m->getName()] = $m;
}
}
}
}
return $metadata;
}
public function createDoctrineDetails(string $entityClassName): ?EntityDetails
{
$metadata = $this->getMetadata($entityClassName);
if ($this->isInstanceOf($metadata, ClassMetadata::class)) {
return new EntityDetails($metadata);
}
return null;
}
public function isClassAMappedEntity(string $className): bool
{
if (!$this->isDoctrineInstalled()) {
return false;
}
return (bool) $this->getMetadata($className);
}
/**
* Determines if the property-type will make the column type redundant.
*
* See ClassMetadataInfo::validateAndCompleteTypedFieldMapping()
*/
public static function canColumnTypeBeInferredByPropertyType(string $columnType, string $propertyType): bool
{
// todo: guessing on enum's could be added
return match ($propertyType) {
'\\'.\DateInterval::class => Types::DATEINTERVAL === $columnType,
'\\'.\DateTime::class => Types::DATETIME_MUTABLE === $columnType,
'\\'.\DateTimeImmutable::class => Types::DATETIME_IMMUTABLE === $columnType,
'array' => Types::JSON === $columnType,
'bool' => Types::BOOLEAN === $columnType,
'float' => Types::FLOAT === $columnType,
'int' => Types::INTEGER === $columnType,
'string' => Types::STRING === $columnType,
default => false,
};
}
public static function getPropertyTypeForColumn(string $columnType): ?string
{
$propertyType = match ($columnType) {
Types::STRING, Types::TEXT, Types::GUID, Types::BIGINT, Types::DECIMAL => 'string',
'array', Types::SIMPLE_ARRAY, Types::JSON => 'array',
Types::BOOLEAN => 'bool',
Types::INTEGER, Types::SMALLINT => 'int',
Types::FLOAT => 'float',
Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_MUTABLE, Types::TIME_MUTABLE => '\\'.\DateTime::class,
Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE => '\\'.\DateTimeImmutable::class,
Types::DATEINTERVAL => '\\'.\DateInterval::class,
'object' => 'object',
'uuid' => '\\'.Uuid::class,
'ulid' => '\\'.Ulid::class,
default => null,
};
if (null !== $propertyType || !($registry = Type::getTypeRegistry())->has($columnType)) {
return $propertyType;
}
$reflection = new \ReflectionClass(($registry->get($columnType))::class);
$returnType = $reflection->getMethod('convertToPHPValue')->getReturnType();
/*
* we do not support union and intersection types
*/
if (!$returnType instanceof \ReflectionNamedType) {
return null;
}
return $returnType->isBuiltin() ? $returnType->getName() : '\\'.$returnType->getName();
}
/**
* Given the string "column type", this returns the "Types::STRING" constant.
*
* This is, effectively, a reverse lookup: given the final string, give us
* the constant to be used in the generated code.
*/
public static function getTypeConstant(string $columnType): ?string
{
$reflection = new \ReflectionClass(Types::class);
$constants = array_flip($reflection->getConstants());
if (!isset($constants[$columnType])) {
return null;
}
return \sprintf('Types::%s', $constants[$columnType]);
}
private function isInstanceOf($object, string $class): bool
{
if (!\is_object($object)) {
return false;
}
return $object instanceof $class;
}
public function getPotentialTableName(string $className): string
{
$entityManager = $this->getRegistry()->getManager();
if (!$entityManager instanceof EntityManagerInterface) {
throw new \RuntimeException('ObjectManager is not an EntityManagerInterface.');
}
/** @var NamingStrategy $namingStrategy */
$namingStrategy = $entityManager->getConfiguration()->getNamingStrategy();
return $namingStrategy->classToTableName($className);
}
public function isKeyword(string $name): bool
{
/** @var Connection $connection */
$connection = $this->getRegistry()->getConnection();
return $connection->getDatabasePlatform()->getReservedKeywordsList()->isKeyword($name);
}
/**
* this method tries to find the correct MappingDriver for the given namespace/class
* To determine which MappingDriver belongs to the class we check the prefixes configured in Doctrine and use the
* prefix that has the closest match to the given $namespace.
*
* this helper function is needed to create entities with the configuration of doctrine if they are not yet been registered
* in the ManagerRegistry
*/
private function getMappingDriverForNamespace(string $namespace): ?MappingDriver
{
$lowestCharacterDiff = null;
$foundDriver = null;
foreach ($this->mappingDriversByPrefix ?? [] as $mappings) {
foreach ($mappings as [$prefix, $driver]) {
$diff = substr_compare($namespace, $prefix, 0);
if ($diff >= 0 && (null === $lowestCharacterDiff || $diff < $lowestCharacterDiff)) {
$lowestCharacterDiff = $diff;
$foundDriver = $driver;
}
}
}
return $foundDriver;
}
}

View File

@@ -0,0 +1,146 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use ApiPlatform\Metadata\ApiResource;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\Maker\Common\EntityIdTypeEnum;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
use Symfony\UX\Turbo\Attribute\Broadcast;
/**
* @internal
*/
final class EntityClassGenerator
{
public function __construct(
private Generator $generator,
private DoctrineHelper $doctrineHelper,
) {
}
public function generateEntityClass(ClassNameDetails $entityClassDetails, bool $apiResource, bool $withPasswordUpgrade = false, bool $generateRepositoryClass = true, bool $broadcast = false, EntityIdTypeEnum $useUuidIdentifier = EntityIdTypeEnum::INT): string
{
$repoClassDetails = $this->generator->createClassNameDetails(
$entityClassDetails->getRelativeName(),
'Repository\\',
'Repository'
);
$tableName = $this->doctrineHelper->getPotentialTableName($entityClassDetails->getFullName());
$useStatements = new UseStatementGenerator([
$repoClassDetails->getFullName(),
['Doctrine\\ORM\\Mapping' => 'ORM'],
]);
if ($broadcast) {
$useStatements->addUseStatement(Broadcast::class);
}
if ($apiResource) {
$useStatements->addUseStatement(ApiResource::class);
}
if (EntityIdTypeEnum::UUID === $useUuidIdentifier) {
$useStatements->addUseStatement([
Uuid::class,
UuidType::class,
]);
}
if (EntityIdTypeEnum::ULID === $useUuidIdentifier) {
$useStatements->addUseStatement([
Ulid::class,
UlidType::class,
]);
}
$entityPath = $this->generator->generateClass(
$entityClassDetails->getFullName(),
'doctrine/Entity.tpl.php',
[
'use_statements' => $useStatements,
'repository_class_name' => $repoClassDetails->getShortName(),
'api_resource' => $apiResource,
'broadcast' => $broadcast,
'should_escape_table_name' => $this->doctrineHelper->isKeyword($tableName),
'table_name' => $tableName,
'id_type' => $useUuidIdentifier,
]
);
if ($generateRepositoryClass) {
$this->generateRepositoryClass(
$repoClassDetails->getFullName(),
$entityClassDetails->getFullName(),
$withPasswordUpgrade,
true
);
}
return $entityPath;
}
public function generateRepositoryClass(string $repositoryClass, string $entityClass, bool $withPasswordUpgrade, bool $includeExampleComments = true): void
{
$shortEntityClass = Str::getShortClassName($entityClass);
$entityAlias = strtolower($shortEntityClass[0]);
$passwordUserInterfaceName = UserInterface::class;
if (interface_exists(PasswordAuthenticatedUserInterface::class)) {
$passwordUserInterfaceName = PasswordAuthenticatedUserInterface::class;
}
$interfaceClassNameDetails = new ClassNameDetails($passwordUserInterfaceName, 'Symfony\Component\Security\Core\User');
$useStatements = new UseStatementGenerator([
$entityClass,
ManagerRegistry::class,
ServiceEntityRepository::class,
]);
if ($withPasswordUpgrade) {
$useStatements->addUseStatement([
$interfaceClassNameDetails->getFullName(),
PasswordUpgraderInterface::class,
UnsupportedUserException::class,
]);
}
$this->generator->generateClass(
$repositoryClass,
'doctrine/Repository.tpl.php',
[
'use_statements' => $useStatements,
'entity_class_name' => $shortEntityClass,
'entity_alias' => $entityAlias,
'with_password_upgrade' => $withPasswordUpgrade,
'password_upgrade_user_interface' => $interfaceClassNameDetails,
'include_example_comments' => $includeExampleComments,
]
);
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\Persistence\Mapping\ClassMetadata;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
/**
* @author Sadicov Vladimir <sadikoff@gmail.com>
*
* @internal
*/
final class EntityDetails
{
public function __construct(
private ClassMetadata $metadata,
) {
}
public function getRepositoryClass(): ?string
{
return $this->metadata->customRepositoryClassName;
}
public function getIdentifier()
{
return $this->metadata->identifier[0];
}
public function getDisplayFields(): array
{
return $this->metadata->fieldMappings;
}
public function getFormFields(): array
{
$fields = (array) $this->metadata->fieldNames;
// Remove the primary key field if it's not managed manually
if (!$this->metadata->isIdentifierNatural()) {
$fields = array_diff($fields, $this->metadata->identifier);
}
$fields = array_values($fields);
if (!empty($this->metadata->embeddedClasses)) {
foreach (array_keys($this->metadata->embeddedClasses) as $embeddedClassKey) {
$fields = array_filter($fields, static fn ($v) => !str_starts_with($v, $embeddedClassKey.'.'));
}
}
$fieldsWithTypes = [];
foreach ($fields as $field) {
$fieldsWithTypes[$field] = null;
}
foreach ($this->metadata->fieldMappings as $fieldName => $fieldMapping) {
$propType = DoctrineHelper::getPropertyTypeForColumn($fieldMapping['type']);
if (($propType === '\\'.\DateTimeImmutable::class)
|| ($propType === '\\'.\DateTimeInterface::class)) {
$fieldsWithTypes[$fieldName] = [
'type' => null,
'options_code' => "'widget' => 'single_text'",
];
}
}
foreach ($this->metadata->associationMappings as $fieldName => $relation) {
if (\Doctrine\ORM\Mapping\ClassMetadata::ONE_TO_MANY === $relation['type']) {
continue;
}
$fieldsWithTypes[$fieldName] = [
'type' => EntityType::class,
'options_code' => \sprintf('\'class\' => %s::class,', $relation['targetEntity'])."\n 'choice_label' => 'id',",
'extra_use_classes' => [$relation['targetEntity']],
];
if (\Doctrine\ORM\Mapping\ClassMetadata::MANY_TO_MANY === $relation['type']) {
$fieldsWithTypes[$fieldName]['options_code'] .= "\n 'multiple' => true,";
}
}
return $fieldsWithTypes;
}
}

View File

@@ -0,0 +1,216 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\EmbeddedClassMapping;
use Doctrine\ORM\Mapping\MappingException;
use Doctrine\Persistence\Mapping\MappingException as PersistenceMappingException;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassProperty;
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
/**
* @internal
*/
final class EntityRegenerator
{
public function __construct(
private DoctrineHelper $doctrineHelper,
private FileManager $fileManager,
private Generator $generator,
private EntityClassGenerator $entityClassGenerator,
private bool $overwrite,
) {
}
public function regenerateEntities(string $classOrNamespace): void
{
try {
$metadata = $this->doctrineHelper->getMetadata($classOrNamespace);
} catch (MappingException|PersistenceMappingException) {
$metadata = $this->doctrineHelper->getMetadata($classOrNamespace, true);
}
if ($metadata instanceof ClassMetadata) {
$metadata = [$metadata];
} elseif (class_exists($classOrNamespace)) {
throw new RuntimeCommandException(\sprintf('Could not find Doctrine metadata for "%s". Is it mapped as an entity?', $classOrNamespace));
} elseif (empty($metadata)) {
throw new RuntimeCommandException(\sprintf('No entities were found in the "%s" namespace.', $classOrNamespace));
}
/** @var ClassSourceManipulator[] $operations */
$operations = [];
foreach ($metadata as $classMetadata) {
if (!class_exists($classMetadata->name)) {
// the class needs to be generated for the first time!
$classPath = $this->generateClass($classMetadata);
} else {
$classPath = $this->getPathOfClass($classMetadata->name);
}
$mappedFields = $this->getMappedFieldsInEntity($classMetadata);
if ($classMetadata->customRepositoryClassName) {
$this->generateRepository($classMetadata);
}
$manipulator = $this->createClassManipulator($classPath);
$operations[$classPath] = $manipulator;
$embeddedClasses = [];
foreach ($classMetadata->embeddedClasses as $fieldName => $mapping) {
if (str_contains($fieldName, '.')) {
continue;
}
/** @legacy - Remove conditional when ORM 2.x is no longer supported. */
$className = ($mapping instanceof EmbeddedClassMapping) ? $mapping->class : $mapping['class'];
$embeddedClasses[$fieldName] = $this->getPathOfClass($className);
$operations[$embeddedClasses[$fieldName]] = $this->createClassManipulator($embeddedClasses[$fieldName]);
if (!\in_array($fieldName, $mappedFields)) {
continue;
}
$manipulator->addEmbeddedEntity($fieldName, $className);
}
foreach ($classMetadata->fieldMappings as $fieldName => $mapping) {
// skip embedded fields
if (str_contains($fieldName, '.')) {
[$fieldName, $embeddedFiledName] = explode('.', $fieldName);
$property = ClassProperty::createFromObject($mapping);
$property->propertyName = $embeddedFiledName;
$operations[$embeddedClasses[$fieldName]]->addEntityField($property);
continue;
}
if (!\in_array($fieldName, $mappedFields)) {
continue;
}
$manipulator->addEntityField(ClassProperty::createFromObject($mapping));
}
foreach ($classMetadata->associationMappings as $fieldName => $mapping) {
if (!\in_array($fieldName, $mappedFields)) {
continue;
}
match ($mapping['type']) {
ClassMetadata::MANY_TO_ONE => $manipulator->addManyToOneRelation(RelationManyToOne::createFromObject($mapping)),
ClassMetadata::ONE_TO_MANY => $manipulator->addOneToManyRelation(RelationOneToMany::createFromObject($mapping)),
ClassMetadata::MANY_TO_MANY => $manipulator->addManyToManyRelation(RelationManyToMany::createFromObject($mapping)),
ClassMetadata::ONE_TO_ONE => $manipulator->addOneToOneRelation(RelationOneToOne::createFromObject($mapping)),
default => throw new \Exception('Unknown association type.'),
};
}
}
foreach ($operations as $filename => $manipulator) {
$this->fileManager->dumpFile(
$filename,
$manipulator->getSourceCode()
);
}
}
private function generateClass(ClassMetadata $metadata): string
{
$path = $this->generator->generateClass(
$metadata->name,
'Class.tpl.php',
[]
);
$this->generator->writeChanges();
return $path;
}
private function createClassManipulator(string $classPath): ClassSourceManipulator
{
return new ClassSourceManipulator(
sourceCode: $this->fileManager->getFileContents($classPath),
overwrite: $this->overwrite,
// if properties need to be generated then, by definition,
// some non-annotation config is being used (e.g. XML), and so, the
// properties should not have annotations added to them
useAttributesForDoctrineMapping: false
);
}
private function getPathOfClass(string $class): string
{
return (new \ReflectionClass($class))->getFileName();
}
private function generateRepository(ClassMetadata $metadata): void
{
if (!$metadata->customRepositoryClassName) {
return;
}
if (class_exists($metadata->customRepositoryClassName)) {
// repository already exists
return;
}
$this->entityClassGenerator->generateRepositoryClass(
$metadata->customRepositoryClassName,
$metadata->name,
false
);
$this->generator->writeChanges();
}
private function getMappedFieldsInEntity(ClassMetadata $classMetadata): array
{
/** @var \ReflectionClass $classReflection */
$classReflection = $classMetadata->reflClass;
$targetFields = [
...array_keys($classMetadata->fieldMappings),
...array_keys($classMetadata->associationMappings),
...array_keys($classMetadata->embeddedClasses),
];
if ($classReflection) {
// exclude traits
$traitProperties = [];
foreach ($classReflection->getTraits() as $trait) {
foreach ($trait->getProperties() as $property) {
$traitProperties[] = $property->getName();
}
}
$targetFields = array_diff($targetFields, $traitProperties);
// exclude inherited properties
$targetFields = array_filter($targetFields, static fn ($field) => $classReflection->hasProperty($field)
&& $classReflection->getProperty($field)->getDeclaringClass()->getName() === $classReflection->getName());
}
return $targetFields;
}
}

View File

@@ -0,0 +1,189 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
/**
* @internal
*/
final class EntityRelation
{
public const MANY_TO_ONE = 'ManyToOne';
public const ONE_TO_MANY = 'OneToMany';
public const MANY_TO_MANY = 'ManyToMany';
public const ONE_TO_ONE = 'OneToOne';
private $owningProperty;
private $inverseProperty;
private bool $isNullable = false;
private bool $isSelfReferencing = false;
private bool $orphanRemoval = false;
private bool $mapInverseRelation = true;
public function __construct(
private string $type,
private string $owningClass,
private string $inverseClass,
) {
if (!\in_array($type, self::getValidRelationTypes())) {
throw new \Exception(\sprintf('Invalid relation type "%s"', $type));
}
if (self::ONE_TO_MANY === $type) {
throw new \Exception('Use ManyToOne instead of OneToMany');
}
$this->isSelfReferencing = $owningClass === $inverseClass;
}
public function setOwningProperty(string $owningProperty): void
{
$this->owningProperty = $owningProperty;
}
public function setInverseProperty(string $inverseProperty): void
{
if (!$this->mapInverseRelation) {
throw new \Exception('Cannot call setInverseProperty() when the inverse relation will not be mapped.');
}
$this->inverseProperty = $inverseProperty;
}
public function setIsNullable(bool $isNullable): void
{
$this->isNullable = $isNullable;
}
public function setOrphanRemoval(bool $orphanRemoval): void
{
$this->orphanRemoval = $orphanRemoval;
}
public static function getValidRelationTypes(): array
{
return [
self::MANY_TO_ONE,
self::ONE_TO_MANY,
self::MANY_TO_MANY,
self::ONE_TO_ONE,
];
}
public function getOwningRelation(): RelationManyToMany|RelationOneToOne|RelationManyToOne
{
return match ($this->getType()) {
self::MANY_TO_ONE => (new RelationManyToOne(
propertyName: $this->owningProperty,
targetClassName: $this->inverseClass,
targetPropertyName: $this->inverseProperty,
isSelfReferencing: $this->isSelfReferencing,
mapInverseRelation: $this->mapInverseRelation,
isOwning: true,
isNullable: $this->isNullable,
)),
self::MANY_TO_MANY => (new RelationManyToMany(
propertyName: $this->owningProperty,
targetClassName: $this->inverseClass,
targetPropertyName: $this->inverseProperty,
isSelfReferencing: $this->isSelfReferencing,
mapInverseRelation: $this->mapInverseRelation,
isOwning: true,
)),
self::ONE_TO_ONE => (new RelationOneToOne(
propertyName: $this->owningProperty,
targetClassName: $this->inverseClass,
targetPropertyName: $this->inverseProperty,
isSelfReferencing: $this->isSelfReferencing,
mapInverseRelation: $this->mapInverseRelation,
isOwning: true,
isNullable: $this->isNullable,
)),
default => throw new \InvalidArgumentException('Invalid type'),
};
}
public function getInverseRelation(): RelationManyToMany|RelationOneToOne|RelationOneToMany
{
return match ($this->getType()) {
self::MANY_TO_ONE => (new RelationOneToMany(
propertyName: $this->inverseProperty,
targetClassName: $this->owningClass,
targetPropertyName: $this->owningProperty,
isSelfReferencing: $this->isSelfReferencing,
orphanRemoval: $this->orphanRemoval,
)),
self::MANY_TO_MANY => (new RelationManyToMany(
propertyName: $this->inverseProperty,
targetClassName: $this->owningClass,
targetPropertyName: $this->owningProperty,
isSelfReferencing: $this->isSelfReferencing
)),
self::ONE_TO_ONE => (new RelationOneToOne(
propertyName: $this->inverseProperty,
targetClassName: $this->owningClass,
targetPropertyName: $this->owningProperty,
isSelfReferencing: $this->isSelfReferencing,
isNullable: $this->isNullable,
)),
default => throw new \InvalidArgumentException('Invalid type'),
};
}
public function getType(): string
{
return $this->type;
}
public function getOwningClass(): string
{
return $this->owningClass;
}
public function getInverseClass(): string
{
return $this->inverseClass;
}
public function getOwningProperty(): string
{
return $this->owningProperty;
}
public function getInverseProperty(): string
{
return $this->inverseProperty;
}
public function isNullable(): bool
{
return $this->isNullable;
}
public function isSelfReferencing(): bool
{
return $this->isSelfReferencing;
}
public function getMapInverseRelation(): bool
{
return $this->mapInverseRelation;
}
public function setMapInverseRelation(bool $mapInverseRelation): void
{
if ($mapInverseRelation && $this->inverseProperty) {
throw new \Exception('Cannot set setMapInverseRelation() to true when the inverse relation property is set.');
}
$this->mapInverseRelation = $mapInverseRelation;
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\ORM\Mapping\Column;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
/**
* @internal
*/
final class ORMDependencyBuilder
{
/**
* Central method to add dependencies needed for Doctrine ORM.
*/
public static function buildDependencies(DependencyBuilder $dependencies): void
{
$classes = [
// guarantee DoctrineBundle
DoctrineBundle::class,
// guarantee ORM
Column::class,
];
foreach ($classes as $class) {
$dependencies->addClassDependency(
$class,
'orm'
);
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\ORM\Mapping\ManyToManyInverseSideMapping;
use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping;
use Symfony\Bundle\MakerBundle\Str;
/**
* @internal
*/
final class RelationManyToMany extends BaseCollectionRelation
{
public function getTargetSetterMethodName(): string
{
return 'add'.Str::asCamelCase(Str::pluralCamelCaseToSingular($this->getTargetPropertyName()));
}
public function getTargetRemoverMethodName(): string
{
return 'remove'.Str::asCamelCase(Str::pluralCamelCaseToSingular($this->getTargetPropertyName()));
}
public static function createFromObject(ManyToManyInverseSideMapping|ManyToManyOwningSideMapping|array $mapping): self
{
/* @legacy Remove conditional when ORM 2.x is no longer supported! */
if (\is_array($mapping)) {
return new self(
propertyName: $mapping['fieldName'],
targetClassName: $mapping['targetEntity'],
targetPropertyName: $mapping['mappedBy'],
mapInverseRelation: !$mapping['isOwningSide'] || null !== $mapping['inversedBy'],
isOwning: $mapping['isOwningSide'],
);
}
if ($mapping instanceof ManyToManyOwningSideMapping) {
return new self(
propertyName: $mapping->fieldName,
targetClassName: $mapping->targetEntity,
targetPropertyName: $mapping->inversedBy,
mapInverseRelation: (null !== $mapping->inversedBy),
isOwning: $mapping->isOwningSide(),
);
}
return new self(
propertyName: $mapping->fieldName,
targetClassName: $mapping->targetEntity,
targetPropertyName: $mapping->mappedBy,
mapInverseRelation: true,
isOwning: $mapping->isOwningSide(),
);
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\ORM\Mapping\ManyToOneAssociationMapping;
/**
* @internal
*/
final class RelationManyToOne extends BaseRelation
{
public static function createFromObject(ManyToOneAssociationMapping|array $mapping): self
{
/* @legacy Remove conditional when ORM 2.x is no longer supported! */
if (\is_array($mapping)) {
return new self(
propertyName: $mapping['fieldName'],
targetClassName: $mapping['targetEntity'],
targetPropertyName: $mapping['inversedBy'],
mapInverseRelation: null !== $mapping['inversedBy'],
isOwning: true,
isNullable: $mapping['joinColumns'][0]['nullable'] ?? true,
);
}
return new self(
propertyName: $mapping->fieldName,
targetClassName: $mapping->targetEntity,
targetPropertyName: $mapping->inversedBy,
mapInverseRelation: null !== $mapping->inversedBy,
isOwning: true,
isNullable: $mapping->joinColumns[0]->nullable ?? true,
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\ORM\Mapping\OneToManyAssociationMapping;
use Symfony\Bundle\MakerBundle\Str;
/**
* @internal
*/
final class RelationOneToMany extends BaseCollectionRelation
{
public function getTargetGetterMethodName(): string
{
return 'get'.Str::asCamelCase($this->getTargetPropertyName());
}
public function getTargetSetterMethodName(): string
{
return 'set'.Str::asCamelCase($this->getTargetPropertyName());
}
public function isMapInverseRelation(): bool
{
throw new \Exception('OneToMany IS the inverse side!');
}
public static function createFromObject(OneToManyAssociationMapping|array $mapping): self
{
/* @legacy Remove conditional when ORM 2.x is no longer supported! */
if (\is_array($mapping)) {
return new self(
propertyName: $mapping['fieldName'],
targetClassName: $mapping['targetEntity'],
targetPropertyName: $mapping['mappedBy'],
orphanRemoval: $mapping['orphanRemoval'],
);
}
return new self(
propertyName: $mapping->fieldName,
targetClassName: $mapping->targetEntity,
targetPropertyName: $mapping->mappedBy,
orphanRemoval: $mapping->orphanRemoval,
);
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\ORM\Mapping\OneToOneInverseSideMapping;
use Doctrine\ORM\Mapping\OneToOneOwningSideMapping;
use Symfony\Bundle\MakerBundle\Str;
/**
* @internal
*/
final class RelationOneToOne extends BaseRelation
{
public function getTargetGetterMethodName(): string
{
return 'get'.Str::asCamelCase($this->getTargetPropertyName());
}
public function getTargetSetterMethodName(): string
{
return 'set'.Str::asCamelCase($this->getTargetPropertyName());
}
public static function createFromObject(OneToOneInverseSideMapping|OneToOneOwningSideMapping|array $mapping): self
{
/* @legacy Remove conditional when ORM 2.x is no longer supported! */
if (\is_array($mapping)) {
return new self(
propertyName: $mapping['fieldName'],
targetClassName: $mapping['targetEntity'],
targetPropertyName: $mapping['isOwningSide'] ? $mapping['inversedBy'] : $mapping['mappedBy'],
mapInverseRelation: !$mapping['isOwningSide'] || null !== $mapping['inversedBy'],
isOwning: $mapping['isOwningSide'],
isNullable: $mapping['joinColumns'][0]['nullable'] ?? true,
);
}
if ($mapping instanceof OneToOneOwningSideMapping) {
return new self(
propertyName: $mapping->fieldName,
targetClassName: $mapping->targetEntity,
targetPropertyName: $mapping->inversedBy,
mapInverseRelation: (null !== $mapping->inversedBy),
isOwning: true,
isNullable: $mapping->joinColumns[0]->nullable ?? true,
);
}
return new self(
propertyName: $mapping->fieldName,
targetClassName: $mapping->targetEntity,
targetPropertyName: $mapping->mappedBy,
mapInverseRelation: true,
isOwning: false,
isNullable: $mapping->joinColumns[0]->nullable ?? true,
);
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Doctrine;
use Doctrine\Persistence\Mapping\ReflectionService;
/**
* @internal replacing removed Doctrine\Persistence\Mapping\StaticReflectionService
*/
final class StaticReflectionService implements ReflectionService
{
public function getParentClasses($class): array
{
return [];
}
public function getClassShortName($class): string
{
$nsSeparatorLastPosition = strrpos($class, '\\');
if (false !== $nsSeparatorLastPosition) {
$class = substr($class, $nsSeparatorLastPosition + 1);
}
return $class;
}
public function getClassNamespace($class): string
{
$namespace = '';
if (str_contains($class, '\\')) {
$namespace = strrev(substr(strrev($class), (int) strpos(strrev($class), '\\') + 1));
}
return $namespace;
}
public function getClass($class): \ReflectionClass
{
return new \ReflectionClass($class);
}
public function getAccessibleProperty($class, $property): ?\ReflectionProperty
{
return null;
}
public function hasPublicMethod($class, $method): bool
{
return true;
}
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Event;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleErrorEvent;
use Symfony\Component\Console\Event\ConsoleTerminateEvent;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Prints certain exceptions in a pretty way and silences normal exception handling.
*
* @author Ryan Weaver <ryan@knpuniversity.com>
*/
final class ConsoleErrorSubscriber implements EventSubscriberInterface
{
private bool $setExitCode = false;
public function onConsoleError(ConsoleErrorEvent $event): void
{
if (!$event->getError() instanceof RuntimeCommandException) {
return;
}
// prevent any visual logging from appearing
$event->stopPropagation();
// prevent the exception from actually being thrown
$event->setExitCode(0);
$this->setExitCode = true;
$io = new SymfonyStyle($event->getInput(), $event->getOutput());
$io->error($event->getError()->getMessage());
}
public function onConsoleTerminate(ConsoleTerminateEvent $event): void
{
if (!$this->setExitCode) {
return;
}
// finally set a non-zero exit code
$event->setExitCode(1);
}
public static function getSubscribedEvents(): array
{
return [
ConsoleEvents::ERROR => 'onConsoleError',
ConsoleEvents::TERMINATE => 'onConsoleTerminate',
];
}
}

View File

@@ -0,0 +1,124 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\AuthenticationEvents;
use Symfony\Component\Security\Http\SecurityEvents;
use Symfony\Component\Workflow\WorkflowEvents;
/**
* @internal
*/
class EventRegistry
{
private static array $eventsMap = [];
public function __construct(
private EventDispatcherInterface $eventDispatcher,
) {
self::$eventsMap = array_flip([
...ConsoleEvents::ALIASES,
...KernelEvents::ALIASES,
...(class_exists(AuthenticationEvents::class) ? AuthenticationEvents::ALIASES : []),
...(class_exists(SecurityEvents::class) ? SecurityEvents::ALIASES : []),
...(class_exists(WorkflowEvents::class) ? WorkflowEvents::ALIASES : []),
...(class_exists(FormEvents::class) ? FormEvents::ALIASES : []),
]);
}
/**
* Returns all known event names in the system.
*/
public function getAllActiveEvents(): array
{
$activeEvents = [];
foreach (self::$eventsMap as $eventName => $eventClass) {
if (!class_exists($eventClass)) {
continue;
}
$activeEvents[] = $eventName;
}
$listeners = $this->eventDispatcher->getListeners();
foreach (array_keys($listeners) as $listenerKey) {
if (!isset(self::$eventsMap[$listenerKey])) {
self::$eventsMap[$listenerKey] = $this->getEventClassName($listenerKey);
}
}
$activeEvents = array_unique(array_merge($activeEvents, array_keys($listeners)));
asort($activeEvents);
return $activeEvents;
}
/**
* Attempts to get the event class for a given event.
*/
public function getEventClassName(string $event): ?string
{
// if the event is already a class name, use it
if (class_exists($event)) {
return $event;
}
if (isset(self::$eventsMap[$event])) {
return self::$eventsMap[$event];
}
$listeners = $this->eventDispatcher->getListeners($event);
if (empty($listeners)) {
return null;
}
foreach ($listeners as $listener) {
if (!\is_array($listener) || 2 !== \count($listener)) {
continue;
}
$reflectionMethod = new \ReflectionMethod($listener[0], $listener[1]);
$args = $reflectionMethod->getParameters();
if (!$args) {
continue;
}
if (null !== $type = $args[0]->getType()) {
$type = $type instanceof \ReflectionNamedType ? $type->getName() : null;
// ignore an "object" type-hint
if ('object' === $type) {
continue;
}
return $type;
}
}
return null;
}
public function listActiveEvents(array $events): array
{
foreach ($events as $key => $event) {
$events[$key] = \sprintf('%s (<fg=yellow>%s</>)', $event, self::$eventsMap[$event]);
}
return $events;
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Exception;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* An exception whose output is displayed as a clean error.
*
* @author Ryan Weaver <ryan@knpuniversity.com>
*/
final class RuntimeCommandException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,207 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
use Symfony\Bundle\MakerBundle\Util\AutoloaderUtil;
use Symfony\Bundle\MakerBundle\Util\MakerFileLinkFormatter;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*
* @internal
*/
class FileManager
{
private ?SymfonyStyle $io = null;
public function __construct(
private Filesystem $fs,
private AutoloaderUtil $autoloaderUtil,
private MakerFileLinkFormatter $makerFileLinkFormatter,
private string $rootDirectory,
private ?string $twigDefaultPath = null,
) {
$this->rootDirectory = rtrim($this->realPath($this->normalizeSlashes($rootDirectory)), '/');
$this->twigDefaultPath = $twigDefaultPath ? rtrim($this->relativizePath($twigDefaultPath), '/') : null;
}
public function setIO(SymfonyStyle $io): void
{
$this->io = $io;
}
public function parseTemplate(string $templatePath, array $parameters): string
{
ob_start();
extract($parameters, \EXTR_SKIP);
include $templatePath;
return ob_get_clean();
}
public function dumpFile(string $filename, string $content): void
{
$absolutePath = $this->absolutizePath($filename);
$newFile = !$this->fileExists($filename);
$existingContent = $newFile ? '' : file_get_contents($absolutePath);
$comment = $newFile ? '<fg=blue>created</>' : '<fg=yellow>updated</>';
if ($existingContent === $content) {
$comment = '<fg=green>no change</>';
}
$this->fs->dumpFile($absolutePath, $content);
$relativePath = $this->relativizePath($filename);
$this->io?->comment(\sprintf(
'%s: %s',
$comment,
$this->makerFileLinkFormatter->makeLinkedPath($absolutePath, $relativePath)
));
}
public function fileExists($path): bool
{
return file_exists($this->absolutizePath($path));
}
/**
* Attempts to make the path relative to the root directory.
*
* @throws \Exception
*/
public function relativizePath(string $absolutePath): string
{
$absolutePath = $this->normalizeSlashes($absolutePath);
// see if the path is even in the root
if (!str_contains($absolutePath, $this->rootDirectory)) {
return $absolutePath;
}
$absolutePath = $this->realPath($absolutePath);
// str_replace but only the first occurrence
$relativePath = ltrim(implode('', explode($this->rootDirectory, $absolutePath, 2)), '/');
if (str_starts_with($relativePath, './')) {
$relativePath = substr($relativePath, 2);
}
return is_dir($absolutePath) ? rtrim($relativePath, '/').'/' : $relativePath;
}
public function getFileContents(string $path): string
{
if (!$this->fileExists($path)) {
throw new \InvalidArgumentException(\sprintf('Cannot find file "%s"', $path));
}
return file_get_contents($this->absolutizePath($path));
}
public function isPathInVendor(string $path): bool
{
return str_starts_with(
$this->normalizeSlashes($path),
$this->normalizeSlashes($this->rootDirectory.'/vendor/')
);
}
public function absolutizePath($path): string
{
if (str_starts_with($path, '/')) {
return $path;
}
// support windows drive paths: C:\ or C:/
if (1 === strpos($path, ':\\') || 1 === strpos($path, ':/')) {
return $path;
}
return \sprintf('%s/%s', $this->rootDirectory, $path);
}
/**
* @throws \Exception
*/
public function getRelativePathForFutureClass(string $className): ?string
{
$path = $this->autoloaderUtil->getPathForFutureClass($className);
return null === $path ? null : $this->relativizePath($path);
}
public function getNamespacePrefixForClass(string $className): string
{
return $this->autoloaderUtil->getNamespacePrefixForClass($className);
}
public function isNamespaceConfiguredToAutoload(string $namespace): bool
{
return $this->autoloaderUtil->isNamespaceConfiguredToAutoload($namespace);
}
public function getRootDirectory(): string
{
return $this->rootDirectory;
}
public function getPathForTemplate(string $filename): string
{
if (null === $this->twigDefaultPath) {
throw new \RuntimeException('Cannot get path for template: is Twig installed?');
}
return $this->twigDefaultPath.'/'.$filename;
}
/**
* Resolve '../' in paths (like real_path), but for non-existent files.
*/
private function realPath(string $absolutePath): string
{
$finalParts = [];
$currentIndex = -1;
$absolutePath = $this->normalizeSlashes($absolutePath);
foreach (explode('/', $absolutePath) as $pathPart) {
if ('..' === $pathPart) {
// we need to remove the previous entry
if (-1 === $currentIndex) {
throw new \Exception(\sprintf('Problem making path relative - is the path "%s" absolute?', $absolutePath));
}
unset($finalParts[$currentIndex]);
--$currentIndex;
continue;
}
$finalParts[] = $pathPart;
++$currentIndex;
}
$finalPath = implode('/', $finalParts);
// Normalize: // => /
// Normalize: /./ => /
return str_replace(['//', '/./'], '/', $finalPath);
}
private function normalizeSlashes(string $path): string
{
return str_replace('\\', '/', $path);
}
}

View File

@@ -0,0 +1,341 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
use Symfony\Bundle\MakerBundle\Util\PhpCompatUtil;
use Symfony\Bundle\MakerBundle\Util\TemplateComponentGenerator;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
class Generator
{
private GeneratorTwigHelper $twigHelper;
private array $pendingOperations = [];
private array $generatedFiles = [];
public function __construct(
private FileManager $fileManager,
private string $namespacePrefix,
?PhpCompatUtil $phpCompatUtil = null,
private ?TemplateComponentGenerator $templateComponentGenerator = null,
) {
$this->twigHelper = new GeneratorTwigHelper($fileManager);
$this->namespacePrefix = trim($namespacePrefix, '\\');
if (null !== $phpCompatUtil) {
trigger_deprecation('symfony/maker-bundle', 'v1.44.0', 'Initializing Generator while providing an instance of PhpCompatUtil is deprecated.');
}
}
/**
* Generate a new file for a class from a template.
*
* @param string $className The fully-qualified class name
* @param string $templateName Template name in Resources/skeleton to use
* @param array $variables Array of variables to pass to the template
*
* @return string The path where the file will be created
*
* @throws \Exception
*/
public function generateClass(string $className, string $templateName, array $variables = []): string
{
if (\array_key_exists('class_data', $variables) && $variables['class_data'] instanceof ClassData) {
$classData = $this->templateComponentGenerator->configureClass($variables['class_data']);
$className = $classData->getFullClassName();
}
$targetPath = $this->fileManager->getRelativePathForFutureClass($className);
if (null === $targetPath) {
throw new \LogicException(\sprintf('Could not determine where to locate the new class "%s", maybe try with a full namespace like "My\\Full\\Namespace\\%s"', $className, Str::getShortClassName($className)));
}
$variables = array_merge($variables, [
'class_name' => Str::getShortClassName($className),
'namespace' => Str::getNamespace($className),
]);
$this->addOperation($targetPath, $templateName, $variables);
return $targetPath;
}
/**
* Future replacement for generateClass().
*
* @internal
*
* @param string $templateName Template name in the templates/ dir to use
* @param array $variables Array of variables to pass to the template
* @param bool $isController Set to true if generating a Controller that needs
* access to the TemplateComponentGenerator ("generator") in
* the Twig template. e.g. to create route attributes for a route method
*
* @return string The path where the file will be created
*
* @throws \Exception
*/
final public function generateClassFromClassData(ClassData $classData, string $templateName, array $variables = [], bool $isController = false): string
{
$classData = $this->templateComponentGenerator->configureClass($classData);
$targetPath = $this->fileManager->getRelativePathForFutureClass($classData->getFullClassName());
if (null === $targetPath) {
throw new \LogicException(\sprintf('Could not determine where to locate the new class "%s", maybe try with a full namespace like "My\\Full\\Namespace\\%s"', $classData->getFullClassName(), $classData->getClassName()));
}
$globalTemplateVars = ['class_data' => $classData];
if ($isController) {
$globalTemplateVars['generator'] = $this->templateComponentGenerator;
}
$this->addOperation($targetPath, $templateName, array_merge($variables, $globalTemplateVars));
return $targetPath;
}
/**
* Generate a normal file from a template.
*
* @return void
*/
public function generateFile(string $targetPath, string $templateName, array $variables = [])
{
$variables = array_merge($variables, [
'helper' => $this->twigHelper,
]);
$this->addOperation($targetPath, $templateName, $variables);
}
/**
* @return void
*/
public function dumpFile(string $targetPath, string $contents)
{
$this->pendingOperations[$targetPath] = [
'contents' => $contents,
];
}
public function getFileContentsForPendingOperation(string $targetPath): string
{
if (!isset($this->pendingOperations[$targetPath])) {
throw new RuntimeCommandException(\sprintf('File "%s" is not in the Generator\'s pending operations', $targetPath));
}
$templatePath = $this->pendingOperations[$targetPath]['template'];
$parameters = $this->pendingOperations[$targetPath]['variables'];
$templateParameters = array_merge($parameters, [
'relative_path' => $this->fileManager->relativizePath($targetPath),
]);
return $this->fileManager->parseTemplate($templatePath, $templateParameters);
}
/**
* Creates a helper object to get data about a class name.
*
* Examples:
*
* // App\Entity\FeaturedProduct
* $gen->createClassNameDetails('FeaturedProduct', 'Entity');
* $gen->createClassNameDetails('featured product', 'Entity');
*
* // App\Controller\FooController
* $gen->createClassNameDetails('foo', 'Controller', 'Controller');
*
* // App\Controller\Foo\AdminController
* $gen->createClassNameDetails('Foo\\Admin', 'Controller', 'Controller');
*
* // App\Security\Voter\CoolVoter
* $gen->createClassNameDetails('Cool', 'Security\Voter', 'Voter');
*
* // Full class names can also be passed. Imagine the user has an autoload
* // rule where Cool\Stuff lives in a "lib/" directory
* // Cool\Stuff\BalloonController
* $gen->createClassNameDetails('Cool\\Stuff\\Balloon', 'Controller', 'Controller');
*
* @param string $name The short "name" that will be turned into the class name
* @param string $namespacePrefix Recommended namespace where this class should live, but *without* the "App\\" part
* @param string $suffix Optional suffix to guarantee is on the end of the class
*/
public function createClassNameDetails(string $name, string $namespacePrefix, string $suffix = '', string $validationErrorMessage = ''): ClassNameDetails
{
$fullNamespacePrefix = $this->namespacePrefix.'\\'.$namespacePrefix;
if ('\\' === $name[0]) {
// class is already "absolute" - leave it alone (but strip opening \)
$className = substr($name, 1);
} else {
$className = Str::asClassName($name, $suffix);
try {
Validator::classDoesNotExist($className);
$className = rtrim($fullNamespacePrefix, '\\').'\\'.$className;
} catch (RuntimeCommandException) {
}
}
Validator::validateClassName($className, $validationErrorMessage);
// if this is a custom class, we may be completely different than the namespace prefix
// the best way can do, is find the PSR4 prefix and use that
if (!str_starts_with($className, $fullNamespacePrefix)) {
$fullNamespacePrefix = $this->fileManager->getNamespacePrefixForClass($className);
}
return new ClassNameDetails($className, $fullNamespacePrefix, $suffix);
}
public function getRootDirectory(): string
{
return $this->fileManager->getRootDirectory();
}
public function hasPendingOperations(): bool
{
return !empty($this->pendingOperations);
}
/**
* Actually writes and file changes that are pending.
*
* @return void
*/
public function writeChanges()
{
foreach ($this->pendingOperations as $targetPath => $templateData) {
$this->generatedFiles[] = $targetPath;
if (isset($templateData['contents'])) {
$this->fileManager->dumpFile($targetPath, $templateData['contents']);
continue;
}
$this->fileManager->dumpFile(
$targetPath,
$this->getFileContentsForPendingOperation($targetPath)
);
}
$this->pendingOperations = [];
}
public function getRootNamespace(): string
{
return $this->namespacePrefix;
}
public function generateController(string $controllerClassName, string $controllerTemplatePath, array $parameters = []): string
{
return $this->generateClass(
$controllerClassName,
$controllerTemplatePath,
$parameters +
[
'generator' => $this->templateComponentGenerator,
]
);
}
/**
* Generate a template file.
*
* @return void
*/
public function generateTemplate(string $targetPath, string $templateName, array $variables = [])
{
$this->generateFile(
$this->fileManager->getPathForTemplate($targetPath),
$templateName,
$variables
);
}
/**
* Get the full path of each file created by the Generator.
*/
public function getGeneratedFiles(): array
{
return $this->generatedFiles;
}
/**
* @deprecated MakerBundle only supports AbstractController::class. This method will be removed in the future.
*/
public static function getControllerBaseClass(): ClassNameDetails
{
trigger_deprecation('symfony/maker-bundle', 'v1.41.0', 'MakerBundle only supports AbstractController. This method will be removed in the future.');
return new ClassNameDetails(AbstractController::class, '\\');
}
private function addOperation(string $targetPath, string $templateName, array $variables): void
{
if ($this->fileManager->fileExists($targetPath)) {
throw new RuntimeCommandException(\sprintf('The file "%s" can\'t be generated because it already exists.', $this->fileManager->relativizePath($targetPath)));
}
$variables['relative_path'] = $this->fileManager->relativizePath($targetPath);
$templatePath = $templateName;
if (!file_exists($templatePath)) {
$templatePath = \sprintf('%s/templates/%s', \dirname(__DIR__), $templateName);
if (!file_exists($templatePath)) {
$templatePath = $this->getTemplateFromLegacySkeletonPath($templateName);
}
if (!file_exists($templatePath)) {
throw new \Exception(\sprintf('Cannot find template "%s" in the templates/ dir.', $templateName));
}
}
$this->pendingOperations[$targetPath] = [
'template' => $templatePath,
'variables' => $variables,
];
}
/**
* @legacy - Remove when public generate methods become "internal" to MakerBundle in v2
*/
private function getTemplateFromLegacySkeletonPath(string $templateName): string
{
$templatePath = $templateName;
if (!file_exists($templatePath)) {
$templatePath = __DIR__.'/Resources/skeleton/'.$templateName;
if (!file_exists($templatePath)) {
throw new \Exception(\sprintf('Cannot find template "%s"', $templateName));
}
}
@trigger_deprecation(
'symfony/maker-bundle',
'1.62.0',
'Storing templates in src/Resources/skeleton is deprecated. Store MakerBundle templates in the "~/templates/" dir instead.',
);
return $templatePath;
}
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
/**
* @author Sadicov Vladimir <sadikoff@gmail.com>
*/
final class GeneratorTwigHelper
{
public function __construct(
private FileManager $fileManager,
) {
}
public function getEntityFieldPrintCode($entity, $field): string
{
$twigField = preg_replace_callback('/(?!^)_([a-z0-9])/', static fn ($s) => strtoupper($s[1]), $field['fieldName']);
$printCode = $entity.'.'.str_replace('_', '', $twigField);
match ($field['type']) {
'datetimetz_immutable', 'datetimetz' => $printCode .= ' ? '.$printCode.'|date(\'Y-m-d H:i:s T\') : \'\'',
'datetime_immutable', 'datetime' => $printCode .= ' ? '.$printCode.'|date(\'Y-m-d H:i:s\') : \'\'',
'dateinterval' => $printCode .= ' ? '.$printCode.'.format(\'%y year(s), %m month(s), %d day(s)\') : \'\'',
'date_immutable', 'date' => $printCode .= ' ? '.$printCode.'|date(\'Y-m-d\') : \'\'',
'time_immutable', 'time' => $printCode .= ' ? '.$printCode.'|date(\'H:i:s\') : \'\'',
'json' => $printCode .= ' ? '.$printCode.'|json_encode : \'\'',
'array' => $printCode .= ' ? '.$printCode.'|join(\', \') : \'\'',
'boolean' => $printCode .= ' ? \'Yes\' : \'No\'',
default => $printCode,
};
return $printCode;
}
public function getHeadPrintCode($title): string
{
if ($this->fileManager->fileExists($this->fileManager->getPathForTemplate('base.html.twig'))) {
return <<<TWIG
{% extends 'base.html.twig' %}
{% block title %}$title{% endblock %}
TWIG;
}
return <<<HTML
<!DOCTYPE html>
<title>$title</title>
HTML;
}
public function getFileLink($path, $text = null, $line = 0): string
{
trigger_deprecation('symfony/maker-bundle', 'v1.53.0', 'getFileLink() is deprecated and will be removed in the future.');
$text = $text ?: $path;
return "<a href=\"{{ '$path'|file_link($line) }}\">$text</a>";
}
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
use Symfony\Component\Console\Input\InputInterface;
/**
* Lets the configureDependencies method access to the command's input.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
interface InputAwareMakerInterface extends MakerInterface
{
public function configureDependencies(DependencyBuilder $dependencies, ?InputInterface $input = null);
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle;
final class InputConfiguration
{
private array $nonInteractiveArguments = [];
/**
* Call in MakerInterface::configureCommand() to disable the automatic interactive
* prompt for an argument.
*/
public function setArgumentAsNonInteractive(string $argumentName): void
{
$this->nonInteractiveArguments[] = $argumentName;
}
public function getNonInteractiveArguments(): array
{
return $this->nonInteractiveArguments;
}
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\MakerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
/**
* Convenient abstract class for makers.
*/
abstract class AbstractMaker implements MakerInterface
{
/**
* @return void
*/
public function interact(InputInterface $input, ConsoleStyle $io, Command $command)
{
}
/**
* @return void
*/
protected function writeSuccessMessage(ConsoleStyle $io)
{
$io->newLine();
$io->writeln(' <bg=green;fg=white> </>');
$io->writeln(' <bg=green;fg=white> Success! </>');
$io->writeln(' <bg=green;fg=white> </>');
$io->newLine();
}
/** @param array<class-string, string> $dependencies */
protected function addDependencies(array $dependencies, ?string $message = null): string
{
$dependencyBuilder = new DependencyBuilder();
foreach ($dependencies as $class => $name) {
$dependencyBuilder->addClassDependency($class, $name);
}
return $dependencyBuilder->getMissingPackagesMessage(
static::getCommandName(),
$message
);
}
/**
* Get the help file contents needed for "setHelp()" of a maker.
*
* @param string $helpFileName the filename (omit path) of the help file located in config/help/
* e.g. MakeController.txt
*
* @internal
*/
final protected function getHelpFileContents(string $helpFileName): string
{
return file_get_contents(\sprintf('%s/config/help/%s', \dirname(__DIR__, 2), $helpFileName));
}
}

View File

@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker\Common;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
/**
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @internal
*/
trait CanGenerateTestsTrait
{
private bool $generateTests = false;
public function configureCommandWithTestsOption(Command $command): Command
{
$testsHelp = file_get_contents(\dirname(__DIR__, 3).'/config/help/_WithTests.txt');
$help = $command->getHelp()."\n".$testsHelp;
$command
->addOption(name: 'with-tests', mode: InputOption::VALUE_NONE, description: 'Generate PHPUnit Tests')
->setHelp($help)
;
return $command;
}
public function interactSetGenerateTests(InputInterface $input, ConsoleStyle $io): void
{
// Sanity check for maker dev's - End user should never see this.
if (!$input->hasOption('with-tests')) {
throw new RuntimeCommandException('Whoops! "--with-tests" option does not exist. Call "addWithTestsOptions()" in the makers "configureCommand().');
}
$this->generateTests = $input->getOption('with-tests');
if (!$this->generateTests) {
$this->generateTests = $io->confirm('Do you want to generate PHPUnit tests? [Experimental]', false);
}
}
public function shouldGenerateTests(): bool
{
return $this->generateTests;
}
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker\Common;
/**
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @internal
*/
enum EntityIdTypeEnum
{
case INT;
case UUID;
case ULID;
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker\Common;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Component\Process\Process;
/**
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @internal
*/
trait InstallDependencyTrait
{
/**
* @param string $composerPackage Fully qualified composer package to install e.g. symfony/maker-bundle
*/
public function installDependencyIfNeeded(ConsoleStyle $io, string $expectedClassToExist, string $composerPackage): ConsoleStyle
{
if (class_exists($expectedClassToExist)) {
return $io;
}
$io->writeln(\sprintf('Running: composer require %s', $composerPackage));
Process::fromShellCommandline(\sprintf('composer require %s', $composerPackage))->run();
$io->writeln(\sprintf('%s successfully installed!', $composerPackage));
$io->newLine();
return $io;
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker\Common;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
/**
* @author Jesse Rushlow<jr@rushlow.dev>
*
* @internal
*/
trait UidTrait
{
private bool $usesUuid = false;
private bool $usesUlid = false;
/**
* Call this in a maker's configure() to consistently allow entity's with UUID's.
* This should be called after you calling "setHelp()" in the maker.
*/
protected function addWithUuidOption(Command $command): Command
{
$uidHelp = file_get_contents(\dirname(__DIR__, 3).'/config/help/_WithUid.txt');
$help = $command->getHelp()."\n".$uidHelp;
$command
->addOption(name: 'with-uuid', mode: InputOption::VALUE_NONE, description: 'Use UUID for entity "id"')
->addOption('with-ulid', mode: InputOption::VALUE_NONE, description: 'Use ULID for entity "id"')
->setHelp($help)
;
return $command;
}
/**
* Call this as early as possible in a maker's interact().
*/
protected function checkIsUsingUid(InputInterface $input): void
{
if (($this->usesUuid = $input->getOption('with-uuid')) && !class_exists(Uuid::class)) {
throw new RuntimeCommandException('You must install symfony/uid to use Uuid\'s as "id" (composer require symfony/uid)');
}
if (($this->usesUlid = $input->getOption('with-ulid')) && !class_exists(Ulid::class)) {
throw new RuntimeCommandException('You must install symfony/uid to use Ulid\'s as "id" (composer require symfony/uid)');
}
if ($this->usesUuid && $this->usesUlid) {
throw new RuntimeCommandException('Setting --with-uuid & --with-ulid at the same time is not allowed. Please choose only one.');
}
}
protected function getIdType(): EntityIdTypeEnum
{
if ($this->usesUuid) {
return EntityIdTypeEnum::UUID;
}
if ($this->usesUlid) {
return EntityIdTypeEnum::ULID;
}
return EntityIdTypeEnum::INT;
}
}

View File

@@ -0,0 +1,472 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper;
use Symfony\Bundle\MakerBundle\Security\SecurityConfigUpdater;
use Symfony\Bundle\MakerBundle\Security\SecurityControllerBuilder;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Util\YamlManipulationFailedException;
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;
use Symfony\Component\Yaml\Yaml;
/**
* @deprecated since MakerBundle v1.59.0, use any of the Security/Make* instead.
*
* @author Ryan Weaver <ryan@symfonycasts.com>
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @internal
*/
final class MakeAuthenticator extends AbstractMaker
{
private const AUTH_TYPE_EMPTY_AUTHENTICATOR = 'empty-authenticator';
private const AUTH_TYPE_FORM_LOGIN = 'form-login';
private const REMEMBER_ME_TYPE_ALWAYS = 'always';
private const REMEMBER_ME_TYPE_CHECKBOX = 'checkbox';
public function __construct(
private FileManager $fileManager,
private SecurityConfigUpdater $configUpdater,
private Generator $generator,
private DoctrineHelper $doctrineHelper,
private SecurityControllerBuilder $securityControllerBuilder,
) {
}
public static function getCommandName(): string
{
return 'make:auth';
}
public static function getCommandDescription(): string
{
return 'Create a Guard authenticator of different flavors';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->setHelp($this->getHelpFileContents('MakeAuth.txt'))
;
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
trigger_deprecation('symfony/maker-bundle', 'v1.59.0', 'The "%s" class is deprecated, use any of the Security\Make* commands instead.', self::class);
$io->caution('"make:auth" is deprecated, use any of the "make:security" commands instead.');
if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) {
throw new RuntimeCommandException('The file "config/packages/security.yaml" does not exist. PHP & XML configuration formats are currently not supported.');
}
$manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path));
$securityData = $manipulator->getData();
// authenticator type
$authenticatorTypeValues = [
'Empty authenticator' => self::AUTH_TYPE_EMPTY_AUTHENTICATOR,
'Login form authenticator' => self::AUTH_TYPE_FORM_LOGIN,
];
$command->addArgument('authenticator-type', InputArgument::REQUIRED);
$authenticatorType = $io->choice(
'What style of authentication do you want?',
array_keys($authenticatorTypeValues),
key($authenticatorTypeValues)
);
$input->setArgument(
'authenticator-type',
$authenticatorTypeValues[$authenticatorType]
);
if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) {
$neededDependencies = [TwigBundle::class => 'twig'];
$missingPackagesMessage = $this->addDependencies($neededDependencies, 'Twig must be installed to display the login form.');
if ($missingPackagesMessage) {
throw new RuntimeCommandException($missingPackagesMessage);
}
if (!isset($securityData['security']['providers']) || !$securityData['security']['providers']) {
throw new RuntimeCommandException('To generate a form login authentication, you must configure at least one entry under "providers" in "security.yaml".');
}
}
// authenticator class
$command->addArgument('authenticator-class', InputArgument::REQUIRED);
$questionAuthenticatorClass = new Question('The class name of the authenticator to create (e.g. <fg=yellow>AppCustomAuthenticator</>)');
$questionAuthenticatorClass->setValidator(
function ($answer) {
Validator::notBlank($answer);
return Validator::classDoesNotExist(
$this->generator->createClassNameDetails($answer, 'Security\\', 'Authenticator')->getFullName()
);
}
);
$input->setArgument('authenticator-class', $io->askQuestion($questionAuthenticatorClass));
$interactiveSecurityHelper = new InteractiveSecurityHelper();
$command->addOption('firewall-name', null, InputOption::VALUE_OPTIONAL);
$input->setOption('firewall-name', $interactiveSecurityHelper->guessFirewallName($io, $securityData));
$command->addOption('entry-point', null, InputOption::VALUE_OPTIONAL);
if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) {
$command->addArgument('controller-class', InputArgument::REQUIRED);
$input->setArgument(
'controller-class',
$io->ask(
'Choose a name for the controller class (e.g. <fg=yellow>SecurityController</>)',
'SecurityController',
Validator::validateClassName(...)
)
);
$command->addArgument('user-class', InputArgument::REQUIRED);
$input->setArgument(
'user-class',
$userClass = $interactiveSecurityHelper->guessUserClass($io, $securityData['security']['providers'])
);
$command->addArgument('username-field', InputArgument::REQUIRED);
$input->setArgument(
'username-field',
$interactiveSecurityHelper->guessUserNameField($io, $userClass, $securityData['security']['providers'])
);
$command->addArgument('logout-setup', InputArgument::REQUIRED);
$input->setArgument(
'logout-setup',
$io->confirm(
'Do you want to generate a \'/logout\' URL?',
true
)
);
$command->addArgument('support-remember-me', InputArgument::REQUIRED);
$input->setArgument(
'support-remember-me',
$io->confirm(
'Do you want to support remember me?',
true
)
);
if ($input->getArgument('support-remember-me')) {
$supportRememberMeValues = [
'Activate when the user checks a box' => self::REMEMBER_ME_TYPE_CHECKBOX,
'Always activate remember me' => self::REMEMBER_ME_TYPE_ALWAYS,
];
$command->addArgument('always-remember-me', InputArgument::REQUIRED);
$supportRememberMeType = $io->choice(
'How should remember me be activated?',
array_keys($supportRememberMeValues),
key($supportRememberMeValues)
);
$input->setArgument(
'always-remember-me',
$supportRememberMeValues[$supportRememberMeType]
);
}
}
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$manipulator = new YamlSourceManipulator($this->fileManager->getFileContents('config/packages/security.yaml'));
$securityData = $manipulator->getData();
$supportRememberMe = $input->hasArgument('support-remember-me') ? $input->getArgument('support-remember-me') : false;
$alwaysRememberMe = $input->hasArgument('always-remember-me') && self::REMEMBER_ME_TYPE_ALWAYS === $input->getArgument('always-remember-me');
$this->generateAuthenticatorClass(
$securityData,
$input->getArgument('authenticator-type'),
$input->getArgument('authenticator-class'),
$input->hasArgument('user-class') ? $input->getArgument('user-class') : null,
$input->hasArgument('username-field') ? $input->getArgument('username-field') : null,
$supportRememberMe,
);
// update security.yaml with guard config
$securityYamlUpdated = false;
$entryPoint = $input->getOption('entry-point');
if (self::AUTH_TYPE_FORM_LOGIN !== $input->getArgument('authenticator-type')) {
$entryPoint = false;
}
try {
$newYaml = $this->configUpdater->updateForAuthenticator(
$this->fileManager->getFileContents($path = 'config/packages/security.yaml'),
$input->getOption('firewall-name'),
$entryPoint,
$input->getArgument('authenticator-class'),
$input->hasArgument('logout-setup') ? $input->getArgument('logout-setup') : false,
$supportRememberMe,
$alwaysRememberMe
);
$generator->dumpFile($path, $newYaml);
$securityYamlUpdated = true;
} catch (YamlManipulationFailedException) {
}
if (self::AUTH_TYPE_FORM_LOGIN === $input->getArgument('authenticator-type')) {
$this->generateFormLoginFiles(
$input->getArgument('controller-class'),
$input->getArgument('username-field'),
$input->getArgument('logout-setup'),
$supportRememberMe,
$alwaysRememberMe,
);
}
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text(
$this->generateNextMessage(
$securityYamlUpdated,
$input->getArgument('authenticator-type'),
$input->getArgument('authenticator-class'),
$input->hasArgument('user-class') ? $input->getArgument('user-class') : null,
$input->hasArgument('logout-setup') ? $input->getArgument('logout-setup') : false,
$supportRememberMe,
$alwaysRememberMe
)
);
}
/** @param array<mixed> $securityData */
private function generateAuthenticatorClass(array $securityData, string $authenticatorType, string $authenticatorClass, ?string $userClass, ?string $userNameField, bool $supportRememberMe): void
{
$useStatements = new UseStatementGenerator([
Request::class,
Response::class,
TokenInterface::class,
Passport::class,
]);
// generate authenticator class
if (self::AUTH_TYPE_EMPTY_AUTHENTICATOR === $authenticatorType) {
$useStatements->addUseStatement([
AuthenticationException::class,
AbstractAuthenticator::class,
]);
$this->generator->generateClass(
$authenticatorClass,
'authenticator/EmptyAuthenticator.tpl.php',
['use_statements' => $useStatements]
);
return;
}
$useStatements->addUseStatement([
RedirectResponse::class,
UrlGeneratorInterface::class,
AbstractLoginFormAuthenticator::class,
CsrfTokenBadge::class,
UserBadge::class,
PasswordCredentials::class,
TargetPathTrait::class,
SecurityRequestAttributes::class,
]);
if ($supportRememberMe) {
$useStatements->addUseStatement(RememberMeBadge::class);
}
$userClassNameDetails = $this->generator->createClassNameDetails(
'\\'.$userClass,
'Entity\\'
);
$this->generator->generateClass(
$authenticatorClass,
'authenticator/LoginFormAuthenticator.tpl.php',
[
'use_statements' => $useStatements,
'user_fully_qualified_class_name' => trim($userClassNameDetails->getFullName(), '\\'),
'user_class_name' => $userClassNameDetails->getShortName(),
'username_field' => $userNameField,
'username_field_label' => Str::asHumanWords($userNameField),
'username_field_var' => Str::asLowerCamelCase($userNameField),
'user_needs_encoder' => $this->userClassHasEncoder($securityData, $userClass),
'user_is_entity' => $this->doctrineHelper->isClassAMappedEntity($userClass),
'remember_me_badge' => $supportRememberMe,
]
);
}
private function generateFormLoginFiles(string $controllerClass, string $userNameField, bool $logoutSetup, bool $supportRememberMe, bool $alwaysRememberMe): void
{
$controllerClassNameDetails = $this->generator->createClassNameDetails(
$controllerClass,
'Controller\\',
'Controller'
);
if (!class_exists($controllerClassNameDetails->getFullName())) {
$useStatements = new UseStatementGenerator([
AbstractController::class,
Route::class,
AuthenticationUtils::class,
]);
$controllerPath = $this->generator->generateController(
$controllerClassNameDetails->getFullName(),
'authenticator/EmptySecurityController.tpl.php',
['use_statements' => $useStatements]
);
$controllerSourceCode = $this->generator->getFileContentsForPendingOperation($controllerPath);
} else {
$controllerPath = $this->fileManager->getRelativePathForFutureClass($controllerClassNameDetails->getFullName());
$controllerSourceCode = $this->fileManager->getFileContents($controllerPath);
}
if (method_exists($controllerClassNameDetails->getFullName(), 'login')) {
throw new RuntimeCommandException(\sprintf('Method "login" already exists on class %s', $controllerClassNameDetails->getFullName()));
}
$manipulator = new ClassSourceManipulator(
sourceCode: $controllerSourceCode,
overwrite: true
);
$this->securityControllerBuilder->addLoginMethod($manipulator);
if ($logoutSetup) {
$this->securityControllerBuilder->addLogoutMethod($manipulator);
}
$this->generator->dumpFile($controllerPath, $manipulator->getSourceCode());
// create login form template
$this->generator->generateTemplate(
'security/login.html.twig',
'authenticator/login_form.tpl.php',
[
'username_field' => $userNameField,
'username_is_email' => false !== stripos($userNameField, 'email'),
'username_label' => ucfirst(Str::asHumanWords($userNameField)),
'logout_setup' => $logoutSetup,
'support_remember_me' => $supportRememberMe,
'always_remember_me' => $alwaysRememberMe,
]
);
}
/** @return string[] */
private function generateNextMessage(bool $securityYamlUpdated, string $authenticatorType, string $authenticatorClass, ?string $userClass, bool $logoutSetup, bool $supportRememberMe, bool $alwaysRememberMe): array
{
$nextTexts = ['Next:'];
$nextTexts[] = '- Customize your new authenticator.';
if (!$securityYamlUpdated) {
$yamlExample = $this->configUpdater->updateForAuthenticator(
'security: {}',
'main',
null,
$authenticatorClass,
$logoutSetup,
$supportRememberMe,
$alwaysRememberMe
);
$nextTexts[] = "- Your <info>security.yaml</info> could not be updated automatically. You'll need to add the following config manually:\n\n".$yamlExample;
}
if (self::AUTH_TYPE_FORM_LOGIN === $authenticatorType) {
$nextTexts[] = \sprintf('- Finish the redirect "TODO" in the <info>%s::onAuthenticationSuccess()</info> method.', $authenticatorClass);
if (!$this->doctrineHelper->isClassAMappedEntity($userClass)) {
$nextTexts[] = \sprintf('- Review <info>%s::getUser()</info> to make sure it matches your needs.', $authenticatorClass);
}
$nextTexts[] = '- Review & adapt the login template: <info>'.$this->fileManager->getPathForTemplate('security/login.html.twig').'</info>.';
}
return $nextTexts;
}
/** @param array<mixed> $securityData */
private function userClassHasEncoder(array $securityData, string $userClass): bool
{
$userNeedsEncoder = false;
$hashersData = $securityData['security']['encoders'] ?? [];
foreach ($hashersData as $userClassWithEncoder => $encoder) {
if ($userClass === $userClassWithEncoder || is_subclass_of($userClass, $userClassWithEncoder) || class_implements($userClass, $userClassWithEncoder)) {
$userNeedsEncoder = true;
}
}
return $userNeedsEncoder;
}
public function configureDependencies(DependencyBuilder $dependencies, ?InputInterface $input = null): void
{
$dependencies->addClassDependency(
SecurityBundle::class,
'security'
);
// needed to update the YAML files
$dependencies->addClassDependency(
Yaml::class,
'yaml'
);
}
}

View File

@@ -0,0 +1,113 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\PhpCompatUtil;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeCommand extends AbstractMaker
{
public function __construct(private ?PhpCompatUtil $phpCompatUtil = null)
{
if (null !== $phpCompatUtil) {
@trigger_deprecation(
'symfony/maker-bundle',
'1.55.0',
\sprintf('Initializing MakeCommand while providing an instance of "%s" is deprecated. The $phpCompatUtil param will be removed in a future version.', PhpCompatUtil::class),
);
}
}
public static function getCommandName(): string
{
return 'make:command';
}
public static function getCommandDescription(): string
{
return 'Create a new console command class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, \sprintf('Choose a command name (e.g. <fg=yellow>app:%s</>)', Str::asCommand(Str::getRandomTerm())))
->setHelp($this->getHelpFileContents('MakeCommand.txt'))
;
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$commandName = trim($input->getArgument('name'));
$commandNameHasAppPrefix = str_starts_with($commandName, 'app:');
$commandClassNameDetails = $generator->createClassNameDetails(
$commandNameHasAppPrefix ? substr($commandName, 4) : $commandName,
'Command\\',
'Command',
\sprintf('The "%s" command name is not valid because it would be implemented by "%s" class, which is not valid as a PHP class name (it must start with a letter or underscore, followed by any number of letters, numbers, or underscores).', $commandName, Str::asClassName($commandName, 'Command'))
);
$useStatements = new UseStatementGenerator([
Command::class,
InputArgument::class,
InputInterface::class,
InputOption::class,
OutputInterface::class,
SymfonyStyle::class,
AsCommand::class,
]);
$generator->generateClass(
$commandClassNameDetails->getFullName(),
'command/Command.tpl.php',
[
'use_statements' => $useStatements,
'command_name' => $commandName,
'set_description' => !class_exists(LazyCommand::class),
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: open your new command class and customize it!',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/console.html</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
Command::class,
'console'
);
}
}

View File

@@ -0,0 +1,170 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\Common\CanGenerateTestsTrait;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
use Symfony\Bundle\MakerBundle\Util\PhpCompatUtil;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeController extends AbstractMaker
{
use CanGenerateTestsTrait;
private bool $isInvokable;
private ClassData $controllerClassData;
private bool $usesTwigTemplate;
private string $twigTemplatePath;
public function __construct(private ?PhpCompatUtil $phpCompatUtil = null)
{
if (null !== $phpCompatUtil) {
@trigger_deprecation(
'symfony/maker-bundle',
'1.55.0',
\sprintf('Initializing MakeCommand while providing an instance of "%s" is deprecated. The $phpCompatUtil param will be removed in a future version.', PhpCompatUtil::class)
);
}
}
public static function getCommandName(): string
{
return 'make:controller';
}
public static function getCommandDescription(): string
{
return 'Create a new controller class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('controller-class', InputArgument::OPTIONAL, \sprintf('Choose a name for your controller class (e.g. <fg=yellow>%sController</>)', Str::asClassName(Str::getRandomTerm())))
->addOption('no-template', null, InputOption::VALUE_NONE, 'Use this option to disable template generation')
->addOption('invokable', 'i', InputOption::VALUE_NONE, 'Use this option to create an invokable controller')
->setHelp($this->getHelpFileContents('MakeController.txt'))
;
$this->configureCommandWithTestsOption($command);
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
$this->usesTwigTemplate = $this->isTwigInstalled() && !$input->getOption('no-template');
$this->isInvokable = (bool) $input->getOption('invokable');
$controllerClass = $input->getArgument('controller-class');
$controllerClassName = \sprintf('Controller\%s', $controllerClass);
// If the class name provided is absolute, we do not assume it will live in src/Controller
// e.g. src/Custom/Location/For/MyController instead of src/Controller/MyController
if ($isAbsoluteNamespace = '\\' === $controllerClass[0]) {
$controllerClassName = substr($controllerClass, 1);
}
$this->controllerClassData = ClassData::create(
class: $controllerClassName,
suffix: 'Controller',
extendsClass: AbstractController::class,
useStatements: [
$this->usesTwigTemplate ? Response::class : JsonResponse::class,
Route::class,
]
);
// Again if the class name is absolute, lets not make assumptions about where the Twig template
// should live. E.g. templates/custom/location/for/my_controller.html.twig instead of
// templates/my/controller.html.twig. We do however remove the root_namespace prefix in either case
// so we don't end up with templates/app/my/controller.html.twig
$templateName = $isAbsoluteNamespace ?
$this->controllerClassData->getFullClassName(withoutRootNamespace: true, withoutSuffix: true) :
$this->controllerClassData->getClassName(relative: true, withoutSuffix: true)
;
// Convert the Twig template name into a file path where it will be generated.
$this->twigTemplatePath = \sprintf('%s%s', Str::asFilePath($templateName), $this->isInvokable ? '.html.twig' : '/index.html.twig');
$this->interactSetGenerateTests($input, $io);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$controllerPath = $generator->generateClassFromClassData($this->controllerClassData, 'controller/Controller.tpl.php', [
'route_path' => Str::asRoutePath($this->controllerClassData->getClassName(relative: true, withoutSuffix: true)),
'route_name' => Str::AsRouteName($this->controllerClassData->getClassName(relative: true, withoutSuffix: true)),
'method_name' => $this->isInvokable ? '__invoke' : 'index',
'with_template' => $this->usesTwigTemplate,
'template_name' => $this->twigTemplatePath,
], true);
if ($this->usesTwigTemplate) {
$generator->generateTemplate(
$this->twigTemplatePath,
'controller/twig_template.tpl.php',
[
'controller_path' => $controllerPath,
'root_directory' => $generator->getRootDirectory(),
'class_name' => $this->controllerClassData->getClassName(),
]
);
}
if ($this->shouldGenerateTests()) {
$testClassData = ClassData::create(
class: \sprintf('Tests\Controller\%s', $this->controllerClassData->getClassName(relative: true, withoutSuffix: true)),
suffix: 'ControllerTest',
extendsClass: WebTestCase::class,
);
$generator->generateClassFromClassData($testClassData, 'controller/test/Test.tpl.php', [
'route_path' => Str::asRoutePath($this->controllerClassData->getClassName(relative: true, withoutSuffix: true)),
]);
if (!class_exists(WebTestCase::class)) {
$io->caution('You\'ll need to install the `symfony/test-pack` to execute the tests for your new controller.');
}
}
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text('Next: Open your new controller class and add some pages!');
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
}
private function isTwigInstalled(): bool
{
return class_exists(TwigBundle::class);
}
}

View File

@@ -0,0 +1,327 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Inflector\Inflector;
use Doctrine\Inflector\InflectorFactory;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\Common\CanGenerateTestsTrait;
use Symfony\Bundle\MakerBundle\Renderer\FormTypeRenderer;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManager;
use Symfony\Component\Validator\Validation;
/**
* @author Sadicov Vladimir <sadikoff@gmail.com>
*/
final class MakeCrud extends AbstractMaker
{
use CanGenerateTestsTrait;
private Inflector $inflector;
private string $controllerClassName;
private bool $generateTests = false;
public function __construct(private DoctrineHelper $doctrineHelper, private FormTypeRenderer $formTypeRenderer)
{
$this->inflector = InflectorFactory::create()->build();
}
public static function getCommandName(): string
{
return 'make:crud';
}
public static function getCommandDescription(): string
{
return 'Create CRUD for Doctrine entity class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('entity-class', InputArgument::OPTIONAL, \sprintf('The class name of the entity to create CRUD (e.g. <fg=yellow>%s</>)', Str::asClassName(Str::getRandomTerm())))
->setHelp($this->getHelpFileContents('MakeCrud.txt'))
;
$inputConfig->setArgumentAsNonInteractive('entity-class');
$this->configureCommandWithTestsOption($command);
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('entity-class')) {
$argument = $command->getDefinition()->getArgument('entity-class');
$entities = $this->doctrineHelper->getEntitiesForAutocomplete();
$question = new Question($argument->getDescription());
$question->setAutocompleterValues($entities);
$value = $io->askQuestion($question);
$input->setArgument('entity-class', $value);
}
$defaultControllerClass = Str::asClassName(\sprintf('%s Controller', $input->getArgument('entity-class')));
$this->controllerClassName = $io->ask(
\sprintf('Choose a name for your controller class (e.g. <fg=yellow>%s</>)', $defaultControllerClass),
$defaultControllerClass
);
$this->interactSetGenerateTests($input, $io);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$entityClassDetails = $generator->createClassNameDetails(
Validator::entityExists($input->getArgument('entity-class'), $this->doctrineHelper->getEntitiesForAutocomplete()),
'Entity\\'
);
$entityDoctrineDetails = $this->doctrineHelper->createDoctrineDetails($entityClassDetails->getFullName());
$repositoryVars = [];
$repositoryClassName = EntityManagerInterface::class;
if (null !== $entityDoctrineDetails->getRepositoryClass()) {
$repositoryClassDetails = $generator->createClassNameDetails(
'\\'.$entityDoctrineDetails->getRepositoryClass(),
'Repository\\',
'Repository'
);
$repositoryClassName = $repositoryClassDetails->getFullName();
$repositoryVars = [
'repository_full_class_name' => $repositoryClassName,
'repository_class_name' => $repositoryClassDetails->getShortName(),
'repository_var' => lcfirst($this->inflector->singularize($repositoryClassDetails->getShortName())),
];
}
$controllerClassDetails = $generator->createClassNameDetails(
$this->controllerClassName,
'Controller\\',
'Controller'
);
$iter = 0;
do {
$formClassDetails = $generator->createClassNameDetails(
$entityClassDetails->getRelativeNameWithoutSuffix().($iter ?: '').'Type',
'Form\\',
'Type'
);
++$iter;
} while (class_exists($formClassDetails->getFullName()));
$controllerClassData = ClassData::create(
class: \sprintf('Controller\%s', $this->controllerClassName),
suffix: 'Controller',
extendsClass: AbstractController::class,
useStatements: [
$entityClassDetails->getFullName(),
$formClassDetails->getFullName(),
$repositoryClassName,
AbstractController::class,
Request::class,
Response::class,
Route::class,
],
);
$entityVarPlural = lcfirst($this->inflector->pluralize($entityClassDetails->getShortName()));
$entityVarSingular = lcfirst($this->inflector->singularize($entityClassDetails->getShortName()));
$entityTwigVarPlural = Str::asTwigVariable($entityVarPlural);
$entityTwigVarSingular = Str::asTwigVariable($entityVarSingular);
$routeName = Str::asRouteName($controllerClassDetails->getRelativeNameWithoutSuffix());
$templatesPath = Str::asFilePath($controllerClassDetails->getRelativeNameWithoutSuffix());
if (EntityManagerInterface::class !== $repositoryClassName) {
$controllerClassData->addUseStatement(EntityManagerInterface::class);
}
$generator->generateController(
$controllerClassData->getFullClassName(),
'crud/controller/Controller.tpl.php',
array_merge([
'class_data' => $controllerClassData,
'entity_class_name' => $entityClassDetails->getShortName(),
'form_class_name' => $formClassDetails->getShortName(),
'route_path' => Str::asRoutePath($controllerClassDetails->getRelativeNameWithoutSuffix()),
'route_name' => $routeName,
'templates_path' => $templatesPath,
'entity_var_plural' => $entityVarPlural,
'entity_twig_var_plural' => $entityTwigVarPlural,
'entity_var_singular' => $entityVarSingular,
'entity_twig_var_singular' => $entityTwigVarSingular,
'entity_identifier' => $entityDoctrineDetails->getIdentifier(),
],
$repositoryVars
)
);
$this->formTypeRenderer->render(
$formClassDetails,
$entityDoctrineDetails->getFormFields(),
$entityClassDetails
);
$templates = [
'_delete_form' => [
'route_name' => $routeName,
'entity_twig_var_singular' => $entityTwigVarSingular,
'entity_identifier' => $entityDoctrineDetails->getIdentifier(),
],
'_form' => [],
'edit' => [
'entity_class_name' => $entityClassDetails->getShortName(),
'entity_twig_var_singular' => $entityTwigVarSingular,
'entity_identifier' => $entityDoctrineDetails->getIdentifier(),
'route_name' => $routeName,
'templates_path' => $templatesPath,
],
'index' => [
'entity_class_name' => $entityClassDetails->getShortName(),
'entity_twig_var_plural' => $entityTwigVarPlural,
'entity_twig_var_singular' => $entityTwigVarSingular,
'entity_identifier' => $entityDoctrineDetails->getIdentifier(),
'entity_fields' => $entityDoctrineDetails->getDisplayFields(),
'route_name' => $routeName,
],
'new' => [
'entity_class_name' => $entityClassDetails->getShortName(),
'route_name' => $routeName,
'templates_path' => $templatesPath,
],
'show' => [
'entity_class_name' => $entityClassDetails->getShortName(),
'entity_twig_var_singular' => $entityTwigVarSingular,
'entity_identifier' => $entityDoctrineDetails->getIdentifier(),
'entity_fields' => $entityDoctrineDetails->getDisplayFields(),
'route_name' => $routeName,
'templates_path' => $templatesPath,
],
];
foreach ($templates as $template => $variables) {
$generator->generateTemplate(
$templatesPath.'/'.$template.'.html.twig',
'crud/templates/'.$template.'.tpl.php',
$variables
);
}
if ($this->shouldGenerateTests()) {
$testClassData = ClassData::create(
class: \sprintf('Tests\Controller\%s', $entityClassDetails->getRelativeNameWithoutSuffix()),
suffix: 'ControllerTest',
extendsClass: WebTestCase::class,
useStatements: [
$entityClassDetails->getFullName(),
WebTestCase::class,
KernelBrowser::class,
$repositoryClassName,
EntityRepository::class,
],
);
if (EntityManagerInterface::class !== $repositoryClassName) {
$testClassData->addUseStatement(EntityManagerInterface::class);
}
$generator->generateClass(
$testClassData->getFullClassName(),
'crud/test/Test.EntityManager.tpl.php',
[
'class_data' => $testClassData,
'entity_full_class_name' => $entityClassDetails->getFullName(),
'entity_class_name' => $entityClassDetails->getShortName(),
'entity_var_singular' => $entityVarSingular,
'route_path' => Str::asRoutePath($controllerClassDetails->getRelativeNameWithoutSuffix()),
'route_name' => $routeName,
'form_fields' => $entityDoctrineDetails->getFormFields(),
'repository_class_name' => EntityManagerInterface::class,
'form_field_prefix' => strtolower(Str::asSnakeCase($entityTwigVarSingular)),
]
);
if (!class_exists(WebTestCase::class)) {
$io->caution('You\'ll need to install the `symfony/test-pack` to execute the tests for your new controller.');
}
}
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text(\sprintf('Next: Check your new CRUD by going to <fg=yellow>%s/</>', Str::asRoutePath($controllerClassDetails->getRelativeNameWithoutSuffix())));
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
Route::class,
'router'
);
$dependencies->addClassDependency(
AbstractType::class,
'form'
);
$dependencies->addClassDependency(
Validation::class,
'validator'
);
$dependencies->addClassDependency(
TwigBundle::class,
'twig-bundle'
);
$dependencies->addClassDependency(
DoctrineBundle::class,
'orm'
);
$dependencies->addClassDependency(
CsrfTokenManager::class,
'security-csrf'
);
}
}

View File

@@ -0,0 +1,200 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Docker\DockerDatabaseServices;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\ComposeFileManipulator;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Yaml\Yaml;
/**
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @internal
*/
final class MakeDockerDatabase extends AbstractMaker
{
private string $composeFilePath;
private ?ComposeFileManipulator $composeFileManipulator = null;
/**
* @var ?string type of database selected by the user
*/
private ?string $databaseChoice = null;
/**
* @var string Service identifier to be set in compose.yaml
*/
private string $serviceName = 'database';
/**
* @var string Version set in compose.yaml for the service. e.g. latest
*/
private string $serviceVersion = 'latest';
public function __construct(private FileManager $fileManager)
{
}
public static function getCommandName(): string
{
return 'make:docker:database';
}
public static function getCommandDescription(): string
{
return 'Add a database container to your compose.yaml file';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->setHelp($this->getHelpFileContents('MakeDockerDatabase.txt'))
;
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
$io->section('- Docker Compose Setup-');
$this->composeFileManipulator = new ComposeFileManipulator($this->getComposeFileContents($io));
$io->newLine();
$this->databaseChoice = strtolower($io->choice(
'Which database service will you be creating?',
['MySQL', 'MariaDB', 'Postgres']
));
$io->text([\sprintf(
'For a list of supported versions, check out https://hub.docker.com/_/%s',
$this->databaseChoice
)]);
$this->serviceVersion = $io->ask('What version would you like to use?', DockerDatabaseServices::getSuggestedServiceVersion($this->databaseChoice));
if ($this->composeFileManipulator->serviceExists($this->serviceName)) {
$io->comment(\sprintf('A <fg=yellow>"%s"</> service is already defined.', $this->serviceName));
$io->newLine();
$serviceNameMsg[] = 'If you are using the Symfony Binary, it will expose the connection config for';
$serviceNameMsg[] = 'this service as environment variables. The name of the service determines the';
$serviceNameMsg[] = 'name of those environment variables.';
$serviceNameMsg[] = '';
$serviceNameMsg[] = 'For example, if you name the service <fg=yellow>database_alt</>, the binary will expose a';
$serviceNameMsg[] = '<fg=yellow>DATABASE_ALT_URL</> environment variable.';
$io->text($serviceNameMsg);
$this->serviceName = $io->ask(\sprintf('What name should we call the new %s service? (e.g. <fg=yellow>database</>)', $this->serviceName), null, Validator::notBlank(...));
}
$this->checkForPDOSupport($this->databaseChoice, $io);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$io->newLine();
$service = DockerDatabaseServices::getDatabaseSkeleton($this->databaseChoice, $this->serviceVersion);
$this->composeFileManipulator->addDockerService($this->serviceName, $service);
$this->composeFileManipulator->exposePorts($this->serviceName, DockerDatabaseServices::getDefaultPorts($this->databaseChoice));
$generator->dumpFile($this->composeFilePath, $this->composeFileManipulator->getDataString());
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text(\sprintf('The new <fg=yellow>"%s"</> service is now ready!', $this->serviceName));
$io->newLine();
$ports = DockerDatabaseServices::getDefaultPorts($this->databaseChoice);
$closing[] = 'Next:';
$closing[] = \sprintf(' A) Run <fg=yellow>docker-compose up -d %s</> to start your database container', $this->serviceName);
$closing[] = ' or <fg=yellow>docker-compose up -d</> to start all of them.';
$closing[] = '';
$closing[] = ' B) If you are using the Symfony Binary, it will detect the new service automatically.';
$closing[] = ' Run <fg=yellow>symfony var:export --multiline</> to see the environment variables the binary is exposing.';
$closing[] = ' These will override any values you have in your .env files.';
$closing[] = '';
$closing[] = ' C) Run <fg=yellow>docker-compose stop</> will stop all the containers in compose.yaml.';
$closing[] = ' <fg=yellow>docker-compose down</> will stop and destroy the containers.';
$closing[] = '';
$closing[] = \sprintf(
'Port%s %s will be exposed to %s random port%s on your host machine.',
1 === \count($ports) ? '' : 's',
implode(' ', $ports),
1 === \count($ports) ? 'a' : '',
1 === \count($ports) ? '' : 's'
);
$io->text($closing);
$io->newLine();
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
Yaml::class,
'yaml'
);
}
private function checkForPDOSupport(string $databaseType, ConsoleStyle $io): void
{
$extension = DockerDatabaseServices::getMissingExtensionName($databaseType);
if (null !== $extension) {
$io->note(
\sprintf('Cannot find PHP\'s pdo_%s extension. Be sure it\'s installed & enabled to talk to the database.', $extension)
);
}
}
/**
* Determines and sets the correct Compose File Path and retrieves its contents
* if the file exists else an empty string.
*/
private function getComposeFileContents(ConsoleStyle $io): string
{
$this->composeFilePath = \sprintf('%s/compose.yaml', $this->fileManager->getRootDirectory());
$composeFileExists = false;
$statusMessage = 'Existing compose.yaml not found: a new one will be generated!';
$contents = '';
foreach (['.yml', '.yaml'] as $extension) {
$composeFilePath = \sprintf('%s/compose%s', $this->fileManager->getRootDirectory(), $extension);
if (!$composeFileExists && $this->fileManager->fileExists($composeFilePath)) {
$composeFileExists = true;
$statusMessage = \sprintf('We found your existing compose%s: Let\'s update it!', $extension);
$this->composeFilePath = $composeFilePath;
$contents = $this->fileManager->getFileContents($composeFilePath);
}
}
$io->text($statusMessage);
return $contents;
}
}

View File

@@ -0,0 +1,899 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use ApiPlatform\Metadata\ApiResource;
use Doctrine\DBAL\Types\Type;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator;
use Symfony\Bundle\MakerBundle\Doctrine\EntityRegenerator;
use Symfony\Bundle\MakerBundle\Doctrine\EntityRelation;
use Symfony\Bundle\MakerBundle\Doctrine\ORMDependencyBuilder;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputAwareMakerInterface;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\Common\UidTrait;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassDetails;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassProperty;
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
use Symfony\Bundle\MakerBundle\Util\CliOutputHelper;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Bundle\MercureBundle\DependencyInjection\MercureExtension;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\UX\Turbo\Attribute\Broadcast;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class MakeEntity extends AbstractMaker implements InputAwareMakerInterface
{
use UidTrait;
private Generator $generator;
private EntityClassGenerator $entityClassGenerator;
public function __construct(
private FileManager $fileManager,
private DoctrineHelper $doctrineHelper,
?string $projectDirectory = null,
?Generator $generator = null,
?EntityClassGenerator $entityClassGenerator = null,
) {
if (null !== $projectDirectory) {
@trigger_error('The $projectDirectory constructor argument is no longer used since 1.41.0', \E_USER_DEPRECATED);
}
if (null === $generator) {
@trigger_error(\sprintf('Passing a "%s" instance as 4th argument is mandatory since version 1.5.', Generator::class), \E_USER_DEPRECATED);
$this->generator = new Generator($fileManager, 'App\\');
} else {
$this->generator = $generator;
}
if (null === $entityClassGenerator) {
@trigger_error(\sprintf('Passing a "%s" instance as 5th argument is mandatory since version 1.15.1', EntityClassGenerator::class), \E_USER_DEPRECATED);
$this->entityClassGenerator = new EntityClassGenerator($generator, $this->doctrineHelper);
} else {
$this->entityClassGenerator = $entityClassGenerator;
}
}
public static function getCommandName(): string
{
return 'make:entity';
}
public static function getCommandDescription(): string
{
return 'Create or update a Doctrine entity class, and optionally an API Platform resource';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, \sprintf('Class name of the entity to create or update (e.g. <fg=yellow>%s</>)', Str::asClassName(Str::getRandomTerm())))
->addOption('api-resource', 'a', InputOption::VALUE_NONE, 'Mark this class as an API Platform resource (expose a CRUD API for it)')
->addOption('broadcast', 'b', InputOption::VALUE_NONE, 'Add the ability to broadcast entity updates using Symfony UX Turbo?')
->addOption('regenerate', null, InputOption::VALUE_NONE, 'Instead of adding new fields, simply generate the methods (e.g. getter/setter) for existing fields')
->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite any existing getter/setter methods')
->setHelp($this->getHelpFileContents('MakeEntity.txt'))
;
$this->addWithUuidOption($command);
$inputConfig->setArgumentAsNonInteractive('name');
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (($entityClassName = $input->getArgument('name')) && empty($this->verifyEntityName($entityClassName))) {
return;
}
if ($input->getOption('regenerate')) {
$io->block([
'This command will generate any missing methods (e.g. getters & setters) for a class or all classes in a namespace.',
'To overwrite any existing methods, re-run this command with the --overwrite flag',
], null, 'fg=yellow');
$classOrNamespace = $io->ask('Enter a class or namespace to regenerate', $this->getEntityNamespace(), Validator::notBlank(...));
$input->setArgument('name', $classOrNamespace);
return;
}
$this->checkIsUsingUid($input);
$argument = $command->getDefinition()->getArgument('name');
$question = $this->createEntityClassQuestion($argument->getDescription());
$entityClassName ??= $io->askQuestion($question);
while ($dangerous = $this->verifyEntityName($entityClassName)) {
if ($io->confirm(\sprintf('"%s" contains one or more non-ASCII characters, which are potentially problematic with some database. It is recommended to use only ASCII characters for entity names. Continue anyway?', $entityClassName), false)) {
break;
}
$entityClassName = $io->askQuestion($question);
}
$input->setArgument('name', $entityClassName);
if (
!$input->getOption('api-resource')
&& class_exists(ApiResource::class)
&& !class_exists($this->generator->createClassNameDetails($entityClassName, 'Entity\\')->getFullName())
) {
$description = $command->getDefinition()->getOption('api-resource')->getDescription();
$question = new ConfirmationQuestion($description, false);
$isApiResource = $io->askQuestion($question);
$input->setOption('api-resource', $isApiResource);
}
if (
!$input->getOption('broadcast')
&& class_exists(Broadcast::class)
&& !class_exists($this->generator->createClassNameDetails($entityClassName, 'Entity\\')->getFullName())
) {
$description = $command->getDefinition()->getOption('broadcast')->getDescription();
$question = new ConfirmationQuestion($description, false);
$isBroadcast = $io->askQuestion($question);
// Mercure is needed
if ($isBroadcast && !class_exists(MercureExtension::class)) {
throw new RuntimeCommandException('Please run "composer require symfony/mercure-bundle". It is needed to broadcast entities.');
}
$input->setOption('broadcast', $isBroadcast);
}
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$overwrite = $input->getOption('overwrite');
// the regenerate option has entirely custom behavior
if ($input->getOption('regenerate')) {
$this->regenerateEntities($input->getArgument('name'), $overwrite, $generator);
$this->writeSuccessMessage($io);
return;
}
$entityClassDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Entity\\'
);
$classExists = class_exists($entityClassDetails->getFullName());
if (!$classExists) {
$broadcast = $input->getOption('broadcast');
$entityPath = $this->entityClassGenerator->generateEntityClass(
entityClassDetails: $entityClassDetails,
apiResource: $input->getOption('api-resource'),
broadcast: $broadcast,
useUuidIdentifier: $this->getIdType(),
);
if ($broadcast) {
$shortName = $entityClassDetails->getShortName();
$generator->generateTemplate(
\sprintf('broadcast/%s.stream.html.twig', $shortName),
'doctrine/broadcast_twig_template.tpl.php',
[
'class_name' => Str::asSnakeCase($shortName),
'class_name_plural' => Str::asSnakeCase(Str::singularCamelCaseToPluralCamelCase($shortName)),
]
);
}
$generator->writeChanges();
}
if ($classExists) {
$entityPath = $this->getPathOfClass($entityClassDetails->getFullName());
$io->text([
'Your entity already exists! So let\'s add some new fields!',
]);
} else {
$io->text([
'',
'Entity generated! Now let\'s add some fields!',
'You can always add more fields later manually or by re-running this command.',
]);
}
$currentFields = $this->getPropertyNames($entityClassDetails->getFullName());
$manipulator = $this->createClassManipulator($entityPath, $io, $overwrite);
$isFirstField = true;
while (true) {
$newField = $this->askForNextField($io, $currentFields, $entityClassDetails->getFullName(), $isFirstField);
$isFirstField = false;
if (null === $newField) {
break;
}
$fileManagerOperations = [];
$fileManagerOperations[$entityPath] = $manipulator;
if ($newField instanceof ClassProperty) {
$manipulator->addEntityField($newField);
$currentFields[] = $newField->propertyName;
} elseif ($newField instanceof EntityRelation) {
// both overridden below for OneToMany
$newFieldName = $newField->getOwningProperty();
if ($newField->isSelfReferencing()) {
$otherManipulatorFilename = $entityPath;
$otherManipulator = $manipulator;
} else {
$otherManipulatorFilename = $this->getPathOfClass($newField->getInverseClass());
$otherManipulator = $this->createClassManipulator($otherManipulatorFilename, $io, $overwrite);
}
switch ($newField->getType()) {
case EntityRelation::MANY_TO_ONE:
if ($newField->getOwningClass() === $entityClassDetails->getFullName()) {
// THIS class will receive the ManyToOne
$manipulator->addManyToOneRelation($newField->getOwningRelation());
if ($newField->getMapInverseRelation()) {
$otherManipulator->addOneToManyRelation($newField->getInverseRelation());
}
} else {
// the new field being added to THIS entity is the inverse
$newFieldName = $newField->getInverseProperty();
$otherManipulatorFilename = $this->getPathOfClass($newField->getOwningClass());
$otherManipulator = $this->createClassManipulator($otherManipulatorFilename, $io, $overwrite);
// The *other* class will receive the ManyToOne
$otherManipulator->addManyToOneRelation($newField->getOwningRelation());
if (!$newField->getMapInverseRelation()) {
throw new \Exception('Somehow a OneToMany relationship is being created, but the inverse side will not be mapped?');
}
$manipulator->addOneToManyRelation($newField->getInverseRelation());
}
break;
case EntityRelation::MANY_TO_MANY:
$manipulator->addManyToManyRelation($newField->getOwningRelation());
if ($newField->getMapInverseRelation()) {
$otherManipulator->addManyToManyRelation($newField->getInverseRelation());
}
break;
case EntityRelation::ONE_TO_ONE:
$manipulator->addOneToOneRelation($newField->getOwningRelation());
if ($newField->getMapInverseRelation()) {
$otherManipulator->addOneToOneRelation($newField->getInverseRelation());
}
break;
default:
throw new \Exception('Invalid relation type');
}
// save the inverse side if it's being mapped
if ($newField->getMapInverseRelation()) {
$fileManagerOperations[$otherManipulatorFilename] = $otherManipulator;
}
$currentFields[] = $newFieldName;
} else {
throw new \Exception('Invalid value');
}
foreach ($fileManagerOperations as $path => $manipulatorOrMessage) {
if (\is_string($manipulatorOrMessage)) { /* @phpstan-ignore-line - https://github.com/symfony/maker-bundle/issues/1509 */
$io->comment($manipulatorOrMessage);
} else {
$this->fileManager->dumpFile($path, $manipulatorOrMessage->getSourceCode());
}
}
}
$this->writeSuccessMessage($io);
$io->text([
\sprintf('Next: When you\'re ready, create a migration with <info>%s make:migration</info>', CliOutputHelper::getCommandPrefix()),
'',
]);
}
public function configureDependencies(DependencyBuilder $dependencies, ?InputInterface $input = null): void
{
if (null !== $input && $input->getOption('api-resource')) {
$dependencies->addClassDependency(
ApiResource::class,
'api'
);
}
if (null !== $input && $input->getOption('broadcast')) {
$dependencies->addClassDependency(
Broadcast::class,
'symfony/ux-turbo'
);
}
ORMDependencyBuilder::buildDependencies($dependencies);
}
/** @param string[] $fields */
private function askForNextField(ConsoleStyle $io, array $fields, string $entityClass, bool $isFirstField): EntityRelation|ClassProperty|null
{
$io->writeln('');
if ($isFirstField) {
$questionText = 'New property name (press <return> to stop adding fields)';
} else {
$questionText = 'Add another property? Enter the property name (or press <return> to stop adding fields)';
}
$fieldName = $io->ask($questionText, null, function ($name) use ($fields) {
// allow it to be empty
if (!$name) {
return $name;
}
if (\in_array($name, $fields)) {
throw new \InvalidArgumentException(\sprintf('The "%s" property already exists.', $name));
}
return Validator::validateDoctrineFieldName($name, $this->doctrineHelper->getRegistry());
});
if (!$fieldName) {
return null;
}
$defaultType = 'string';
// try to guess the type by the field name prefix/suffix
// convert to snake case for simplicity
$snakeCasedField = Str::asSnakeCase($fieldName);
if ('_at' === $suffix = substr($snakeCasedField, -3)) {
$defaultType = 'datetime_immutable';
} elseif ('_id' === $suffix) {
$defaultType = 'integer';
} elseif (str_starts_with($snakeCasedField, 'is_')) {
$defaultType = 'boolean';
} elseif (str_starts_with($snakeCasedField, 'has_')) {
$defaultType = 'boolean';
} elseif ('uuid' === $snakeCasedField) {
$defaultType = Type::hasType('uuid') ? 'uuid' : 'guid';
} elseif ('guid' === $snakeCasedField) {
$defaultType = 'guid';
}
$type = null;
$types = $this->getTypesMap();
$allValidTypes = array_merge(
array_keys($types),
EntityRelation::getValidRelationTypes(),
['relation', 'enum']
);
while (null === $type) {
$question = new Question('Field type (enter <comment>?</comment> to see all types)', $defaultType);
$question->setAutocompleterValues($allValidTypes);
$type = $io->askQuestion($question);
if ('?' === $type) {
$this->printAvailableTypes($io);
$io->writeln('');
$type = null;
} elseif (!\in_array($type, $allValidTypes)) {
$this->printAvailableTypes($io);
$io->error(\sprintf('Invalid type "%s".', $type));
$io->writeln('');
$type = null;
}
}
if ('relation' === $type || \in_array($type, EntityRelation::getValidRelationTypes())) {
return $this->askRelationDetails($io, $entityClass, $type, $fieldName);
}
// this is a normal field
$classProperty = new ClassProperty(propertyName: $fieldName, type: $type);
if ('string' === $type) {
// default to 255, avoid the question
$classProperty->length = $io->ask('Field length', '255', Validator::validateLength(...));
} elseif ('decimal' === $type) {
// 10 is the default value given in \Doctrine\DBAL\Schema\Column::$_precision
$classProperty->precision = $io->ask('Precision (total number of digits stored: 100.00 would be 5)', '10', Validator::validatePrecision(...));
// 0 is the default value given in \Doctrine\DBAL\Schema\Column::$_scale
$classProperty->scale = $io->ask('Scale (number of decimals to store: 100.00 would be 2)', '0', Validator::validateScale(...));
} elseif ('enum' === $type) {
// ask for valid backed enum class
$classProperty->enumType = $io->ask('Enum class', null, Validator::classIsBackedEnum(...));
// set type according to user decision
$classProperty->type = $io->confirm('Can this field store multiple enum values', false) ? 'simple_array' : 'string';
}
if ($io->confirm('Can this field be null in the database (nullable)', false)) {
$classProperty->nullable = true;
}
return $classProperty;
}
private function printAvailableTypes(ConsoleStyle $io): void
{
$allTypes = $this->getTypesMap();
$typesTable = [
'main' => [
'string' => ['ascii_string'],
'text' => [],
'boolean' => [],
'integer' => ['smallint', 'bigint'],
'float' => [],
],
'array_object' => [
'array' => ['simple_array'],
'json' => [],
'object' => [],
'binary' => [],
'blob' => [],
],
'date_time' => [
'datetime' => ['datetime_immutable'],
'datetimetz' => ['datetimetz_immutable'],
'date' => ['date_immutable'],
'time' => ['time_immutable'],
'dateinterval' => [],
],
'other' => [
'enum' => [],
],
];
$printSection = static function (array $sectionTypes) use ($io, &$allTypes) {
foreach ($sectionTypes as $mainType => $subTypes) {
if (!\array_key_exists($mainType, $allTypes)) {
// The type is not a valid DBAL Type - don't show it as an option
continue;
}
foreach ($subTypes as $key => $potentialType) {
if (!\array_key_exists($potentialType, $allTypes)) {
// The type is not a valid DBAL Type - don't show it as an "or" option
unset($subTypes[$key]);
}
// Remove type as not to show it again in "Other Types"
unset($allTypes[$potentialType]);
}
// Remove type as not to show it again in "Other Types"
unset($allTypes[$mainType]);
$line = \sprintf(' * <comment>%s</comment>', $mainType);
if (!empty($subTypes)) {
$line .= \sprintf(' or %s', implode(' or ', array_map(
static fn ($subType) => \sprintf('<comment>%s</comment>', $subType), $subTypes))
);
}
$io->writeln($line);
}
$io->writeln('');
};
$printRelationsSection = static function () use ($io) {
if ('Hyper' === getenv('TERM_PROGRAM')) {
$wizard = 'wizard 🧙';
} else {
$wizard = '\\' === \DIRECTORY_SEPARATOR ? 'wizard' : 'wizard 🧙';
}
$io->writeln(\sprintf(' * <comment>relation</comment> a %s will help you build the relation', $wizard));
$relations = [EntityRelation::MANY_TO_ONE, EntityRelation::ONE_TO_MANY, EntityRelation::MANY_TO_MANY, EntityRelation::ONE_TO_ONE];
foreach ($relations as $relation) {
$line = \sprintf(' * <comment>%s</comment>', $relation);
$io->writeln($line);
}
$io->writeln('');
};
$io->writeln('<info>Main Types</info>');
$printSection($typesTable['main']);
$io->writeln('<info>Relationships/Associations</info>');
$printRelationsSection();
$io->writeln('<info>Array/Object Types</info>');
$printSection($typesTable['array_object']);
$io->writeln('<info>Date/Time Types</info>');
$printSection($typesTable['date_time']);
$io->writeln('<info>Other Types</info>');
// empty the values
$allTypes = array_map(static fn () => [], $allTypes);
$allTypes = [...$typesTable['other'], ...$allTypes];
$printSection($allTypes);
}
private function createEntityClassQuestion(string $questionText): Question
{
$question = new Question($questionText);
$question->setValidator(Validator::notBlank(...));
$question->setAutocompleterValues($this->doctrineHelper->getEntitiesForAutocomplete());
return $question;
}
private function askRelationDetails(ConsoleStyle $io, string $generatedEntityClass, string $type, string $newFieldName): EntityRelation
{
// ask the targetEntity
$targetEntityClass = null;
while (null === $targetEntityClass) {
$question = $this->createEntityClassQuestion('What class should this entity be related to?');
$answeredEntityClass = $io->askQuestion($question);
// find the correct class name - but give priority over looking
// in the Entity namespace versus just checking the full class
// name to avoid issues with classes like "Directory" that exist
// in PHP's core.
if (class_exists($this->getEntityNamespace().'\\'.$answeredEntityClass)) {
$targetEntityClass = $this->getEntityNamespace().'\\'.$answeredEntityClass;
} elseif (class_exists($answeredEntityClass)) {
$targetEntityClass = $answeredEntityClass;
} else {
$io->error(\sprintf('Unknown class "%s"', $answeredEntityClass));
}
}
// help the user select the type
if ('relation' === $type) {
$type = $this->askRelationType($io, $generatedEntityClass, $targetEntityClass);
}
$askFieldName = fn (string $targetClass, string $defaultValue) => $io->ask(
\sprintf('New field name inside %s', Str::getShortClassName($targetClass)),
$defaultValue,
function ($name) use ($targetClass) {
// it's still *possible* to create duplicate properties - by
// trying to generate the same property 2 times during the
// same make:entity run. property_exists() only knows about
// properties that *originally* existed on this class.
if (property_exists($targetClass, $name)) {
throw new \InvalidArgumentException(\sprintf('The "%s" class already has a "%s" property.', $targetClass, $name));
}
return Validator::validateDoctrineFieldName($name, $this->doctrineHelper->getRegistry());
}
);
$askIsNullable = static fn (string $propertyName, string $targetClass) => $io->confirm(\sprintf(
'Is the <comment>%s</comment>.<comment>%s</comment> property allowed to be null (nullable)?',
Str::getShortClassName($targetClass),
$propertyName
));
$askOrphanRemoval = static function (string $owningClass, string $inverseClass) use ($io) {
$io->text([
'Do you want to activate <comment>orphanRemoval</comment> on your relationship?',
\sprintf(
'A <comment>%s</comment> is "orphaned" when it is removed from its related <comment>%s</comment>.',
Str::getShortClassName($owningClass),
Str::getShortClassName($inverseClass)
),
\sprintf(
'e.g. <comment>$%s->remove%s($%s)</comment>',
Str::asLowerCamelCase(Str::getShortClassName($inverseClass)),
Str::asCamelCase(Str::getShortClassName($owningClass)),
Str::asLowerCamelCase(Str::getShortClassName($owningClass))
),
'',
\sprintf(
'NOTE: If a <comment>%s</comment> may *change* from one <comment>%s</comment> to another, answer "no".',
Str::getShortClassName($owningClass),
Str::getShortClassName($inverseClass)
),
]);
return $io->confirm(\sprintf('Do you want to automatically delete orphaned <comment>%s</comment> objects (orphanRemoval)?', $owningClass), false);
};
$askInverseSide = function (EntityRelation $relation) use ($io) {
if ($this->isClassInVendor($relation->getInverseClass())) {
$relation->setMapInverseRelation(false);
return;
}
// recommend an inverse side, except for OneToOne, where it's inefficient
$recommendMappingInverse = EntityRelation::ONE_TO_ONE !== $relation->getType();
$getterMethodName = 'get'.Str::asCamelCase(Str::getShortClassName($relation->getOwningClass()));
if (EntityRelation::ONE_TO_ONE !== $relation->getType()) {
// pluralize!
$getterMethodName = Str::singularCamelCaseToPluralCamelCase($getterMethodName);
}
$mapInverse = $io->confirm(
\sprintf(
'Do you want to add a new property to <comment>%s</comment> so that you can access/update <comment>%s</comment> objects from it - e.g. <comment>$%s->%s()</comment>?',
Str::getShortClassName($relation->getInverseClass()),
Str::getShortClassName($relation->getOwningClass()),
Str::asLowerCamelCase(Str::getShortClassName($relation->getInverseClass())),
$getterMethodName
),
$recommendMappingInverse
);
$relation->setMapInverseRelation($mapInverse);
};
switch ($type) {
case EntityRelation::MANY_TO_ONE:
$relation = new EntityRelation(
EntityRelation::MANY_TO_ONE,
$generatedEntityClass,
$targetEntityClass
);
$relation->setOwningProperty($newFieldName);
$relation->setIsNullable($askIsNullable(
$relation->getOwningProperty(),
$relation->getOwningClass()
));
$askInverseSide($relation);
if ($relation->getMapInverseRelation()) {
$io->comment(\sprintf(
'A new property will also be added to the <comment>%s</comment> class so that you can access the related <comment>%s</comment> objects from it.',
Str::getShortClassName($relation->getInverseClass()),
Str::getShortClassName($relation->getOwningClass())
));
$relation->setInverseProperty($askFieldName(
$relation->getInverseClass(),
Str::singularCamelCaseToPluralCamelCase(Str::getShortClassName($relation->getOwningClass()))
));
// orphan removal only applies if the inverse relation is set
if (!$relation->isNullable()) {
$relation->setOrphanRemoval($askOrphanRemoval(
$relation->getOwningClass(),
$relation->getInverseClass()
));
}
}
break;
case EntityRelation::ONE_TO_MANY:
// we *actually* create a ManyToOne, but populate it differently
$relation = new EntityRelation(
EntityRelation::MANY_TO_ONE,
$targetEntityClass,
$generatedEntityClass
);
$relation->setInverseProperty($newFieldName);
$io->comment(\sprintf(
'A new property will also be added to the <comment>%s</comment> class so that you can access and set the related <comment>%s</comment> object from it.',
Str::getShortClassName($relation->getOwningClass()),
Str::getShortClassName($relation->getInverseClass())
));
$relation->setOwningProperty($askFieldName(
$relation->getOwningClass(),
Str::asLowerCamelCase(Str::getShortClassName($relation->getInverseClass()))
));
$relation->setIsNullable($askIsNullable(
$relation->getOwningProperty(),
$relation->getOwningClass()
));
if (!$relation->isNullable()) {
$relation->setOrphanRemoval($askOrphanRemoval(
$relation->getOwningClass(),
$relation->getInverseClass()
));
}
break;
case EntityRelation::MANY_TO_MANY:
$relation = new EntityRelation(
EntityRelation::MANY_TO_MANY,
$generatedEntityClass,
$targetEntityClass
);
$relation->setOwningProperty($newFieldName);
$askInverseSide($relation);
if ($relation->getMapInverseRelation()) {
$io->comment(\sprintf(
'A new property will also be added to the <comment>%s</comment> class so that you can access the related <comment>%s</comment> objects from it.',
Str::getShortClassName($relation->getInverseClass()),
Str::getShortClassName($relation->getOwningClass())
));
$relation->setInverseProperty($askFieldName(
$relation->getInverseClass(),
Str::singularCamelCaseToPluralCamelCase(Str::getShortClassName($relation->getOwningClass()))
));
}
break;
case EntityRelation::ONE_TO_ONE:
$relation = new EntityRelation(
EntityRelation::ONE_TO_ONE,
$generatedEntityClass,
$targetEntityClass
);
$relation->setOwningProperty($newFieldName);
$relation->setIsNullable($askIsNullable(
$relation->getOwningProperty(),
$relation->getOwningClass()
));
$askInverseSide($relation);
if ($relation->getMapInverseRelation()) {
$io->comment(\sprintf(
'A new property will also be added to the <comment>%s</comment> class so that you can access the related <comment>%s</comment> object from it.',
Str::getShortClassName($relation->getInverseClass()),
Str::getShortClassName($relation->getOwningClass())
));
$relation->setInverseProperty($askFieldName(
$relation->getInverseClass(),
Str::asLowerCamelCase(Str::getShortClassName($relation->getOwningClass()))
));
}
break;
default:
throw new \InvalidArgumentException('Invalid type: '.$type);
}
return $relation;
}
private function askRelationType(ConsoleStyle $io, string $entityClass, string $targetEntityClass): string
{
$io->writeln('What type of relationship is this?');
$originalEntityShort = Str::getShortClassName($entityClass);
$targetEntityShort = Str::getShortClassName($targetEntityClass);
if ($originalEntityShort === $targetEntityShort) {
[$originalDiscriminator, $targetDiscriminator] = Str::getHumanDiscriminatorBetweenTwoClasses($entityClass, $targetEntityClass);
$originalEntityShort = trim($originalDiscriminator.'\\'.$originalEntityShort, '\\');
$targetEntityShort = trim($targetDiscriminator.'\\'.$targetEntityShort, '\\');
}
$rows = [];
$rows[] = [
EntityRelation::MANY_TO_ONE,
\sprintf("Each <comment>%s</comment> relates to (has) <info>one</info> <comment>%s</comment>.\nEach <comment>%s</comment> can relate to (can have) <info>many</info> <comment>%s</comment> objects.", $originalEntityShort, $targetEntityShort, $targetEntityShort, $originalEntityShort),
];
$rows[] = ['', ''];
$rows[] = [
EntityRelation::ONE_TO_MANY,
\sprintf("Each <comment>%s</comment> can relate to (can have) <info>many</info> <comment>%s</comment> objects.\nEach <comment>%s</comment> relates to (has) <info>one</info> <comment>%s</comment>.", $originalEntityShort, $targetEntityShort, $targetEntityShort, $originalEntityShort),
];
$rows[] = ['', ''];
$rows[] = [
EntityRelation::MANY_TO_MANY,
\sprintf("Each <comment>%s</comment> can relate to (can have) <info>many</info> <comment>%s</comment> objects.\nEach <comment>%s</comment> can also relate to (can also have) <info>many</info> <comment>%s</comment> objects.", $originalEntityShort, $targetEntityShort, $targetEntityShort, $originalEntityShort),
];
$rows[] = ['', ''];
$rows[] = [
EntityRelation::ONE_TO_ONE,
\sprintf("Each <comment>%s</comment> relates to (has) exactly <info>one</info> <comment>%s</comment>.\nEach <comment>%s</comment> also relates to (has) exactly <info>one</info> <comment>%s</comment>.", $originalEntityShort, $targetEntityShort, $targetEntityShort, $originalEntityShort),
];
$io->table([
'Type',
'Description',
], $rows);
$question = new Question(\sprintf(
'Relation type? [%s]',
implode(', ', EntityRelation::getValidRelationTypes())
));
$question->setAutocompleterValues(EntityRelation::getValidRelationTypes());
$question->setValidator(function ($type) {
if (!\in_array($type, EntityRelation::getValidRelationTypes())) {
throw new \InvalidArgumentException(\sprintf('Invalid type: use one of: %s', implode(', ', EntityRelation::getValidRelationTypes())));
}
return $type;
});
return $io->askQuestion($question);
}
/** @return string[] */
private function verifyEntityName(string $entityName): array
{
preg_match('/([^\x00-\x7F]+)/u', $entityName, $matches);
return $matches;
}
private function createClassManipulator(string $path, ConsoleStyle $io, bool $overwrite): ClassSourceManipulator
{
$manipulator = new ClassSourceManipulator(
sourceCode: $this->fileManager->getFileContents($path),
overwrite: $overwrite,
);
$manipulator->setIo($io);
return $manipulator;
}
private function getPathOfClass(string $class): string
{
return (new ClassDetails($class))->getPath();
}
private function isClassInVendor(string $class): bool
{
$path = $this->getPathOfClass($class);
return $this->fileManager->isPathInVendor($path);
}
private function regenerateEntities(string $classOrNamespace, bool $overwrite, Generator $generator): void
{
$regenerator = new EntityRegenerator($this->doctrineHelper, $this->fileManager, $generator, $this->entityClassGenerator, $overwrite);
$regenerator->regenerateEntities($classOrNamespace);
}
/** @return string[] */
private function getPropertyNames(string $class): array
{
if (!class_exists($class)) {
return [];
}
$reflClass = new \ReflectionClass($class);
return array_map(static fn (\ReflectionProperty $prop) => $prop->getName(), $reflClass->getProperties());
}
private function getEntityNamespace(): string
{
return $this->doctrineHelper->getEntityNamespace();
}
/** @return string[] */
private function getTypesMap(): array
{
return Type::getTypesMap();
}
}

View File

@@ -0,0 +1,97 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\ORM\Mapping\Column;
use Doctrine\Persistence\ObjectManager;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeFixtures extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:fixtures';
}
public static function getCommandDescription(): string
{
return 'Create a new class to load Doctrine fixtures';
}
/** @return void */
public function configureCommand(Command $command, InputConfiguration $inputConf)
{
$command
->addArgument('fixtures-class', InputArgument::OPTIONAL, 'The class name of the fixtures to create (e.g. <fg=yellow>AppFixtures</>)')
->setHelp($this->getHelpFileContents('MakeFixture.txt'))
;
}
/** @return void */
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator)
{
$fixturesClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('fixtures-class'),
'DataFixtures\\'
);
$useStatements = new UseStatementGenerator([
Fixture::class,
ObjectManager::class,
]);
$generator->generateClass(
$fixturesClassNameDetails->getFullName(),
'doctrine/Fixtures.tpl.php',
[
'use_statements' => $useStatements,
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new fixtures class and start customizing it.',
\sprintf('Load your fixtures by running: <comment>php %s doctrine:fixtures:load</comment>', $_SERVER['PHP_SELF']),
'Docs: <fg=yellow>https://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html</>',
]);
}
/** @return void */
public function configureDependencies(DependencyBuilder $dependencies)
{
$dependencies->addClassDependency(
Column::class,
'doctrine'
);
$dependencies->addClassDependency(
Fixture::class,
'orm-fixtures',
true,
true
);
}
}

View File

@@ -0,0 +1,144 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Renderer\FormTypeRenderer;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassDetails;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Validator\Validation;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeForm extends AbstractMaker
{
public function __construct(private DoctrineHelper $entityHelper, private FormTypeRenderer $formTypeRenderer)
{
}
public static function getCommandName(): string
{
return 'make:form';
}
public static function getCommandDescription(): string
{
return 'Create a new form class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, \sprintf('The name of the form class (e.g. <fg=yellow>%sType</>)', Str::asClassName(Str::getRandomTerm())))
->addArgument('bound-class', InputArgument::OPTIONAL, 'The name of Entity or fully qualified model class name that the new form will be bound to (empty for none)')
->setHelp($this->getHelpFileContents('MakeForm.txt'))
;
$inputConfig->setArgumentAsNonInteractive('bound-class');
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (null === $input->getArgument('bound-class')) {
$argument = $command->getDefinition()->getArgument('bound-class');
$entities = $this->entityHelper->getEntitiesForAutocomplete();
$question = new Question($argument->getDescription());
$question->setValidator(fn ($answer) => Validator::existsOrNull($answer, $entities));
$question->setAutocompleterValues($entities);
$question->setMaxAttempts(3);
$input->setArgument('bound-class', $io->askQuestion($question));
}
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$formClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Form\\',
'Type'
);
$formFields = ['field_name' => null];
$boundClass = $input->getArgument('bound-class');
$boundClassDetails = null;
if (null !== $boundClass) {
$boundClassDetails = $generator->createClassNameDetails(
$boundClass,
'Entity\\'
);
$doctrineEntityDetails = $this->entityHelper->createDoctrineDetails($boundClassDetails->getFullName());
if (null !== $doctrineEntityDetails) {
$formFields = $doctrineEntityDetails->getFormFields();
} else {
$classDetails = new ClassDetails($boundClassDetails->getFullName());
$formFields = $classDetails->getFormFields();
}
}
$this->formTypeRenderer->render(
$formClassNameDetails,
$formFields,
$boundClassDetails
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Add fields to your form and start using it.',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/forms.html</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
AbstractType::class,
// technically only form is needed, but the user will *probably* also want validation
'form'
);
$dependencies->addClassDependency(
Validation::class,
'validator',
// add as an optional dependency: the user *probably* wants validation
false
);
$dependencies->addClassDependency(
DoctrineBundle::class,
'orm',
false
);
}
}

View File

@@ -0,0 +1,106 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\FrameworkBundle\Test\WebTestAssertionsTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\BrowserKit\History;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\CssSelector\CssSelectorConverter;
use Symfony\Component\Panther\PantherTestCase;
use Symfony\Component\Panther\PantherTestCaseTrait;
trigger_deprecation('symfony/maker-bundle', '1.29', 'The "%s" class is deprecated, use "%s" instead.', MakeFunctionalTest::class, MakeTest::class);
/**
* @deprecated since MakerBundle 1.29, use Symfony\Bundle\MakerBundle\Maker\MakeTest instead.
*
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
class MakeFunctionalTest extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:functional-test';
}
public static function getCommandDescription(): string
{
return 'Create a new functional test class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the functional test class (e.g. <fg=yellow>DefaultControllerTest</>)')
->setHelp($this->getHelpFileContents('MakeFunctionalTest.txt'))
;
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$testClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Tests\\',
'Test'
);
$pantherAvailable = trait_exists(PantherTestCaseTrait::class);
$useStatements = new UseStatementGenerator([
$pantherAvailable ? PantherTestCase::class : WebTestCase::class,
]);
$generator->generateClass(
$testClassNameDetails->getFullName(),
'test/Functional.tpl.php',
[
'use_statements' => $useStatements,
'web_assertions_are_available' => trait_exists(WebTestAssertionsTrait::class),
'panther_is_available' => $pantherAvailable,
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new test class and start customizing it.',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/testing.html#functional-tests</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
History::class,
'browser-kit',
true,
true
);
$dependencies->addClassDependency(
CssSelectorConverter::class,
'css-selector',
true,
true
);
}
}

View File

@@ -0,0 +1,264 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\EventRegistry;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Steven Renaux <steven.renaux8000@gmail.com>
*/
final class MakeListener extends AbstractMaker
{
private const ALL_TYPES = ['Listener', 'Subscriber'];
private bool $isSubscriber = false;
public function __construct(private readonly EventRegistry $eventRegistry)
{
}
public static function getCommandName(): string
{
return 'make:listener';
}
/**
* @deprecated remove this method when removing make:subscriber
*/
public static function getCommandAlias(): string
{
return 'make:subscriber';
}
public static function getCommandDescription(): string
{
return 'Creates a new event subscriber class or a new event listener class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'Choose a class name for your event listener or subscriber (e.g. <fg=yellow>ExceptionListener</> or <fg=yellow>ExceptionSubscriber</>)')
->addArgument('event', InputArgument::OPTIONAL, 'What event do you want to listen to?')
->setHelp($this->getHelpFileContents('MakeListener.txt'))
;
$inputConfig->setArgumentAsNonInteractive('event');
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
/* @deprecated remove the following block when removing make:subscriber */
$this->handleDeprecatedMakerCommands($input, $io);
$io->writeln('');
$name = $input->getArgument('name');
if (!str_ends_with($name, 'Subscriber') && !str_ends_with($name, 'Listener')) {
$question = new ChoiceQuestion('Do you want to generate an event listener or subscriber?', self::ALL_TYPES, 0);
$classToGenerate = $io->askQuestion($question);
$input->setArgument('name', $name.$classToGenerate);
}
if (str_ends_with($input->getArgument('name'), 'Subscriber')) {
$this->isSubscriber = true;
}
if (!$input->getArgument('event')) {
$events = $this->eventRegistry->getAllActiveEvents();
$io->writeln(' <fg=green>Suggested Events:</>');
$io->listing($this->eventRegistry->listActiveEvents($events));
$question = new Question(\sprintf(' <fg=green>%s</>', $command->getDefinition()->getArgument('event')->getDescription()));
$question->setAutocompleterValues($events);
$question->setValidator(Validator::notBlank(...));
$event = $io->askQuestion($question);
$input->setArgument('event', $event);
}
$event = $input->getArgument('event');
if (null === $this->getEventConstant($event) && null === $this->eventRegistry->getEventClassName($event)) {
$eventList = $this->eventRegistry->getAllActiveEvents();
$eventFQCNList = array_filter(array_map($this->eventRegistry->getEventClassName(...), $eventList), fn ($eventFQCN) => \is_string($eventFQCN));
$eventIdAndFQCNList = array_unique(array_merge($eventList, $eventFQCNList));
$suggestionList = [];
foreach ($eventIdAndFQCNList as $eventSuggestion) {
if (levenshtein($event, Str::getShortClassName($eventSuggestion)) < 3) {
$suggestionList[] = $eventSuggestion;
}
}
if (!$suggestionList) {
return;
}
if (1 === \count($suggestionList)) {
$question = new ConfirmationQuestion(\sprintf('<fg=green>Did you mean</> <fg=yellow>"%s"</> <fg=green>?</>', $suggestionList[0]), false);
$input->setArgument('event', $io->askQuestion($question) ? $suggestionList[0] : $event);
return;
}
$io->writeln(' <fg=yellow>Did you mean one of these events?</>');
$io->listing($suggestionList);
$question = new Question(\sprintf(' <fg=green>%s</>', $command->getDefinition()->getArgument('event')->getDescription()), $event);
$question->setAutocompleterValues(array_merge($suggestionList, [$event]));
$input->setArgument('event', $io->askQuestion($question));
}
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
if ($this->isSubscriber) {
$useStatements = new UseStatementGenerator([
EventSubscriberInterface::class,
]);
} else {
$useStatements = new UseStatementGenerator([
AsEventListener::class,
]);
}
$event = $input->getArgument('event');
$eventFullClassName = $this->eventRegistry->getEventClassName($event);
$eventClassName = $eventFullClassName ? Str::getShortClassName($eventFullClassName) : null;
if ($this->getEventConstant($event)) {
$event = $eventFullClassName;
}
$eventName = class_exists($event) ? \sprintf('%s::class', $eventClassName) : \sprintf('\'%s\'', $event);
if (null !== $eventFullClassName) {
$useStatements->addUseStatement($eventFullClassName);
}
if ($this->isSubscriber) {
$this->generateSubscriberClass($input, $io, $generator, $useStatements, $event, $eventName, $eventClassName);
} else {
$this->generateListenerClass($input, $io, $generator, $useStatements, $event, $eventName, $eventClassName);
}
}
/** @return void */
public function configureDependencies(DependencyBuilder $dependencies)
{
}
private function getEventConstant(string $event): ?string
{
$constants = (new \ReflectionClass(KernelEvents::class))->getConstants();
if (false !== ($name = array_search($event, $constants, true))) {
return \sprintf('KernelEvents::%s', $name);
}
return null;
}
private function generateSubscriberClass(InputInterface $input, ConsoleStyle $io, Generator $generator, UseStatementGenerator $useStatements, string $event, string $eventName, ?string $eventClassName): void
{
$subscriberClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'EventSubscriber\\',
'Subscriber'
);
$generator->generateClass(
$subscriberClassNameDetails->getFullName(),
'event/Subscriber.tpl.php',
[
'use_statements' => $useStatements,
'event' => $eventName,
'event_arg' => $eventClassName ? \sprintf('%s $event', $eventClassName) : '$event',
'method_name' => class_exists($event) ? Str::asEventMethod($eventClassName) : Str::asEventMethod($event),
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new subscriber class and start customizing it.',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/event_dispatcher.html#creating-an-event-subscriber</>',
]);
}
private function generateListenerClass(InputInterface $input, ConsoleStyle $io, Generator $generator, UseStatementGenerator $useStatements, string $event, string $eventName, ?string $eventClassName): void
{
$listenerClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'EventListener\\',
'Listener'
);
$generator->generateClass(
$listenerClassNameDetails->getFullName(),
'event/Listener.tpl.php',
[
'use_statements' => $useStatements,
'event' => $eventName,
'class_event' => str_ends_with($eventName, '::class'),
'event_arg' => $eventClassName ? \sprintf('%s $event', $eventClassName) : '$event',
'method_name' => class_exists($event) ? Str::asEventMethod($eventClassName) : Str::asEventMethod($event),
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new listener class and start customizing it.',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/event_dispatcher.html#creating-an-event-listener</>',
]);
}
/**
* @deprecated
*/
private function handleDeprecatedMakerCommands(InputInterface $input, ConsoleStyle $io): void
{
$currentCommand = $input->getFirstArgument();
$name = $input->getArgument('name');
if ('make:subscriber' === $currentCommand) {
if (!str_ends_with($name, 'Subscriber')) {
$input->setArgument('name', $name.'Subscriber');
}
$io->warning('The "make:subscriber" command is deprecated, use "make:listener" instead.');
}
}
}

View File

@@ -0,0 +1,171 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Messenger\Attribute\AsMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @author Ryan Weaver <ryan@symfonycasts.com>
* @author Nicolas Philippe <nikophil@gmail.com>
*
* @internal
*/
final class MakeMessage extends AbstractMaker
{
public function __construct(private FileManager $fileManager)
{
}
public static function getCommandName(): string
{
return 'make:message';
}
public static function getCommandDescription(): string
{
return 'Create a new message and handler';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the message class (e.g. <fg=yellow>SendEmailMessage</>)')
->setHelp($this->getHelpFileContents('MakeMessage.txt'))
;
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
$command->addArgument('chosen-transport', InputArgument::OPTIONAL);
$messengerData = [];
try {
$manipulator = new YamlSourceManipulator($this->fileManager->getFileContents('config/packages/messenger.yaml'));
$messengerData = $manipulator->getData();
} catch (\Exception) {
}
if (!isset($messengerData['framework']['messenger']['transports'])) {
return;
}
$transports = array_keys($messengerData['framework']['messenger']['transports']);
array_unshift($transports, $noTransport = '[no transport]');
$chosenTransport = $io->choice(
'Which transport do you want to route your message to?',
$transports,
$noTransport
);
if ($noTransport !== $chosenTransport) {
$input->setArgument('chosen-transport', $chosenTransport);
}
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$chosenTransport = $input->getArgument('chosen-transport');
$messageClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Message\\'
);
$handlerClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name').'Handler',
'MessageHandler\\',
'Handler'
);
$useStatements = new UseStatementGenerator([]);
/* @legacy remove when AsMessage is always available */
if ($chosenTransport && class_exists(AsMessage::class)) {
$useStatements->addUseStatement(AsMessage::class);
}
$generator->generateClass(
$messageClassNameDetails->getFullName(),
'message/Message.tpl.php',
[
'use_statements' => $useStatements,
'transport' => class_exists(AsMessage::class) ? $chosenTransport : null,
]
);
$useStatements = new UseStatementGenerator([
AsMessageHandler::class,
$messageClassNameDetails->getFullName(),
]);
$generator->generateClass(
$handlerClassNameDetails->getFullName(),
'message/MessageHandler.tpl.php',
[
'use_statements' => $useStatements,
'message_class_name' => $messageClassNameDetails->getShortName(),
]
);
/* @legacy remove when AsMessage is always available */
if ($chosenTransport && !class_exists(AsMessage::class)) {
$this->updateMessengerConfig($generator, $chosenTransport, $messageClassNameDetails->getFullName());
}
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new message class and add the properties you need.',
' Then, open the new message handler and do whatever work you want!',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/messenger.html</>',
]);
}
private function updateMessengerConfig(Generator $generator, string $chosenTransport, string $messageClass): void
{
$manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($configFilePath = 'config/packages/messenger.yaml'));
$messengerData = $manipulator->getData();
if (!isset($messengerData['framework']['messenger']['routing'])) {
$messengerData['framework']['messenger']['routing'] = [];
}
$messengerData['framework']['messenger']['routing'][$messageClass] = $chosenTransport;
$manipulator->setData($messengerData);
$generator->dumpFile($configFilePath, $manipulator->getContents());
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
MessageBusInterface::class,
'messenger'
);
}
}

View File

@@ -0,0 +1,93 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
/**
* @author Imad ZAIRIG <imadzairig@gmail.com>
*
* @internal
*/
final class MakeMessengerMiddleware extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:messenger-middleware';
}
public static function getCommandDescription(): string
{
return 'Create a new messenger middleware';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the middleware class (e.g. <fg=yellow>CustomMiddleware</>)')
->setHelp($this->getHelpFileContents('MakeMessage.txt'))
;
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$middlewareClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Middleware\\',
'Middleware'
);
$useStatements = new UseStatementGenerator([
Envelope::class,
MiddlewareInterface::class,
StackInterface::class,
]);
$generator->generateClass(
$middlewareClassNameDetails->getFullName(),
'middleware/Middleware.tpl.php',
[
'use_statements' => $useStatements,
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next:',
\sprintf('- Open the <info>%s</info> class and add the code you need', $middlewareClassNameDetails->getFullName()),
'- Add the middleware to your <info>config/packages/messenger.yaml</info> file',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/messenger.html#middleware</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
MessageBusInterface::class,
'messenger'
);
}
}

View File

@@ -0,0 +1,180 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Doctrine\Bundle\MigrationsBundle\Command\MigrationsDiffDoctrineCommand;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Symfony\Bundle\MakerBundle\ApplicationAwareMakerInterface;
use Symfony\Bundle\MakerBundle\Console\MigrationDiffFilteredOutput;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\CliOutputHelper;
use Symfony\Bundle\MakerBundle\Util\MakerFileLinkFormatter;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
/**
* @author Amrouche Hamza <hamza.simperfit@gmail.com>
* @author Ryan Weaver <ryan@knpuniversity.com>
*/
final class MakeMigration extends AbstractMaker implements ApplicationAwareMakerInterface
{
private Application $application;
public function __construct(
private string $projectDir,
private ?MakerFileLinkFormatter $makerFileLinkFormatter = null,
) {
}
public static function getCommandName(): string
{
return 'make:migration';
}
public static function getCommandDescription(): string
{
return 'Create a new migration based on database changes';
}
/** @return void */
public function setApplication(Application $application)
{
$this->application = $application;
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->setHelp($this->getHelpFileContents('MakeMigration.txt'))
;
if (class_exists(MigrationsDiffDoctrineCommand::class)) {
// support for DoctrineMigrationsBundle 2.x
$command
->addOption('db', null, InputOption::VALUE_REQUIRED, 'The database connection name')
->addOption('em', null, InputOption::VALUE_OPTIONAL, 'The entity manager name')
->addOption('shard', null, InputOption::VALUE_REQUIRED, 'The shard connection name')
;
}
$command
->addOption('formatted', null, InputOption::VALUE_NONE, 'Format the generated SQL')
->addOption('configuration', null, InputOption::VALUE_OPTIONAL, 'The path of doctrine configuration file')
;
}
/** @return void|int */
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator)
{
$options = ['doctrine:migrations:diff'];
// DoctrineMigrationsBundle 2.x support
if ($input->hasOption('db') && null !== $input->getOption('db')) {
$options[] = '--db='.$input->getOption('db');
}
if ($input->hasOption('em') && null !== $input->getOption('em')) {
$options[] = '--em='.$input->getOption('em');
}
if ($input->hasOption('shard') && null !== $input->getOption('shard')) {
$options[] = '--shard='.$input->getOption('shard');
}
// end 2.x support
if ($input->getOption('formatted')) {
$options[] = '--formatted';
}
if (null !== $configuration = $input->getOption('configuration')) {
$options[] = '--configuration='.$configuration;
}
$generateMigrationCommand = $this->application->find('doctrine:migrations:diff');
$generateMigrationCommandInput = new ArgvInput($options);
if (!$input->isInteractive()) {
$generateMigrationCommandInput->setInteractive(false);
}
$commandOutput = new MigrationDiffFilteredOutput($io->getOutput());
try {
$returnCode = $generateMigrationCommand->run($generateMigrationCommandInput, $commandOutput);
// non-zero code would ideally mean the internal command has already printed an errror
// this happens if you "decline" generating a migration when you already
// have some available
if (0 !== $returnCode) {
return $returnCode;
}
$migrationOutput = $commandOutput->fetch();
if (str_contains($migrationOutput, 'No changes detected')) {
$this->noChangesMessage($io);
return;
}
} catch (\Doctrine\Migrations\Generator\Exception\NoChangesDetected) {
$this->noChangesMessage($io);
return;
}
$absolutePath = $this->getGeneratedMigrationFilename($migrationOutput);
$relativePath = str_replace($this->projectDir.'/', '', $absolutePath);
$io->comment('<fg=blue>created</>: '.($this->makerFileLinkFormatter?->makeLinkedPath($absolutePath, $relativePath) ?? $relativePath));
$this->writeSuccessMessage($io);
$io->text([
\sprintf('Review the new migration then run it with <info>%s doctrine:migrations:migrate</info>', CliOutputHelper::getCommandPrefix()),
'See <fg=yellow>https://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html</>',
]);
}
private function noChangesMessage(ConsoleStyle $io): void
{
$io->warning([
'No database changes were detected.',
]);
$io->text([
'The database schema and the application mapping information are already in sync.',
'',
]);
}
/** @return void */
public function configureDependencies(DependencyBuilder $dependencies)
{
$dependencies->addClassDependency(
DoctrineMigrationsBundle::class,
'doctrine/doctrine-migrations-bundle'
);
}
private function getGeneratedMigrationFilename(string $migrationOutput): string
{
preg_match('#"<info>(.*?)</info>"#', $migrationOutput, $matches);
if (!isset($matches[1])) {
throw new \Exception('Your migration generated successfully, but an error occurred printing the summary of what occurred.');
}
return $matches[1];
}
}

View File

@@ -0,0 +1,603 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\Column;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\Common\CanGenerateTestsTrait;
use Symfony\Bundle\MakerBundle\Renderer\FormTypeRenderer;
use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper;
use Symfony\Bundle\MakerBundle\Security\Model\Authenticator;
use Symfony\Bundle\MakerBundle\Security\Model\AuthenticatorType;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassDetails;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
use Symfony\Bundle\MakerBundle\Util\CliOutputHelper;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Translation\Translator;
use Symfony\Component\Validator\Validation;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelper;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
/**
* @author Ryan Weaver <ryan@symfonycasts.com>
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @internal
*/
final class MakeRegistrationForm extends AbstractMaker
{
use CanGenerateTestsTrait;
private string $userClass;
private string $usernameField;
private string $passwordField;
private bool $willVerifyEmail = false;
private bool $verifyEmailAnonymously = false;
private string $idGetter;
private string $emailGetter;
private string $fromEmailAddress;
private string $fromEmailName;
private ?Authenticator $autoLoginAuthenticator = null;
private string $redirectRouteName;
private bool $addUniqueEntityConstraint = false;
public function __construct(
private FileManager $fileManager,
private FormTypeRenderer $formTypeRenderer,
private DoctrineHelper $doctrineHelper,
private ?RouterInterface $router = null,
) {
}
public static function getCommandName(): string
{
return 'make:registration-form';
}
public static function getCommandDescription(): string
{
return 'Create a new registration form system';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->setHelp($this->getHelpFileContents('MakeRegistrationForm.txt'))
;
$this->configureCommandWithTestsOption($command);
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
$interactiveSecurityHelper = new InteractiveSecurityHelper();
if (null === $this->router) {
throw new RuntimeCommandException('Router have been explicitly disabled in your configuration. This command needs to use the router.');
}
if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) {
throw new RuntimeCommandException('The file "config/packages/security.yaml" does not exist. PHP & XML configuration formats are currently not supported.');
}
$manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path));
$securityData = $manipulator->getData();
$providersData = $securityData['security']['providers'] ?? [];
$this->userClass = $interactiveSecurityHelper->guessUserClass(
$io,
$providersData,
'Enter the User class that you want to create during registration (e.g. <fg=yellow>App\\Entity\\User</>)'
);
$io->text(\sprintf('Creating a registration form for <info>%s</info>', $this->userClass));
$this->usernameField = $interactiveSecurityHelper->guessUserNameField($io, $this->userClass, $providersData);
$this->passwordField = $interactiveSecurityHelper->guessPasswordField($io, $this->userClass);
// see if it makes sense to add the UniqueEntity constraint
$userClassDetails = new ClassDetails($this->userClass);
if (!$userClassDetails->hasAttribute(UniqueEntity::class)) {
$this->addUniqueEntityConstraint = (bool) $io->confirm(\sprintf('Do you want to add a <comment>#[UniqueEntity]</comment> validation attribute to your <comment>%s</comment> class to make sure duplicate accounts aren\'t created?', Str::getShortClassName($this->userClass)));
}
$this->willVerifyEmail = (bool) $io->confirm('Do you want to send an email to verify the user\'s email address after registration?');
if ($this->willVerifyEmail) {
$this->checkComponentsExist($io);
$emailText[] = 'By default, users are required to be authenticated when they click the verification link that is emailed to them.';
$emailText[] = 'This prevents the user from registering on their laptop, then clicking the link on their phone, without';
$emailText[] = 'having to log in. To allow multi device email verification, we can embed a user id in the verification link.';
$io->text($emailText);
$io->newLine();
$this->verifyEmailAnonymously = (bool) $io->confirm('Would you like to include the user id in the verification link to allow anonymous email verification?', false);
$this->idGetter = $interactiveSecurityHelper->guessIdGetter($io, $this->userClass);
$this->emailGetter = $interactiveSecurityHelper->guessEmailGetter($io, $this->userClass, 'email');
$this->fromEmailAddress = $io->ask(
'What email address will be used to send registration confirmations? (e.g. <fg=yellow>mailer@your-domain.com</>)',
null,
Validator::validateEmailAddress(...)
);
$this->fromEmailName = $io->ask(
'What "name" should be associated with that email address? (e.g. <fg=yellow>Acme Mail Bot</>)',
null,
Validator::notBlank(...)
);
}
if ($io->confirm('Do you want to automatically authenticate the user after registration?')) {
$this->interactAuthenticatorQuestions(
$io,
$interactiveSecurityHelper,
$securityData
);
}
if (!$this->autoLoginAuthenticator) {
$routeNames = array_keys($this->router->getRouteCollection()->all());
$this->redirectRouteName = $io->choice('What route should the user be redirected to after registration?', $routeNames);
}
$this->interactSetGenerateTests($input, $io);
}
/** @param array<string, mixed> $securityData */
private function interactAuthenticatorQuestions(ConsoleStyle $io, InteractiveSecurityHelper $interactiveSecurityHelper, array $securityData): void
{
// get list of authenticators
$authenticators = $interactiveSecurityHelper->getAuthenticatorsFromConfig($securityData['security']['firewalls'] ?? []);
if (empty($authenticators)) {
$io->note('No authenticators found - so your user won\'t be automatically authenticated after registering.');
return;
}
$this->autoLoginAuthenticator =
1 === \count($authenticators) ? $authenticators[0] : $io->choice(
'Which authenticator should be used to login the user?',
$authenticators
);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$userClassNameDetails = $generator->createClassNameDetails(
'\\'.$this->userClass,
'Entity\\'
);
$userDoctrineDetails = $this->doctrineHelper->createDoctrineDetails($userClassNameDetails->getFullName());
$userRepoVars = [
'repository_full_class_name' => EntityManagerInterface::class,
'repository_class_name' => 'EntityManagerInterface',
'repository_var' => '$manager',
];
$userRepository = $userDoctrineDetails->getRepositoryClass();
if (null !== $userRepository) {
$userRepoClassDetails = $generator->createClassNameDetails('\\'.$userRepository, 'Repository\\', 'Repository');
$userRepoVars = [
'repository_full_class_name' => $userRepoClassDetails->getFullName(),
'repository_class_name' => $userRepoClassDetails->getShortName(),
'repository_var' => \sprintf('$%s', lcfirst($userRepoClassDetails->getShortName())),
];
}
$verifyEmailServiceClassNameDetails = $generator->createClassNameDetails(
'EmailVerifier',
'Security\\'
);
$verifyEmailVars = ['will_verify_email' => $this->willVerifyEmail];
if ($this->willVerifyEmail) {
$verifyEmailVars = [
'will_verify_email' => $this->willVerifyEmail,
'email_verifier_class_details' => $verifyEmailServiceClassNameDetails,
'verify_email_anonymously' => $this->verifyEmailAnonymously,
'from_email' => $this->fromEmailAddress,
'from_email_name' => addslashes($this->fromEmailName),
'email_getter' => $this->emailGetter,
];
$useStatements = new UseStatementGenerator([
EntityManagerInterface::class,
TemplatedEmail::class,
Request::class,
MailerInterface::class,
UserInterface::class,
VerifyEmailExceptionInterface::class,
VerifyEmailHelperInterface::class,
$userClassNameDetails->getFullName(),
]);
$generator->generateClass(
$verifyEmailServiceClassNameDetails->getFullName(),
'verifyEmail/EmailVerifier.tpl.php',
array_merge([
'use_statements' => $useStatements,
'id_getter' => $this->idGetter,
'email_getter' => $this->emailGetter,
'verify_email_anonymously' => $this->verifyEmailAnonymously,
'user_class_name' => $userClassNameDetails->getShortName(),
],
$userRepoVars
)
);
$generator->generateTemplate(
'registration/confirmation_email.html.twig',
'registration/twig_email.tpl.php'
);
}
// 1) Generate the form class
$usernameField = $this->usernameField;
$formClassDetails = $this->generateFormClass(
$userClassNameDetails,
$generator,
$usernameField
);
// 2) Generate the controller
$controllerClassNameDetails = $generator->createClassNameDetails(
'RegistrationController',
'Controller\\'
);
$useStatements = new UseStatementGenerator([
AbstractController::class,
$formClassDetails->getFullName(),
$userClassNameDetails->getFullName(),
Request::class,
Response::class,
Route::class,
UserPasswordHasherInterface::class,
EntityManagerInterface::class,
]);
if ($this->willVerifyEmail) {
$useStatements->addUseStatement([
$verifyEmailServiceClassNameDetails->getFullName(),
TemplatedEmail::class,
Address::class,
VerifyEmailExceptionInterface::class,
]);
if ($this->verifyEmailAnonymously) {
$useStatements->addUseStatement($userRepoVars['repository_full_class_name']);
}
}
$autoLoginVars = [
'login_after_registration' => null !== $this->autoLoginAuthenticator,
];
if (null !== $this->autoLoginAuthenticator) {
$useStatements->addUseStatement([
Security::class,
]);
$autoLoginVars['firewall'] = $this->autoLoginAuthenticator->firewallName;
$autoLoginVars['authenticator'] = \sprintf('\'%s\'', $this->autoLoginAuthenticator->type->value);
if (AuthenticatorType::CUSTOM === $this->autoLoginAuthenticator->type) {
$useStatements->addUseStatement($this->autoLoginAuthenticator->authenticatorClass);
$autoLoginVars['authenticator'] = \sprintf('%s::class', Str::getShortClassName($this->autoLoginAuthenticator->authenticatorClass));
}
}
if ($isTranslatorAvailable = class_exists(Translator::class)) {
$useStatements->addUseStatement(TranslatorInterface::class);
}
$generator->generateController(
$controllerClassNameDetails->getFullName(),
'registration/RegistrationController.tpl.php',
array_merge([
'use_statements' => $useStatements,
'route_path' => '/register',
'route_name' => 'app_register',
'form_class_name' => $formClassDetails->getShortName(),
'user_class_name' => $userClassNameDetails->getShortName(),
'password_field' => $this->passwordField,
'redirect_route_name' => $this->redirectRouteName ?? null,
'translator_available' => $isTranslatorAvailable,
],
$userRepoVars,
$autoLoginVars,
$verifyEmailVars,
)
);
// 3) Generate the template
$generator->generateTemplate(
'registration/register.html.twig',
'registration/twig_template.tpl.php',
[
'username_field' => $usernameField,
'will_verify_email' => $this->willVerifyEmail,
]
);
// 4) Update the User class if necessary
if ($this->addUniqueEntityConstraint) {
$classDetails = new ClassDetails($this->userClass);
$userManipulator = new ClassSourceManipulator(
sourceCode: file_get_contents($classDetails->getPath())
);
$userManipulator->setIo($io);
if ($this->doctrineHelper->isDoctrineSupportingAttributes()) {
$userManipulator->addAttributeToClass(
UniqueEntity::class,
['fields' => [$usernameField], 'message' => \sprintf('There is already an account with this %s', $usernameField)]
);
}
$this->fileManager->dumpFile($classDetails->getPath(), $userManipulator->getSourceCode());
}
if ($this->willVerifyEmail) {
$classDetails = new ClassDetails($this->userClass);
$userManipulator = new ClassSourceManipulator(
sourceCode: file_get_contents($classDetails->getPath()),
overwrite: false,
);
$userManipulator->setIo($io);
$userManipulator->addProperty(
name: 'isVerified',
defaultValue: false,
attributes: [$userManipulator->buildAttributeNode(attributeClass: Column::class, options: [], attributePrefix: 'ORM')],
propertyType: 'bool'
);
$userManipulator->addAccessorMethod('isVerified', 'isVerified', 'bool', false);
$userManipulator->addSetter('isVerified', 'bool', false);
$this->fileManager->dumpFile($classDetails->getPath(), $userManipulator->getSourceCode());
}
// Generate PHPUnit Tests
if ($this->shouldGenerateTests()) {
$testClassDetails = $generator->createClassNameDetails(
'RegistrationControllerTest',
'Test\\'
);
$useStatements = new UseStatementGenerator([
EntityManager::class,
KernelBrowser::class,
TemplatedEmail::class,
WebTestCase::class,
$userRepoVars['repository_full_class_name'],
]);
$generator->generateFile(
targetPath: \sprintf('tests/%s.php', $testClassDetails->getShortName()),
templateName: $this->willVerifyEmail ? 'registration/Test.WithVerify.tpl.php' : 'registration/Test.WithoutVerify.tpl.php',
variables: array_merge([
'use_statements' => $useStatements,
'from_email' => $this->fromEmailAddress ?? null,
], $userRepoVars)
);
if (!class_exists(WebTestCase::class)) {
$io->caution('You\'ll need to install the `symfony/test-pack` to execute the tests for your new controller.');
}
}
$generator->writeChanges();
$this->writeSuccessMessage($io);
$this->successMessage($io, $this->willVerifyEmail, $userClassNameDetails->getShortName());
}
private function successMessage(ConsoleStyle $io, bool $emailVerification, string $userClass): void
{
$closing[] = 'Next:';
if (!$emailVerification) {
$closing[] = 'Make any changes you need to the form, controller & template.';
} else {
$index = 1;
if ($missingPackagesMessage = $this->getMissingComponentsComposerMessage()) {
$closing[] = '1) Install some missing packages:';
$closing[] = \sprintf(' <fg=green>%s</>', $missingPackagesMessage);
++$index;
}
$closing[] = \sprintf('%d) In <fg=yellow>RegistrationController::verifyUserEmail()</>:', $index++);
$closing[] = ' * Customize the last <fg=yellow>redirectToRoute()</> after a successful email verification.';
$closing[] = ' * Make sure you\'re rendering <fg=yellow>success</> flash messages or change the <fg=yellow>$this->addFlash()</> line.';
$closing[] = \sprintf('%d) Review and customize the form, controller, and templates as needed.', $index++);
$closing[] = \sprintf('%d) Run <fg=yellow>"%s make:migration"</> to generate a migration for the newly added <fg=yellow>%s::isVerified</> property.', $index++, CliOutputHelper::getCommandPrefix(), $userClass);
}
$io->text($closing);
$io->newLine();
$io->text('Then open your browser, go to "/register" and enjoy your new form!');
$io->newLine();
}
private function checkComponentsExist(ConsoleStyle $io): void
{
$message = $this->getMissingComponentsComposerMessage();
if ($message) {
$io->warning([
'We\'re missing some important components. Don\'t forget to install these after you\'re finished.',
$message,
]);
}
}
private function getMissingComponentsComposerMessage(): ?string
{
$missing = false;
$composerMessage = 'composer require';
// verify-email-bundle 1.17.0 includes the new validateEmailConfirmationFromRequest method.
// we need to check that if the bundle is installed, it is version 1.17.0 or greater
if (class_exists(SymfonyCastsVerifyEmailBundle::class)) {
$reflectedComponents = new \ReflectionClass(VerifyEmailHelper::class);
if (!$reflectedComponents->hasMethod('validateEmailConfirmationFromRequest')) {
throw new RuntimeCommandException('Please upgrade symfonycasts/verify-email-bundle to version 1.17.0 or greater.');
}
} else {
$missing = true;
$composerMessage = \sprintf('%s symfonycasts/verify-email-bundle', $composerMessage);
}
if (!interface_exists(MailerInterface::class)) {
$missing = true;
$composerMessage = \sprintf('%s symfony/mailer', $composerMessage);
}
if (!$missing) {
return null;
}
return $composerMessage;
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
AbstractType::class,
'form'
);
$dependencies->addClassDependency(
Validation::class,
'validator'
);
$dependencies->addClassDependency(
TwigBundle::class,
'twig-bundle'
);
$dependencies->addClassDependency(
DoctrineBundle::class,
'orm'
);
$dependencies->addClassDependency(
SecurityBundle::class,
'security'
);
}
private function generateFormClass(ClassNameDetails $userClassDetails, Generator $generator, string $usernameField): ClassNameDetails
{
$formClassDetails = $generator->createClassNameDetails(
'RegistrationFormType',
'Form\\'
);
$formFields = [
$usernameField => null,
'agreeTerms' => [
'type' => CheckboxType::class,
'options_code' => <<<EOF
'mapped' => false,
'constraints' => [
new IsTrue([
'message' => 'You should agree to our terms.',
]),
],
EOF
],
'plainPassword' => [
'type' => PasswordType::class,
'options_code' => <<<EOF
// instead of being set onto the object directly,
// this is read and encoded in the controller
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
],
EOF
],
];
$this->formTypeRenderer->render(
$formClassDetails,
$formFields,
$userClassDetails,
[
'Symfony\Component\Validator\Constraints\IsTrue',
'Symfony\Component\Validator\Constraints\Length',
'Symfony\Component\Validator\Constraints\NotBlank',
]
);
return $formClassDetails;
}
}

View File

@@ -0,0 +1,553 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Doctrine\ORM\EntityManagerInterface;
use PhpParser\Builder\Param;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator;
use Symfony\Bundle\MakerBundle\Doctrine\ORMDependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\RelationManyToOne;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\Common\CanGenerateTestsTrait;
use Symfony\Bundle\MakerBundle\Maker\Common\UidTrait;
use Symfony\Bundle\MakerBundle\Security\InteractiveSecurityHelper;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
use Symfony\Bundle\MakerBundle\Util\CliOutputHelper;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Util\YamlSourceManipulator;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Route as RouteObject;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Translation\Translator;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotCompromisedPassword;
use Symfony\Component\Validator\Constraints\PasswordStrength;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\Translation\TranslatorInterface;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelper;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
use SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle;
/**
* @author Romaric Drigon <romaric.drigon@gmail.com>
* @author Jesse Rushlow <jr@rushlow.dev>
* @author Ryan Weaver <ryan@symfonycasts.com>
* @author Antoine Michelet <jean.marcel.michelet@gmail.com>
*
* @internal
*
* @final
*/
class MakeResetPassword extends AbstractMaker
{
use CanGenerateTestsTrait;
use UidTrait;
private string $fromEmailAddress;
private string $fromEmailName;
private string $controllerResetSuccessRedirect;
private ?RouteObject $controllerResetSuccessRoute = null;
private string $userClass;
private string $emailPropertyName;
private string $emailGetterMethodName;
private string $passwordSetterMethodName;
public function __construct(
private FileManager $fileManager,
private DoctrineHelper $doctrineHelper,
private EntityClassGenerator $entityClassGenerator,
private ?RouterInterface $router = null,
) {
}
public static function getCommandName(): string
{
return 'make:reset-password';
}
public static function getCommandDescription(): string
{
return 'Create controller, entity, and repositories for use with symfonycasts/reset-password-bundle';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->setHelp($this->getHelpFileContents('MakeResetPassword.txt'))
;
$this->addWithUuidOption($command);
$this->configureCommandWithTestsOption($command);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(SymfonyCastsResetPasswordBundle::class, 'symfonycasts/reset-password-bundle');
$dependencies->addClassDependency(MailerInterface::class, 'symfony/mailer');
$dependencies->addClassDependency(Form::class, 'symfony/form');
$dependencies->addClassDependency(Validation::class, 'symfony/validator');
$dependencies->addClassDependency(SecurityBundle::class, 'security-bundle');
$dependencies->addClassDependency(AppVariable::class, 'twig');
ORMDependencyBuilder::buildDependencies($dependencies);
// reset-password-bundle 1.6 includes the ability to generate a fake token.
// we need to check that version 1.6 is installed
if (class_exists(ResetPasswordHelper::class) && !method_exists(ResetPasswordHelper::class, 'generateFakeResetToken')) {
throw new RuntimeCommandException('Please run "composer upgrade symfonycasts/reset-password-bundle". Version 1.6 or greater of this bundle is required.');
}
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
$io->title('Let\'s make a password reset feature!');
$this->checkIsUsingUid($input);
$interactiveSecurityHelper = new InteractiveSecurityHelper();
if (!$this->fileManager->fileExists($path = 'config/packages/security.yaml')) {
throw new RuntimeCommandException('The file "config/packages/security.yaml" does not exist. PHP & XML configuration formats are currently not supported.');
}
$manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path));
$securityData = $manipulator->getData();
$providersData = $securityData['security']['providers'] ?? [];
$this->userClass = $interactiveSecurityHelper->guessUserClass(
$io,
$providersData,
'What is the User entity that should be used with the "forgotten password" feature? (e.g. <fg=yellow>App\\Entity\\User</>)'
);
$this->emailPropertyName = $interactiveSecurityHelper->guessEmailField($io, $this->userClass);
$this->emailGetterMethodName = $interactiveSecurityHelper->guessEmailGetter($io, $this->userClass, $this->emailPropertyName);
$this->passwordSetterMethodName = $interactiveSecurityHelper->guessPasswordSetter($io, $this->userClass);
$io->text(\sprintf('Implementing reset password for <info>%s</info>', $this->userClass));
$io->section('- ResetPasswordController -');
$io->text('A named route is used for redirecting after a successful reset. Even a route that does not exist yet can be used here.');
$this->controllerResetSuccessRedirect = $io->ask(
'What route should users be redirected to after their password has been successfully reset?',
'app_home',
Validator::notBlank(...)
);
if ($this->router instanceof RouterInterface) {
$this->controllerResetSuccessRoute = $this->router->getRouteCollection()->get($this->controllerResetSuccessRedirect);
}
$io->section('- Email -');
$emailText[] = 'These are used to generate the email code. Don\'t worry, you can change them in the code later!';
$io->text($emailText);
$this->fromEmailAddress = $io->ask(
'What email address will be used to send reset confirmations? e.g. mailer@your-domain.com',
null,
Validator::validateEmailAddress(...)
);
$this->fromEmailName = $io->ask(
'What "name" should be associated with that email address? e.g. "Acme Mail Bot"',
null,
Validator::notBlank(...)
);
$this->interactSetGenerateTests($input, $io);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$userClassNameDetails = $generator->createClassNameDetails(
'\\'.$this->userClass,
'Entity\\'
);
$controllerClassNameDetails = $generator->createClassNameDetails(
'ResetPasswordController',
'Controller\\'
);
$requestClassNameDetails = $generator->createClassNameDetails(
'ResetPasswordRequest',
'Entity\\'
);
$repositoryClassNameDetails = $generator->createClassNameDetails(
'ResetPasswordRequestRepository',
'Repository\\'
);
$requestFormTypeClassNameDetails = $generator->createClassNameDetails(
'ResetPasswordRequestFormType',
'Form\\'
);
$changePasswordFormTypeClassNameDetails = $generator->createClassNameDetails(
'ChangePasswordFormType',
'Form\\'
);
$useStatements = new UseStatementGenerator([
AbstractController::class,
$userClassNameDetails->getFullName(),
$changePasswordFormTypeClassNameDetails->getFullName(),
$requestFormTypeClassNameDetails->getFullName(),
TemplatedEmail::class,
RedirectResponse::class,
Request::class,
Response::class,
MailerInterface::class,
Address::class,
Route::class,
ResetPasswordControllerTrait::class,
ResetPasswordExceptionInterface::class,
ResetPasswordHelperInterface::class,
UserPasswordHasherInterface::class,
EntityManagerInterface::class,
]);
// Namespace for ResetPasswordExceptionInterface was imported above
$problemValidateMessageOrConstant = \defined('SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE')
? 'ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE'
: "'There was a problem validating your password reset request'";
$problemHandleMessageOrConstant = \defined('SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE')
? 'ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE'
: "'There was a problem handling your password reset request'";
if ($isTranslatorAvailable = class_exists(Translator::class)) {
$useStatements->addUseStatement(TranslatorInterface::class);
}
$generator->generateController(
$controllerClassNameDetails->getFullName(),
'resetPassword/ResetPasswordController.tpl.php',
[
'use_statements' => $useStatements,
'user_class_name' => $userClassNameDetails->getShortName(),
'request_form_type_class_name' => $requestFormTypeClassNameDetails->getShortName(),
'reset_form_type_class_name' => $changePasswordFormTypeClassNameDetails->getShortName(),
'password_setter' => $this->passwordSetterMethodName,
'success_redirect_route' => $this->controllerResetSuccessRedirect,
'from_email' => $this->fromEmailAddress,
'from_email_name' => $this->fromEmailName,
'email_getter' => $this->emailGetterMethodName,
'email_field' => $this->emailPropertyName,
'problem_validate_message_or_constant' => $problemValidateMessageOrConstant,
'problem_handle_message_or_constant' => $problemHandleMessageOrConstant,
'translator_available' => $isTranslatorAvailable,
]
);
$this->generateRequestEntity($generator, $requestClassNameDetails, $repositoryClassNameDetails, $userClassNameDetails);
$this->setBundleConfig($io, $generator, $repositoryClassNameDetails->getFullName());
$useStatements = new UseStatementGenerator([
AbstractType::class,
EmailType::class,
FormBuilderInterface::class,
OptionsResolver::class,
NotBlank::class,
]);
$generator->generateClass(
$requestFormTypeClassNameDetails->getFullName(),
'resetPassword/ResetPasswordRequestFormType.tpl.php',
[
'use_statements' => $useStatements,
'email_field' => $this->emailPropertyName,
]
);
$useStatements = new UseStatementGenerator([
AbstractType::class,
PasswordType::class,
RepeatedType::class,
FormBuilderInterface::class,
OptionsResolver::class,
Length::class,
NotBlank::class,
NotCompromisedPassword::class,
PasswordStrength::class,
]);
$generator->generateClass(
$changePasswordFormTypeClassNameDetails->getFullName(),
'resetPassword/ChangePasswordFormType.tpl.php',
['use_statements' => $useStatements]
);
$generator->generateTemplate(
'reset_password/check_email.html.twig',
'resetPassword/twig_check_email.tpl.php'
);
$generator->generateTemplate(
'reset_password/email.html.twig',
'resetPassword/twig_email.tpl.php'
);
$generator->generateTemplate(
'reset_password/request.html.twig',
'resetPassword/twig_request.tpl.php',
[
'email_field' => $this->emailPropertyName,
]
);
$generator->generateTemplate(
'reset_password/reset.html.twig',
'resetPassword/twig_reset.tpl.php'
);
// Generate PHPUnit tests
if ($this->shouldGenerateTests()) {
$testClassDetails = $generator->createClassNameDetails(
'ResetPasswordControllerTest',
'Test\\',
);
$userRepositoryDetails = $generator->createClassNameDetails(
\sprintf('%sRepository', $userClassNameDetails->getShortName()),
'Repository\\'
);
$useStatements = new UseStatementGenerator([
$userClassNameDetails->getFullName(),
$userRepositoryDetails->getFullName(),
EntityManagerInterface::class,
KernelBrowser::class,
WebTestCase::class,
UserPasswordHasherInterface::class,
]);
$generator->generateFile(
targetPath: \sprintf('tests/%s.php', $testClassDetails->getShortName()),
templateName: 'resetPassword/Test.ResetPasswordController.tpl.php',
variables: [
'use_statements' => $useStatements,
'user_short_name' => $userClassNameDetails->getShortName(),
'user_repo_short_name' => $userRepositoryDetails->getShortName(),
'success_route_path' => null !== $this->controllerResetSuccessRoute ? $this->controllerResetSuccessRoute->getPath() : '/',
'from_email' => $this->fromEmailAddress,
],
);
if (!class_exists(WebTestCase::class)) {
$io->caution('You\'ll need to install the `symfony/test-pack` to execute the tests for your new controller.');
}
}
$generator->writeChanges();
$this->writeSuccessMessage($io);
$this->successMessage($io, $requestClassNameDetails->getFullName());
}
private function setBundleConfig(ConsoleStyle $io, Generator $generator, string $repositoryClassFullName): void
{
$configFileExists = $this->fileManager->fileExists($path = 'config/packages/reset_password.yaml');
/*
* reset_password.yaml does not exist, we assume flex was present when
* the bundle was installed & a customized configuration is in use.
* Remind the developer to set the repository class accordingly.
*/
if (!$configFileExists) {
$io->text(\sprintf('We can\'t find %s. That\'s ok, you probably have a customized configuration.', $path));
$io->text('Just remember to set the <fg=yellow>request_password_repository</> in your configuration.');
$io->newLine();
return;
}
$manipulator = new YamlSourceManipulator($this->fileManager->getFileContents($path));
$data = $manipulator->getData();
$symfonyCastsKey = 'symfonycasts_reset_password';
/*
* reset_password.yaml exists, and was probably created by flex;
* Let's replace it with a "clean" file.
*/
if (1 >= (is_countable($data[$symfonyCastsKey]) ? \count($data[$symfonyCastsKey]) : 0)) {
$yaml = [
$symfonyCastsKey => [
'request_password_repository' => $repositoryClassFullName,
],
];
$generator->dumpFile($path, Yaml::dump($yaml));
return;
}
/*
* reset_password.yaml exists and appears to have been customized
* before running make:reset-password. Let's just change the repository
* value and preserve everything else.
*/
$data[$symfonyCastsKey]['request_password_repository'] = $repositoryClassFullName;
$manipulator->setData($data);
$generator->dumpFile($path, $manipulator->getContents());
}
private function successMessage(ConsoleStyle $io, string $requestClassName): void
{
$closing[] = 'Next:';
$closing[] = \sprintf(' 1) Run <fg=yellow>"%s make:migration"</> to generate a migration for the new <fg=yellow>"%s"</> entity.', CliOutputHelper::getCommandPrefix(), $requestClassName);
$closing[] = ' 2) Review forms in <fg=yellow>"src/Form"</> to customize validation and labels.';
$closing[] = ' 3) Review and customize the templates in <fg=yellow>`templates/reset_password`</>.';
$closing[] = ' 4) Make sure your <fg=yellow>MAILER_DSN</> env var has the correct settings.';
$closing[] = ' 5) Create a "forgot your password link" to the <fg=yellow>app_forgot_password_request</> route on your login form.';
$io->text($closing);
$io->newLine();
$io->text('Then open your browser, go to "/reset-password" and enjoy!');
$io->newLine();
}
private function generateRequestEntity(Generator $generator, ClassNameDetails $requestClassNameDetails, ClassNameDetails $repositoryClassNameDetails, ClassNameDetails $userClassDetails): void
{
// Generate ResetPasswordRequest Entity
$requestEntityPath = $this->entityClassGenerator->generateEntityClass(
entityClassDetails: $requestClassNameDetails,
apiResource: false,
generateRepositoryClass: false,
useUuidIdentifier: $this->getIdType()
);
$generator->writeChanges();
$manipulator = new ClassSourceManipulator(
sourceCode: $this->fileManager->getFileContents($requestEntityPath),
overwrite: false,
useAttributesForDoctrineMapping: $this->doctrineHelper->doesClassUsesAttributes($requestClassNameDetails->getFullName()),
);
$manipulator->addInterface(ResetPasswordRequestInterface::class);
$manipulator->addTrait(ResetPasswordRequestTrait::class);
$manipulator->addUseStatementIfNecessary($userClassDetails->getFullName());
$manipulator->addConstructor([
(new Param('user'))->setType($userClassDetails->getShortName())->getNode(),
(new Param('expiresAt'))->setType('\DateTimeInterface')->getNode(),
(new Param('selector'))->setType('string')->getNode(),
(new Param('hashedToken'))->setType('string')->getNode(),
], <<<'CODE'
<?php
$this->user = $user;
$this->initialize($expiresAt, $selector, $hashedToken);
CODE
);
$manipulator->addManyToOneRelation(new RelationManyToOne(
propertyName: 'user',
targetClassName: $this->userClass,
mapInverseRelation: false,
avoidSetter: true,
isCustomReturnTypeNullable: false,
customReturnType: $userClassDetails->getShortName(),
isOwning: true,
));
$this->fileManager->dumpFile($requestEntityPath, $manipulator->getSourceCode());
$this->entityClassGenerator->generateRepositoryClass(
$repositoryClassNameDetails->getFullName(),
$requestClassNameDetails->getFullName(),
false,
false
);
$generator->writeChanges();
// Generate ResetPasswordRequestRepository
$pathRequestRepository = $this->fileManager->getRelativePathForFutureClass(
$repositoryClassNameDetails->getFullName()
);
$manipulator = new ClassSourceManipulator(
sourceCode: $this->fileManager->getFileContents($pathRequestRepository)
);
$manipulator->addInterface(ResetPasswordRequestRepositoryInterface::class);
$manipulator->addTrait(ResetPasswordRequestRepositoryTrait::class);
$methodBuilder = $manipulator->createMethodBuilder(
methodName: 'createResetPasswordRequest',
returnType: ResetPasswordRequestInterface::class,
isReturnTypeNullable: false,
commentLines: [\sprintf('@param %s $user', $userClassDetails->getShortName())]
);
$manipulator->addUseStatementIfNecessary($userClassDetails->getFullName());
$manipulator->addMethodBuilder($methodBuilder, [
(new Param('user'))->setType('object')->getNode(),
(new Param('expiresAt'))->setType('\DateTimeInterface')->getNode(),
(new Param('selector'))->setType('string')->getNode(),
(new Param('hashedToken'))->setType('string')->getNode(),
], <<<'CODE'
<?php
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
CODE
);
$this->fileManager->dumpFile($pathRequestRepository, $manipulator->getSourceCode());
}
}

View File

@@ -0,0 +1,149 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Process\Process;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
/**
* @author Jesse Rushlow <jr@rushlow.dev>
*
* @deprecated since MakerBundle v1.63.0, use symfony/scheduler recipe instead,
*
* @internal
*/
final class MakeSchedule extends AbstractMaker
{
private string $scheduleName;
private ?string $message = null;
private ?string $transportName = null;
public function __construct(
private FileManager $fileManager,
private Finder $finder = new Finder(),
) {
}
public static function getCommandName(): string
{
return 'make:schedule';
}
public static function getCommandDescription(): string
{
return 'Create a scheduler component';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->setHelp($this->getHelpFileContents('MakeScheduler.txt'))
;
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
trigger_deprecation('symfony/maker-bundle', 'v1.63.0', '"make:schedule" is deprecated, install the symfony/scheduler recipe instead.');
if (!class_exists(AsSchedule::class)) {
$io->writeln('Running composer require symfony/scheduler');
$process = Process::fromShellCommandline('composer require symfony/scheduler');
$process->run();
$io->writeln('Scheduler successfully installed!');
}
// Loop over existing src/Message/* and ask which message the user would like to schedule
$availableMessages = ['Empty Schedule'];
$messageDir = $this->fileManager->getRootDirectory().'/src/Message';
if ($this->fileManager->fileExists($messageDir)) {
$finder = $this->finder->in($this->fileManager->getRootDirectory().'/src/Message');
foreach ($finder->files() as $file) {
$availableMessages[] = $file->getFilenameWithoutExtension();
}
}
$this->transportName = $io->ask('What should we call the new transport? (To be used for the attribute #[AsSchedule(name)])');
$scheduleNameHint = 'MainSchedule';
// If the count is 1, no other messages were found - don't ask to create a message
if (1 !== \count($availableMessages)) {
$selectedMessage = $io->choice('Select which message', $availableMessages);
if ('Empty Schedule' !== $selectedMessage) {
$this->message = $selectedMessage;
// We don't want SomeMessageSchedule, so remove the "Message" suffix to give us SomeSchedule
$scheduleNameHint = \sprintf('%sSchedule', Str::removeSuffix($selectedMessage, 'Message'));
}
}
// Ask the name of the new schedule
$this->scheduleName = $io->ask(question: 'What should we call the new schedule?', default: $scheduleNameHint);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$scheduleClassDetails = $generator->createClassNameDetails(
$this->scheduleName,
'Scheduler\\',
);
$useStatements = new UseStatementGenerator([
AsSchedule::class,
RecurringMessage::class,
Schedule::class,
ScheduleProviderInterface::class,
CacheInterface::class,
]);
if (null !== $this->message) {
$useStatements->addUseStatement('App\\Message\\'.$this->message);
}
$generator->generateClass(
$scheduleClassDetails->getFullName(),
'scheduler/Schedule.tpl.php',
[
'use_statements' => $useStatements,
'has_custom_message' => null !== $this->message,
'message_class_name' => $this->message,
'has_transport_name' => null !== $this->transportName,
'transport_name' => $this->transportName,
],
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
}
}

View File

@@ -0,0 +1,93 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Encoder\EncoderInterface;
use Symfony\Component\Serializer\Serializer;
/**
* @author Piotr Grabski-Gradzinski <piotr.gradzinski@gmail.com>
*/
final class MakeSerializerEncoder extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:serializer:encoder';
}
public static function getCommandDescription(): string
{
return 'Create a new serializer encoder class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'Choose a class name for your encoder (e.g. <fg=yellow>YamlEncoder</>)')
->addArgument('format', InputArgument::OPTIONAL, 'Pick your format name (e.g. <fg=yellow>yaml</>)')
->setHelp($this->getHelpFileContents('MakeSerializerEncoder.txt'))
;
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$encoderClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Serializer\\',
'Encoder'
);
$format = $input->getArgument('format');
$useStatements = new UseStatementGenerator([
DecoderInterface::class,
EncoderInterface::class,
]);
/* @legacy - Remove "decoder_return_type" when Symfony 6.4 is no longer supported */
$generator->generateClass(
$encoderClassNameDetails->getFullName(),
'serializer/Encoder.tpl.php',
[
'use_statements' => $useStatements,
'format' => $format,
'use_decoder_return_type' => Kernel::VERSION_ID >= 70000,
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new serializer encoder class and start customizing it.',
'Find the documentation at <fg=yellow>http://symfony.com/doc/current/serializer/custom_encoders.html</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
Serializer::class,
'serializer'
);
}
}

View File

@@ -0,0 +1,108 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Serializer;
/**
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
final class MakeSerializerNormalizer extends AbstractMaker
{
public function __construct(private ?FileManager $fileManager = null)
{
if (null !== $this->fileManager) {
@trigger_deprecation(
'symfony/maker-bundle',
'1.56.0',
\sprintf('Initializing MakeSerializerNormalizer while providing an instance of "%s" is deprecated. The $fileManager param will be removed in a future version.', FileManager::class)
);
}
}
public static function getCommandName(): string
{
return 'make:serializer:normalizer';
}
public static function getCommandDescription(): string
{
return 'Create a new serializer normalizer class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'Choose a class name for your normalizer (e.g. <fg=yellow>UserNormalizer</>)')
->setHelp($this->getHelpFileContents('MakeSerializerNormalizer.txt'))
;
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$normalizerClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Serializer\\Normalizer\\',
\Normalizer::class
);
$useStatements = new UseStatementGenerator([
NormalizerInterface::class,
Autowire::class,
\sprintf('App\Entity\%s', str_replace('Normalizer', '', $normalizerClassNameDetails->getShortName())),
]);
$entityDetails = $generator->createClassNameDetails(
str_replace('Normalizer', '', $normalizerClassNameDetails->getShortName()),
'Entity\\',
);
if ($entityExists = class_exists($entityDetails->getFullName())) {
$useStatements->addUseStatement($entityDetails->getFullName());
}
$generator->generateClass($normalizerClassNameDetails->getFullName(), 'serializer/Normalizer.tpl.php', [
'use_statements' => $useStatements,
'entity_exists' => $entityExists,
'entity_name' => $entityDetails->getShortName(),
]);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next:',
' - Open your new serializer normalizer class and start customizing it.',
' - Find the documentation at <fg=yellow>https://symfony.com/doc/current/serializer/custom_normalizer.html</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
Serializer::class,
'serializer'
);
}
}

View File

@@ -0,0 +1,358 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\Question;
use Symfony\UX\StimulusBundle\StimulusBundle;
use Symfony\WebpackEncoreBundle\WebpackEncoreBundle;
/**
* @author Abdelilah Jabri <jbrabdelilah@gmail.com>
*
* @internal
*/
final class MakeStimulusController extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:stimulus-controller';
}
public static function getCommandDescription(): string
{
return 'Create a new Stimulus controller';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::REQUIRED, 'The name of the Stimulus controller (e.g. <fg=yellow>hello</>)')
->addOption('typescript', 'ts', InputOption::VALUE_NONE, 'Create a TypeScript controller (default is JavaScript)')
->setHelp($this->getHelpFileContents('MakeStimulusController.txt'))
;
$inputConfig->setArgumentAsNonInteractive('typescript');
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
$command->addArgument('extension', InputArgument::OPTIONAL);
$command->addArgument('targets', InputArgument::OPTIONAL);
$command->addArgument('values', InputArgument::OPTIONAL);
$command->addArgument('classes', InputArgument::OPTIONAL);
if ($input->getOption('typescript')) {
$input->setArgument('extension', 'ts');
} else {
$chosenExtension = $io->choice(
'Language (<fg=yellow>JavaScript</> or <fg=yellow>TypeScript</>)',
[
'js' => 'JavaScript',
'ts' => 'TypeScript',
],
'js',
);
$input->setArgument('extension', $chosenExtension);
}
if ($io->confirm('Do you want to include targets?')) {
$targets = [];
$isFirstTarget = true;
while (true) {
$newTarget = $this->askForNextTarget($io, $targets, $isFirstTarget);
$isFirstTarget = false;
if (null === $newTarget) {
break;
}
$targets[] = $newTarget;
}
$input->setArgument('targets', $targets);
}
if ($io->confirm('Do you want to include values?')) {
$values = [];
$isFirstValue = true;
while (true) {
$newValue = $this->askForNextValue($io, $values, $isFirstValue);
$isFirstValue = false;
if (null === $newValue) {
break;
}
$values[$newValue['name']] = $newValue;
}
$input->setArgument('values', $values);
}
if ($io->confirm('Do you want to add classes?', false)) {
$classes = [];
$isFirstClass = true;
while (true) {
$newClass = $this->askForNextClass($io, $classes, $isFirstClass);
if (null === $newClass) {
break;
}
$isFirstClass = false;
$classes[] = $newClass;
}
$input->setArgument('classes', $classes);
}
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$controllerName = Str::asSnakeCase($input->getArgument('name'));
$chosenExtension = $input->getArgument('extension');
$targets = $targetArgs = $input->getArgument('targets') ?? [];
$values = $valuesArg = $input->getArgument('values') ?? [];
$classes = $classesArgs = $input->getArgument('classes') ?? [];
$targets = empty($targets) ? $targets : \sprintf("['%s']", implode("', '", $targets));
$classes = $classes ? \sprintf("['%s']", implode("', '", $classes)) : null;
$fileName = \sprintf('%s_controller.%s', $controllerName, $chosenExtension);
$filePath = \sprintf('assets/controllers/%s', $fileName);
$generator->generateFile(
$filePath,
'stimulus/Controller.tpl.php',
[
'targets' => $targets,
'values' => $values,
'classes' => $classes,
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next:',
\sprintf('- Open <info>%s</info> and add the code you need', $filePath),
'- Use the controller in your templates:',
...array_map(
fn (string $line): string => " $line",
explode("\n", $this->generateUsageExample($controllerName, $targetArgs, $valuesArg, $classesArgs)),
),
'Find the documentation at <fg=yellow>https://symfony.com/bundles/StimulusBundle</>',
]);
}
/** @param string[] $targets */
private function askForNextTarget(ConsoleStyle $io, array $targets, bool $isFirstTarget): ?string
{
$questionText = 'New target name (press <return> to stop adding targets)';
if (!$isFirstTarget) {
$questionText = 'Add another target? Enter the target name (or press <return> to stop adding targets)';
}
$targetName = $io->ask($questionText, validator: function (?string $name) use ($targets) {
if (\in_array($name, $targets)) {
throw new \InvalidArgumentException(\sprintf('The "%s" target already exists.', $name));
}
return $name;
});
return !$targetName ? null : $targetName;
}
/**
* @param array<string, array<string, string>> $values
*
* @return array<string, string>|null
*/
private function askForNextValue(ConsoleStyle $io, array $values, bool $isFirstValue): ?array
{
$questionText = 'New value name (press <return> to stop adding values)';
if (!$isFirstValue) {
$questionText = 'Add another value? Enter the value name (or press <return> to stop adding values)';
}
$valueName = $io->ask($questionText, null, function ($name) use ($values) {
if (\array_key_exists($name, $values)) {
throw new \InvalidArgumentException(\sprintf('The "%s" value already exists.', $name));
}
return $name;
});
if (!$valueName) {
return null;
}
$defaultType = 'String';
// try to guess the type by the value name prefix/suffix
// convert to snake case for simplicity
$snakeCasedField = Str::asSnakeCase($valueName);
if (str_ends_with($snakeCasedField, '_id')) {
$defaultType = 'Number';
} elseif (str_starts_with($snakeCasedField, 'is_')) {
$defaultType = 'Boolean';
} elseif (str_starts_with($snakeCasedField, 'has_')) {
$defaultType = 'Boolean';
}
$type = null;
$types = $this->getValuesTypes();
while (null === $type) {
$question = new Question('Value type (enter <comment>?</comment> to see all types)', $defaultType);
$question->setAutocompleterValues($types);
$type = $io->askQuestion($question);
if ('?' === $type) {
$this->printAvailableTypes($io);
$io->writeln('');
$type = null;
} elseif (!\in_array($type, $types)) {
$this->printAvailableTypes($io);
$io->error(\sprintf('Invalid type "%s".', $type));
$io->writeln('');
$type = null;
}
}
return ['name' => $valueName, 'type' => $type];
}
/** @param string[] $classes */
private function askForNextClass(ConsoleStyle $io, array $classes, bool $isFirstClass): ?string
{
$questionText = 'New class name (press <return> to stop adding classes)';
if (!$isFirstClass) {
$questionText = 'Add another class? Enter the class name (or press <return> to stop adding classes)';
}
$className = $io->ask($questionText, validator: function (?string $name) use ($classes) {
if (str_contains($name, ' ')) {
throw new \InvalidArgumentException('Class name cannot contain spaces.');
}
if (\in_array($name, $classes, true)) {
throw new \InvalidArgumentException(\sprintf('The "%s" class already exists.', $name));
}
return $name;
});
return $className ?: null;
}
private function printAvailableTypes(ConsoleStyle $io): void
{
foreach ($this->getValuesTypes() as $type) {
$io->writeln(\sprintf('<info>%s</info>', $type));
}
}
/** @return string[] */
private function getValuesTypes(): array
{
return [
'Array',
'Boolean',
'Number',
'Object',
'String',
];
}
/**
* @param array<int, string> $targets
* @param array<array{name: string, type: string}> $values
* @param array<int, string> $classes
*/
private function generateUsageExample(string $name, array $targets, array $values, array $classes): string
{
$slugify = fn (string $name) => str_replace('_', '-', Str::asSnakeCase($name));
$controller = $slugify($name);
$htmlTargets = [];
foreach ($targets as $target) {
$htmlTargets[] = \sprintf('<div data-%s-target="%s"></div>', $controller, $target);
}
$htmlValues = [];
foreach ($values as ['name' => $name, 'type' => $type]) {
$value = match ($type) {
'Array' => '[]',
'Boolean' => 'false',
'Number' => '123',
'Object' => '{}',
'String' => 'abc',
default => '',
};
$htmlValues[] = \sprintf('data-%s-%s-value="%s"', $controller, $slugify($name), $value);
}
$htmlClasses = [];
foreach ($classes as $class) {
$value = Str::asLowerCamelCase($class);
$htmlClasses[] = \sprintf('data-%s-%s-class="%s"', $controller, $slugify($class), $value);
}
return \sprintf(
'<div data-controller="%s"%s%s%s>%s%s</div>',
$controller,
$htmlValues ? ("\n ".implode("\n ", $htmlValues)) : '',
$htmlClasses ? ("\n ".implode("\n ", $htmlClasses)) : '',
($htmlValues || $htmlClasses) ? "\n" : '',
$htmlTargets ? ("\n ".implode("\n ", $htmlTargets)) : '',
"\n <!-- ... -->\n",
);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
// lower than 8.1, allow WebpackEncoreBundle
if (\PHP_VERSION_ID < 80100) {
$dependencies->addClassDependency(
WebpackEncoreBundle::class,
'symfony/webpack-encore-bundle'
);
return;
}
// else: encourage StimulusBundle by requiring it
$dependencies->addClassDependency(
StimulusBundle::class,
'symfony/stimulus-bundle'
);
}
}

View File

@@ -0,0 +1,142 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\EventRegistry;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
trigger_deprecation('symfony/maker-bundle', '1.51', 'The "%s" class is deprecated, use "%s" instead.', MakeSubscriber::class, MakeListener::class);
/**
* @deprecated since MakerBundle 1.51, use Symfony\Bundle\MakerBundle\Maker\MakeListener instead.
*
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeSubscriber extends AbstractMaker
{
public function __construct(private EventRegistry $eventRegistry)
{
}
public static function getCommandName(): string
{
return 'make:subscriber';
}
public static function getCommandDescription(): string
{
return 'Create a new event subscriber class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'Choose a class name for your event subscriber (e.g. <fg=yellow>ExceptionSubscriber</>)')
->addArgument('event', InputArgument::OPTIONAL, 'What event do you want to subscribe to?')
->setHelp($this->getHelpFileContents('MakeSubscriber.txt'))
;
$inputConfig->setArgumentAsNonInteractive('event');
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (!$input->getArgument('event')) {
$events = $this->eventRegistry->getAllActiveEvents();
$io->writeln(' <fg=green>Suggested Events:</>');
$io->listing($this->eventRegistry->listActiveEvents($events));
$question = new Question(\sprintf(' <fg=green>%s</>', $command->getDefinition()->getArgument('event')->getDescription()));
$question->setAutocompleterValues($events);
$question->setValidator(Validator::notBlank(...));
$event = $io->askQuestion($question);
$input->setArgument('event', $event);
}
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$subscriberClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'EventSubscriber\\',
'Subscriber'
);
$event = $input->getArgument('event');
$eventFullClassName = $this->eventRegistry->getEventClassName($event);
$eventClassName = $eventFullClassName ? Str::getShortClassName($eventFullClassName) : null;
$useStatements = new UseStatementGenerator([
EventSubscriberInterface::class,
]);
// Determine if we use a KernelEvents::CONSTANT or custom even name
if (null !== ($eventConstant = $this->getEventConstant($event))) {
$useStatements->addUseStatement(KernelEvents::class);
$eventName = $eventConstant;
} else {
$eventName = class_exists($event) ? \sprintf('%s::class', $eventClassName) : \sprintf('\'%s\'', $event);
}
if (null !== $eventFullClassName) {
$useStatements->addUseStatement($eventFullClassName);
}
$generator->generateClass(
$subscriberClassNameDetails->getFullName(),
'event/Subscriber.tpl.php',
[
'use_statements' => $useStatements,
'event' => $eventName,
'event_arg' => $eventClassName ? \sprintf('%s $event', $eventClassName) : '$event',
'method_name' => class_exists($event) ? Str::asEventMethod($eventClassName) : Str::asEventMethod($event),
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new subscriber class and start customizing it.',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/event_dispatcher.html#creating-an-event-subscriber</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
}
private function getEventConstant(string $event): ?string
{
$constants = (new \ReflectionClass(KernelEvents::class))->getConstants();
if (false !== ($name = array_search($event, $constants, true))) {
return \sprintf('KernelEvents::%s', $name);
}
return null;
}
}

View File

@@ -0,0 +1,229 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestAssertionsTrait;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputAwareMakerInterface;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Component\BrowserKit\History;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\CssSelector\CssSelectorConverter;
use Symfony\Component\Panther\PantherTestCaseTrait;
/**
* @author Kévin Dunglas <kevin@dunglas.fr>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeTest extends AbstractMaker implements InputAwareMakerInterface
{
private const DESCRIPTIONS = [
'TestCase' => 'basic PHPUnit tests',
'KernelTestCase' => 'basic tests that have access to Symfony services',
'WebTestCase' => 'to run browser-like scenarios, but that don\'t execute JavaScript code',
'ApiTestCase' => 'to run API-oriented scenarios',
'PantherTestCase' => 'to run e2e scenarios, using a real-browser or HTTP client and a real web server',
];
private const DOCS = [
'TestCase' => 'https://symfony.com/doc/current/testing.html#unit-tests',
'KernelTestCase' => 'https://symfony.com/doc/current/testing/database.html#functional-testing-of-a-doctrine-repository',
'WebTestCase' => 'https://symfony.com/doc/current/testing.html#functional-tests',
'ApiTestCase' => 'https://api-platform.com/docs/distribution/testing/',
'PantherTestCase' => 'https://github.com/symfony/panther#testing-usage',
];
public static function getCommandName(): string
{
return 'make:test';
}
/**
* @deprecated remove this method when removing make:unit-test and make:functional-test
*
* @return string[]
*/
public static function getCommandAliases(): iterable
{
yield 'make:unit-test';
yield 'make:functional-test';
}
public static function getCommandDescription(): string
{
return 'Create a new test class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$typesDesc = [];
$typesHelp = [];
foreach (self::DESCRIPTIONS as $type => $desc) {
$typesDesc[] = \sprintf('<fg=yellow>%s</> (%s)', $type, $desc);
$typesHelp[] = \sprintf('* <info>%s</info>: %s', $type, $desc);
}
$command
->addArgument('type', InputArgument::OPTIONAL, 'The type of test: '.implode(', ', $typesDesc))
->addArgument('name', InputArgument::OPTIONAL, 'The name of the test class (e.g. <fg=yellow>BlogPostTest</>)')
->setHelp($this->getHelpFileContents('MakeTest.txt').implode("\n", $typesHelp))
;
$inputConfig->setArgumentAsNonInteractive('name');
$inputConfig->setArgumentAsNonInteractive('type');
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
/* @deprecated remove the following block when removing make:unit-test and make:functional-test */
$this->handleDeprecatedMakerCommands($input, $io);
if (null !== $type = $input->getArgument('type')) {
if (!isset(self::DESCRIPTIONS[$type])) {
throw new RuntimeCommandException(\sprintf('The test type must be one of "%s", "%s" given.', implode('", "', array_keys(self::DESCRIPTIONS)), $type));
}
} else {
$input->setArgument(
'type',
$io->choice('Which test type would you like?', self::DESCRIPTIONS)
);
}
if ('ApiTestCase' === $input->getArgument('type') && !class_exists(ApiTestCase::class)) {
$io->warning([
'API Platform is required for this test type. Install it with',
'composer require api',
]);
}
if ('PantherTestCase' === $input->getArgument('type') && !trait_exists(PantherTestCaseTrait::class)) {
$io->warning([
'symfony/panther is required for this test type. Install it with',
'composer require symfony/panther --dev',
]);
}
if (null === $input->getArgument('name')) {
$io->writeln([
'',
'Choose a class name for your test, like:',
' * <fg=yellow>UtilTest</> (to create tests/UtilTest.php)',
' * <fg=yellow>Service\\UtilTest</> (to create tests/Service/UtilTest.php)',
' * <fg=yellow>\\App\Tests\\Service\\UtilTest</> (to create tests/Service/UtilTest.php)',
]);
$nameArgument = $command->getDefinition()->getArgument('name');
$value = $io->ask($nameArgument->getDescription(), $nameArgument->getDefault(), Validator::notBlank(...));
$input->setArgument($nameArgument->getName(), $value);
}
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$testClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Tests\\',
'Test'
);
$type = $input->getArgument('type');
$generator->generateClass(
$testClassNameDetails->getFullName(),
"test/$type.tpl.php",
[
'web_assertions_are_available' => trait_exists(WebTestAssertionsTrait::class),
'api_test_case_fqcn' => !class_exists(ApiTestCase::class) ? LegacyApiTestCase::class : ApiTestCase::class,
]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new test class and start customizing it.',
\sprintf('Find the documentation at <fg=yellow>%s</>', self::DOCS[$type]),
]);
}
public function configureDependencies(DependencyBuilder $dependencies, ?InputInterface $input = null): void
{
if (null === $input) {
return;
}
switch ($input->getArgument('type')) {
case 'WebTestCase':
$dependencies->addClassDependency(
History::class,
'browser-kit',
true,
true
);
$dependencies->addClassDependency(
CssSelectorConverter::class,
'css-selector',
true,
true
);
return;
case 'ApiTestCase':
$dependencies->addClassDependency(
!class_exists(ApiTestCase::class) ? LegacyApiTestCase::class : ApiTestCase::class,
'api',
true,
false
);
return;
case 'PantherTestCase':
$dependencies->addClassDependency(
PantherTestCaseTrait::class,
'panther',
true,
true
);
return;
}
}
/**
* @deprecated
*/
private function handleDeprecatedMakerCommands(InputInterface $input, ConsoleStyle $io): void
{
$currentCommand = $input->getFirstArgument();
switch ($currentCommand) {
case 'make:unit-test':
$input->setArgument('type', 'TestCase');
$io->warning('The "make:unit-test" command is deprecated, use "make:test" instead.');
break;
case 'make:functional-test':
$input->setArgument('type', trait_exists(PantherTestCaseTrait::class) ? 'WebTestCase' : 'PantherTestCase');
$io->warning('The "make:functional-test" command is deprecated, use "make:test" instead.');
break;
}
}
}

View File

@@ -0,0 +1,119 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Yaml\Yaml;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class MakeTwigComponent extends AbstractMaker
{
private string $namespace = 'Twig\\Components';
public function __construct(private FileManager $fileManager)
{
}
public static function getCommandName(): string
{
return 'make:twig-component';
}
public static function getCommandDescription(): string
{
return 'Create a Twig (or Live) component';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->setDescription(self::getCommandDescription())
->addArgument('name', InputArgument::OPTIONAL, 'The name of your Twig component (ie <fg=yellow>Notification</>)')
->addOption('live', null, InputOption::VALUE_NONE, 'Whether to create a Live component (requires <fg=yellow>symfony/ux-live-component</>)')
;
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(AsTwigComponent::class, 'symfony/ux-twig-component');
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$name = $input->getArgument('name');
$live = $input->getOption('live');
if ($live && !class_exists(AsLiveComponent::class)) {
throw new \RuntimeException('You must install symfony/ux-live-component to create a Live component (composer require symfony/ux-live-component)');
}
$factory = $generator->createClassNameDetails(
$name,
str_replace($generator->getRootNamespace().'\\', '', $this->namespace),
);
$templatePath = str_replace('\\', '/', $factory->getRelativeNameWithoutSuffix());
$shortName = str_replace('\\', ':', $factory->getRelativeNameWithoutSuffix());
$generator->generateClass(
$factory->getFullName(),
\sprintf('%s/templates/twig/%s', \dirname(__DIR__, 2), $live ? 'LiveComponent.tpl.php' : 'Component.tpl.php'),
[
'live' => $live,
]
);
$generator->generateTemplate(
"components/{$templatePath}.html.twig",
\sprintf('%s/templates/twig/%s', \dirname(__DIR__, 2), 'component_template.tpl.php')
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->newLine();
$io->writeln(" To render the component, use <fg=yellow><twig:{$shortName} /></>.");
$io->newLine();
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
if (!$input->getOption('live')) {
$input->setOption('live', $io->confirm('Make this a Live component?', false));
}
$path = 'config/packages/twig_component.yaml';
if (!$this->fileManager->fileExists($path)) {
return;
}
try {
$value = Yaml::parse($this->fileManager->getFileContents($path));
$this->namespace = array_key_first($value['twig_component']['defaults']);
} catch (\Throwable $throwable) {
throw new RuntimeCommandException(message: 'Unable to parse config/packages/twig_component.yaml', previous: $throwable);
}
}
}

View File

@@ -0,0 +1,107 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Twig\Extension\AbstractExtension;
use Twig\Extension\RuntimeExtensionInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeTwigExtension extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:twig-extension';
}
public static function getCommandDescription(): string
{
return 'Create a new Twig extension with its runtime class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the Twig extension class (e.g. <fg=yellow>AppExtension</>)')
->setHelp($this->getHelpFileContents('MakeTwigExtension.txt'))
;
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$name = $input->getArgument('name');
$extensionClassNameDetails = $generator->createClassNameDetails(
$name,
'Twig\\Extension\\',
'Extension'
);
$runtimeClassNameDetails = $generator->createClassNameDetails(
$name,
'Twig\\Runtime\\',
'Runtime'
);
$useStatements = new UseStatementGenerator([
AbstractExtension::class,
TwigFilter::class,
TwigFunction::class,
$runtimeClassNameDetails->getFullName(),
]);
$runtimeUseStatements = new UseStatementGenerator([
RuntimeExtensionInterface::class,
]);
$generator->generateClass(
$extensionClassNameDetails->getFullName(),
'twig/Extension.tpl.php',
['use_statements' => $useStatements, 'runtime_class_name' => $runtimeClassNameDetails->getShortName()]
);
$generator->generateClass(
$runtimeClassNameDetails->getFullName(),
'twig/Runtime.tpl.php',
['use_statements' => $runtimeUseStatements]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new extension class and start customizing it.',
'Find the documentation at <fg=yellow>http://symfony.com/doc/current/templating/twig_extension.html</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
AbstractExtension::class,
'twig'
);
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
trigger_deprecation('symfony/maker-bundle', '1.29', 'The "%s" class is deprecated, use "%s" instead.', MakeUnitTest::class, MakeTest::class);
/**
* @deprecated since MakerBundle 1.29, use Symfony\Bundle\MakerBundle\Maker\MakeTest instead.
*
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeUnitTest extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:unit-test';
}
public static function getCommandDescription(): string
{
return 'Create a new unit test class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the unit test class (e.g. <fg=yellow>UtilTest</>)')
->setHelp($this->getHelpFileContents('MakeUnitTest.txt'))
;
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$testClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Tests\\',
'Test'
);
$generator->generateClass(
$testClassNameDetails->getFullName(),
'test/Unit.tpl.php',
['use_statements' => new UseStatementGenerator([TestCase::class])]
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new test class and start customizing it.',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/testing.html#unit-tests</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
}
}

View File

@@ -0,0 +1,262 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
use Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator;
use Symfony\Bundle\MakerBundle\Doctrine\ORMDependencyBuilder;
use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\Common\UidTrait;
use Symfony\Bundle\MakerBundle\Security\SecurityConfigUpdater;
use Symfony\Bundle\MakerBundle\Security\UserClassBuilder;
use Symfony\Bundle\MakerBundle\Security\UserClassConfiguration;
use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use Symfony\Bundle\MakerBundle\Util\YamlManipulationFailedException;
use Symfony\Bundle\MakerBundle\Validator;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Yaml\Yaml;
/**
* @author Ryan Weaver <weaverryan@gmail.com>
*
* @internal
*/
final class MakeUser extends AbstractMaker
{
use UidTrait;
public function __construct(
private FileManager $fileManager,
private UserClassBuilder $userClassBuilder,
private SecurityConfigUpdater $configUpdater,
private EntityClassGenerator $entityClassGenerator,
private DoctrineHelper $doctrineHelper,
) {
}
public static function getCommandName(): string
{
return 'make:user';
}
public static function getCommandDescription(): string
{
return 'Create a new security user class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the security user class (e.g. <fg=yellow>User</>)')
->addOption('is-entity', null, InputOption::VALUE_NONE, 'Do you want to store user data in the database (via Doctrine)?')
->addOption('identity-property-name', null, InputOption::VALUE_REQUIRED, 'Enter a property name that will be the unique "display" name for the user (e.g. <comment>email, username, uuid</comment>)')
->addOption('with-password', null, InputOption::VALUE_NONE, 'Will this app be responsible for checking the password? Choose <comment>No</comment> if the password is actually checked by some other system (e.g. a single sign-on server)')
->setHelp($this->getHelpFileContents('MakeUser.txt'))
;
$this->addWithUuidOption($command);
$inputConfig->setArgumentAsNonInteractive('name');
}
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
$this->checkIsUsingUid($input);
if (null === $input->getArgument('name')) {
$name = $io->ask(
$command->getDefinition()->getArgument('name')->getDescription(),
'User'
);
$input->setArgument('name', $name);
}
$userIsEntity = $io->confirm(
'Do you want to store user data in the database (via Doctrine)?',
class_exists(DoctrineBundle::class)
);
if ($userIsEntity) {
$dependencies = new DependencyBuilder();
ORMDependencyBuilder::buildDependencies($dependencies);
$missingPackagesMessage = $dependencies->getMissingPackagesMessage(self::getCommandName(), 'Doctrine must be installed to store user data in the database');
if ($missingPackagesMessage) {
throw new RuntimeCommandException($missingPackagesMessage);
}
}
$input->setOption('is-entity', $userIsEntity);
$identityFieldName = $io->ask('Enter a property name that will be the unique "display" name for the user (e.g. <comment>email, username, uuid</comment>)', 'email', Validator::validatePropertyName(...));
$input->setOption('identity-property-name', $identityFieldName);
$io->text('Will this app need to hash/check user passwords? Choose <comment>No</comment> if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).');
$userWillHavePassword = $io->confirm('Does this app need to hash/check user passwords?');
$input->setOption('with-password', $userWillHavePassword);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$userClassConfiguration = new UserClassConfiguration(
$input->getOption('is-entity'),
$input->getOption('identity-property-name'),
$input->getOption('with-password')
);
$userClassNameDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
$userClassConfiguration->isEntity() ? 'Entity\\' : 'Security\\'
);
// A) Generate the User class
if ($userClassConfiguration->isEntity()) {
$classPath = $this->entityClassGenerator->generateEntityClass(
entityClassDetails: $userClassNameDetails,
apiResource: false, // api resource
withPasswordUpgrade: $userClassConfiguration->hasPassword(), // security user
useUuidIdentifier: $this->getIdType()
);
} else {
$classPath = $generator->generateClass($userClassNameDetails->getFullName(), 'Class.tpl.php');
}
// need to write changes early so we can modify the contents below
$generator->writeChanges();
$entityUsesAttributes = ($isEntity = $userClassConfiguration->isEntity()) && $this->doctrineHelper->doesClassUsesAttributes($userClassNameDetails->getFullName());
if ($isEntity && !$entityUsesAttributes) {
throw new \RuntimeException('MakeUser only supports attribute mapping with doctrine entities.');
}
// B) Implement UserInterface
$manipulator = new ClassSourceManipulator(
sourceCode: $this->fileManager->getFileContents($classPath),
overwrite: true,
useAttributesForDoctrineMapping: $entityUsesAttributes
);
$manipulator->setIo($io);
$this->userClassBuilder->addUserInterfaceImplementation($manipulator, $userClassConfiguration);
$generator->dumpFile($classPath, $manipulator->getSourceCode());
// C) Generate a custom user provider, if necessary
if (!$userClassConfiguration->isEntity()) {
$userClassConfiguration->setUserProviderClass($generator->getRootNamespace().'\\Security\\UserProvider');
$useStatements = new UseStatementGenerator([
UnsupportedUserException::class,
UserNotFoundException::class,
PasswordAuthenticatedUserInterface::class,
PasswordUpgraderInterface::class,
UserInterface::class,
UserProviderInterface::class,
]);
$customProviderPath = $generator->generateClass(
$userClassConfiguration->getUserProviderClass(),
'security/UserProvider.tpl.php',
[
'use_statements' => $useStatements,
'user_short_name' => $userClassNameDetails->getShortName(),
]
);
}
// D) Update security.yaml
$securityYamlUpdated = false;
$path = 'config/packages/security.yaml';
if ($this->fileManager->fileExists($path)) {
try {
$newYaml = $this->configUpdater->updateForUserClass(
$this->fileManager->getFileContents($path),
$userClassConfiguration,
$userClassNameDetails->getFullName()
);
$generator->dumpFile($path, $newYaml);
$securityYamlUpdated = true;
} catch (YamlManipulationFailedException) {
}
}
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text('Next Steps:');
$nextSteps = [
\sprintf('Review your new <info>%s</info> class.', $userClassNameDetails->getFullName()),
];
if ($userClassConfiguration->isEntity()) {
$nextSteps[] = \sprintf(
'Use <comment>make:entity</comment> to add more fields to your <info>%s</info> entity and then run <comment>make:migration</comment>.',
$userClassNameDetails->getShortName()
);
} else {
$nextSteps[] = \sprintf(
'Open <info>%s</info> to finish implementing your user provider.',
/* @phpstan-ignore-next-line - $customProviderPath is defined in this else statement */
$this->fileManager->relativizePath($customProviderPath)
);
}
if (!$securityYamlUpdated) {
$yamlExample = $this->configUpdater->updateForUserClass(
'security: {}',
$userClassConfiguration,
$userClassNameDetails->getFullName()
);
$nextSteps[] = "Your <info>security.yaml</info> could not be updated automatically. You'll need to add the following config manually:\n\n".$yamlExample;
}
$nextSteps[] = 'Create a way to authenticate! See https://symfony.com/doc/current/security.html';
$nextSteps = array_map(static fn ($step) => \sprintf(' - %s', $step), $nextSteps);
$io->text($nextSteps);
}
public function configureDependencies(DependencyBuilder $dependencies, ?InputInterface $input = null): void
{
// checking for SecurityBundle guarantees security.yaml is present
$dependencies->addClassDependency(
SecurityBundle::class,
'security'
);
// needed to update the YAML files
$dependencies->addClassDependency(
Yaml::class,
'yaml'
);
if (null !== $input && $input->getOption('is-entity')) {
ORMDependencyBuilder::buildDependencies($dependencies);
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Validation;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeValidator extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:validator';
}
public static function getCommandDescription(): string
{
return 'Create a new validator and constraint class';
}
/** @return void */
public function configureCommand(Command $command, InputConfiguration $inputConf)
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the validator class (e.g. <fg=yellow>EnabledValidator</>)')
->setHelp($this->getHelpFileContents('MakeValidator.txt'))
;
}
/** @return void */
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator)
{
$validatorClassData = ClassData::create(
class: \sprintf('Validator\\%s', $input->getArgument('name')),
suffix: 'Validator',
extendsClass: ConstraintValidator::class,
useStatements: [
Constraint::class,
],
);
$constraintDataClass = ClassData::create(
class: \sprintf('Validator\\%s', Str::removeSuffix($validatorClassData->getClassName(), 'Validator')),
extendsClass: Constraint::class,
);
$generator->generateClassFromClassData(
$validatorClassData,
'validator/Validator.tpl.php',
[
'constraint_class_name' => $constraintDataClass->getClassName(),
]
);
$generator->generateClassFromClassData(
$constraintDataClass,
'validator/Constraint.tpl.php',
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your new constraint & validators and add your logic.',
'Find the documentation at <fg=yellow>http://symfony.com/doc/current/validation/custom_constraint.html</>',
]);
}
/** @return void */
public function configureDependencies(DependencyBuilder $dependencies)
{
$dependencies->addClassDependency(
Validation::class,
'validator'
);
}
}

View File

@@ -0,0 +1,85 @@
<?php
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Bundle\MakerBundle\Maker;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassData;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
* @author Ryan Weaver <weaverryan@gmail.com>
*/
final class MakeVoter extends AbstractMaker
{
public static function getCommandName(): string
{
return 'make:voter';
}
public static function getCommandDescription(): string
{
return 'Create a new security voter class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
$command
->addArgument('name', InputArgument::OPTIONAL, 'The name of the security voter class (e.g. <fg=yellow>BlogPostVoter</>)')
->setHelp($this->getHelpFileContents('MakeVoter.txt'))
;
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
$voterClassData = ClassData::create(
class: \sprintf('Security\Voter\%s', $input->getArgument('name')),
suffix: 'Voter',
extendsClass: Voter::class,
useStatements: [
TokenInterface::class,
Voter::class,
UserInterface::class,
]
);
$generator->generateClassFromClassData(
$voterClassData,
'security/Voter.tpl.php',
);
$generator->writeChanges();
$this->writeSuccessMessage($io);
$io->text([
'Next: Open your voter and add your logic.',
'Find the documentation at <fg=yellow>https://symfony.com/doc/current/security/voters.html</>',
]);
}
public function configureDependencies(DependencyBuilder $dependencies): void
{
$dependencies->addClassDependency(
Voter::class,
'security'
);
}
}

Some files were not shown because too many files have changed in this diff Show More