vendor/symfony/maker-bundle/src/Maker/MakeEntity.php line 329

Open in your IDE?
  1. <?php
  2. /*
  3. * This file is part of the Symfony MakerBundle package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Bundle\MakerBundle\Maker;
  11. use ApiPlatform\Core\Annotation\ApiResource;
  12. use Doctrine\DBAL\Types\Type;
  13. use Symfony\Bundle\MakerBundle\ConsoleStyle;
  14. use Symfony\Bundle\MakerBundle\DependencyBuilder;
  15. use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper;
  16. use Symfony\Bundle\MakerBundle\Doctrine\EntityClassGenerator;
  17. use Symfony\Bundle\MakerBundle\Doctrine\EntityRegenerator;
  18. use Symfony\Bundle\MakerBundle\Doctrine\EntityRelation;
  19. use Symfony\Bundle\MakerBundle\Doctrine\ORMDependencyBuilder;
  20. use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException;
  21. use Symfony\Bundle\MakerBundle\FileManager;
  22. use Symfony\Bundle\MakerBundle\Generator;
  23. use Symfony\Bundle\MakerBundle\InputAwareMakerInterface;
  24. use Symfony\Bundle\MakerBundle\InputConfiguration;
  25. use Symfony\Bundle\MakerBundle\Str;
  26. use Symfony\Bundle\MakerBundle\Util\ClassDetails;
  27. use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator;
  28. use Symfony\Bundle\MakerBundle\Util\PhpCompatUtil;
  29. use Symfony\Bundle\MakerBundle\Validator;
  30. use Symfony\Component\Console\Command\Command;
  31. use Symfony\Component\Console\Input\InputArgument;
  32. use Symfony\Component\Console\Input\InputInterface;
  33. use Symfony\Component\Console\Input\InputOption;
  34. use Symfony\Component\Console\Question\ConfirmationQuestion;
  35. use Symfony\Component\Console\Question\Question;
  36. use Symfony\UX\Turbo\Attribute\Broadcast;
  37. /**
  38. * @author Javier Eguiluz <javier.eguiluz@gmail.com>
  39. * @author Ryan Weaver <weaverryan@gmail.com>
  40. * @author Kévin Dunglas <dunglas@gmail.com>
  41. */
  42. final class MakeEntity extends AbstractMaker implements InputAwareMakerInterface
  43. {
  44. private $fileManager;
  45. private $doctrineHelper;
  46. private $generator;
  47. private $entityClassGenerator;
  48. private $phpCompatUtil;
  49. public function __construct(
  50. FileManager $fileManager,
  51. DoctrineHelper $doctrineHelper,
  52. string $projectDirectory = null,
  53. Generator $generator = null,
  54. EntityClassGenerator $entityClassGenerator = null,
  55. PhpCompatUtil $phpCompatUtil = null
  56. ) {
  57. $this->fileManager = $fileManager;
  58. $this->doctrineHelper = $doctrineHelper;
  59. if (null !== $projectDirectory) {
  60. @trigger_error('The $projectDirectory constructor argument is no longer used since 1.41.0', \E_USER_DEPRECATED);
  61. }
  62. if (null === $generator) {
  63. @trigger_error(sprintf('Passing a "%s" instance as 4th argument is mandatory since version 1.5.', Generator::class), \E_USER_DEPRECATED);
  64. $this->generator = new Generator($fileManager, 'App\\');
  65. } else {
  66. $this->generator = $generator;
  67. }
  68. if (null === $entityClassGenerator) {
  69. @trigger_error(sprintf('Passing a "%s" instance as 5th argument is mandatory since version 1.15.1', EntityClassGenerator::class), \E_USER_DEPRECATED);
  70. $this->entityClassGenerator = new EntityClassGenerator($generator, $this->doctrineHelper);
  71. } else {
  72. $this->entityClassGenerator = $entityClassGenerator;
  73. }
  74. if (null === $phpCompatUtil) {
  75. @trigger_error(sprintf('Passing a "%s" instance as 6th argument is mandatory since version 1.41.0', PhpCompatUtil::class), \E_USER_DEPRECATED);
  76. $this->phpCompatUtil = new PhpCompatUtil($this->fileManager);
  77. } else {
  78. $this->phpCompatUtil = $phpCompatUtil;
  79. }
  80. }
  81. public static function getCommandName(): string
  82. {
  83. return 'make:entity';
  84. }
  85. public static function getCommandDescription(): string
  86. {
  87. return 'Creates or updates a Doctrine entity class, and optionally an API Platform resource';
  88. }
  89. public function configureCommand(Command $command, InputConfiguration $inputConfig): void
  90. {
  91. $command
  92. ->addArgument('name', InputArgument::OPTIONAL, sprintf('Class name of the entity to create or update (e.g. <fg=yellow>%s</>)', Str::asClassName(Str::getRandomTerm())))
  93. ->addOption('api-resource', 'a', InputOption::VALUE_NONE, 'Mark this class as an API Platform resource (expose a CRUD API for it)')
  94. ->addOption('broadcast', 'b', InputOption::VALUE_NONE, 'Add the ability to broadcast entity updates using Symfony UX Turbo?')
  95. ->addOption('regenerate', null, InputOption::VALUE_NONE, 'Instead of adding new fields, simply generate the methods (e.g. getter/setter) for existing fields')
  96. ->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite any existing getter/setter methods')
  97. ->setHelp(file_get_contents(__DIR__.'/../Resources/help/MakeEntity.txt'))
  98. ;
  99. $inputConfig->setArgumentAsNonInteractive('name');
  100. }
  101. public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
  102. {
  103. if ($input->getArgument('name')) {
  104. return;
  105. }
  106. if ($input->getOption('regenerate')) {
  107. $io->block([
  108. 'This command will generate any missing methods (e.g. getters & setters) for a class or all classes in a namespace.',
  109. 'To overwrite any existing methods, re-run this command with the --overwrite flag',
  110. ], null, 'fg=yellow');
  111. $classOrNamespace = $io->ask('Enter a class or namespace to regenerate', $this->getEntityNamespace(), [Validator::class, 'notBlank']);
  112. $input->setArgument('name', $classOrNamespace);
  113. return;
  114. }
  115. $argument = $command->getDefinition()->getArgument('name');
  116. $question = $this->createEntityClassQuestion($argument->getDescription());
  117. $entityClassName = $io->askQuestion($question);
  118. $input->setArgument('name', $entityClassName);
  119. if (
  120. !$input->getOption('api-resource') &&
  121. class_exists(ApiResource::class) &&
  122. !class_exists($this->generator->createClassNameDetails($entityClassName, 'Entity\\')->getFullName())
  123. ) {
  124. $description = $command->getDefinition()->getOption('api-resource')->getDescription();
  125. $question = new ConfirmationQuestion($description, false);
  126. $isApiResource = $io->askQuestion($question);
  127. $input->setOption('api-resource', $isApiResource);
  128. }
  129. if (
  130. !$input->getOption('broadcast') &&
  131. class_exists(Broadcast::class) &&
  132. !class_exists($this->generator->createClassNameDetails($entityClassName, 'Entity\\')->getFullName())
  133. ) {
  134. $description = $command->getDefinition()->getOption('broadcast')->getDescription();
  135. $question = new ConfirmationQuestion($description, false);
  136. $isBroadcast = $io->askQuestion($question);
  137. $input->setOption('broadcast', $isBroadcast);
  138. }
  139. }
  140. public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
  141. {
  142. $overwrite = $input->getOption('overwrite');
  143. // the regenerate option has entirely custom behavior
  144. if ($input->getOption('regenerate')) {
  145. $this->regenerateEntities($input->getArgument('name'), $overwrite, $generator);
  146. $this->writeSuccessMessage($io);
  147. return;
  148. }
  149. $entityClassDetails = $generator->createClassNameDetails(
  150. $input->getArgument('name'),
  151. 'Entity\\'
  152. );
  153. if (!$this->doctrineHelper->isDoctrineSupportingAttributes() && $this->doctrineHelper->doesClassUsesAttributes($entityClassDetails->getFullName())) {
  154. throw new RuntimeCommandException('To use Doctrine entity attributes you\'ll need PHP 8, doctrine/orm 2.9, doctrine/doctrine-bundle 2.4 and symfony/framework-bundle 5.2.');
  155. }
  156. $classExists = class_exists($entityClassDetails->getFullName());
  157. if (!$classExists) {
  158. $broadcast = $input->getOption('broadcast');
  159. $entityPath = $this->entityClassGenerator->generateEntityClass(
  160. $entityClassDetails,
  161. $input->getOption('api-resource'),
  162. false,
  163. true,
  164. $broadcast
  165. );
  166. if ($broadcast) {
  167. $shortName = $entityClassDetails->getShortName();
  168. $generator->generateTemplate(
  169. sprintf('broadcast/%s.stream.html.twig', $shortName),
  170. 'doctrine/broadcast_twig_template.tpl.php',
  171. [
  172. 'class_name' => Str::asSnakeCase($shortName),
  173. 'class_name_plural' => Str::asSnakeCase(Str::singularCamelCaseToPluralCamelCase($shortName)),
  174. ]
  175. );
  176. }
  177. $generator->writeChanges();
  178. }
  179. if (
  180. !$this->doesEntityUseAnnotationMapping($entityClassDetails->getFullName())
  181. && !$this->doesEntityUseAttributeMapping($entityClassDetails->getFullName())
  182. ) {
  183. throw new RuntimeCommandException(sprintf('Only annotation or attribute mapping is supported by make:entity, but the <info>%s</info> class uses a different format. If you would like this command to generate the properties & getter/setter methods, add your mapping configuration, and then re-run this command with the <info>--regenerate</info> flag.', $entityClassDetails->getFullName()));
  184. }
  185. if ($classExists) {
  186. $entityPath = $this->getPathOfClass($entityClassDetails->getFullName());
  187. $io->text([
  188. 'Your entity already exists! So let\'s add some new fields!',
  189. ]);
  190. } else {
  191. $io->text([
  192. '',
  193. 'Entity generated! Now let\'s add some fields!',
  194. 'You can always add more fields later manually or by re-running this command.',
  195. ]);
  196. }
  197. $currentFields = $this->getPropertyNames($entityClassDetails->getFullName());
  198. $manipulator = $this->createClassManipulator($entityPath, $io, $overwrite, $entityClassDetails->getFullName());
  199. $isFirstField = true;
  200. while (true) {
  201. $newField = $this->askForNextField($io, $currentFields, $entityClassDetails->getFullName(), $isFirstField);
  202. $isFirstField = false;
  203. if (null === $newField) {
  204. break;
  205. }
  206. $fileManagerOperations = [];
  207. $fileManagerOperations[$entityPath] = $manipulator;
  208. if (\is_array($newField)) {
  209. $annotationOptions = $newField;
  210. unset($annotationOptions['fieldName']);
  211. $manipulator->addEntityField($newField['fieldName'], $annotationOptions);
  212. $currentFields[] = $newField['fieldName'];
  213. } elseif ($newField instanceof EntityRelation) {
  214. // both overridden below for OneToMany
  215. $newFieldName = $newField->getOwningProperty();
  216. if ($newField->isSelfReferencing()) {
  217. $otherManipulatorFilename = $entityPath;
  218. $otherManipulator = $manipulator;
  219. } else {
  220. $otherManipulatorFilename = $this->getPathOfClass($newField->getInverseClass());
  221. $otherManipulator = $this->createClassManipulator($otherManipulatorFilename, $io, $overwrite, $entityClassDetails->getFullName());
  222. }
  223. switch ($newField->getType()) {
  224. case EntityRelation::MANY_TO_ONE:
  225. if ($newField->getOwningClass() === $entityClassDetails->getFullName()) {
  226. // THIS class will receive the ManyToOne
  227. $manipulator->addManyToOneRelation($newField->getOwningRelation());
  228. if ($newField->getMapInverseRelation()) {
  229. $otherManipulator->addOneToManyRelation($newField->getInverseRelation());
  230. }
  231. } else {
  232. // the new field being added to THIS entity is the inverse
  233. $newFieldName = $newField->getInverseProperty();
  234. $otherManipulatorFilename = $this->getPathOfClass($newField->getOwningClass());
  235. $otherManipulator = $this->createClassManipulator($otherManipulatorFilename, $io, $overwrite, $entityClassDetails->getFullName());
  236. // The *other* class will receive the ManyToOne
  237. $otherManipulator->addManyToOneRelation($newField->getOwningRelation());
  238. if (!$newField->getMapInverseRelation()) {
  239. throw new \Exception('Somehow a OneToMany relationship is being created, but the inverse side will not be mapped?');
  240. }
  241. $manipulator->addOneToManyRelation($newField->getInverseRelation());
  242. }
  243. break;
  244. case EntityRelation::MANY_TO_MANY:
  245. $manipulator->addManyToManyRelation($newField->getOwningRelation());
  246. if ($newField->getMapInverseRelation()) {
  247. $otherManipulator->addManyToManyRelation($newField->getInverseRelation());
  248. }
  249. break;
  250. case EntityRelation::ONE_TO_ONE:
  251. $manipulator->addOneToOneRelation($newField->getOwningRelation());
  252. if ($newField->getMapInverseRelation()) {
  253. $otherManipulator->addOneToOneRelation($newField->getInverseRelation());
  254. }
  255. break;
  256. default:
  257. throw new \Exception('Invalid relation type');
  258. }
  259. // save the inverse side if it's being mapped
  260. if ($newField->getMapInverseRelation()) {
  261. $fileManagerOperations[$otherManipulatorFilename] = $otherManipulator;
  262. }
  263. $currentFields[] = $newFieldName;
  264. } else {
  265. throw new \Exception('Invalid value');
  266. }
  267. foreach ($fileManagerOperations as $path => $manipulatorOrMessage) {
  268. if (\is_string($manipulatorOrMessage)) {
  269. $io->comment($manipulatorOrMessage);
  270. } else {
  271. $this->fileManager->dumpFile($path, $manipulatorOrMessage->getSourceCode());
  272. }
  273. }
  274. }
  275. $this->writeSuccessMessage($io);
  276. $io->text([
  277. 'Next: When you\'re ready, create a migration with <info>php bin/console make:migration</info>',
  278. '',
  279. ]);
  280. }
  281. public function configureDependencies(DependencyBuilder $dependencies, InputInterface $input = null): void
  282. {
  283. if (null !== $input && $input->getOption('api-resource')) {
  284. $dependencies->addClassDependency(
  285. ApiResource::class,
  286. 'api'
  287. );
  288. }
  289. if (null !== $input && $input->getOption('broadcast')) {
  290. $dependencies->addClassDependency(
  291. Broadcast::class,
  292. 'ux-turbo-mercure'
  293. );
  294. }
  295. ORMDependencyBuilder::buildDependencies($dependencies);
  296. }
  297. private function askForNextField(ConsoleStyle $io, array $fields, string $entityClass, bool $isFirstField)
  298. {
  299. $io->writeln('');
  300. if ($isFirstField) {
  301. $questionText = 'New property name (press <return> to stop adding fields)';
  302. } else {
  303. $questionText = 'Add another property? Enter the property name (or press <return> to stop adding fields)';
  304. }
  305. $fieldName = $io->ask($questionText, null, function ($name) use ($fields) {
  306. // allow it to be empty
  307. if (!$name) {
  308. return $name;
  309. }
  310. if (\in_array($name, $fields)) {
  311. throw new \InvalidArgumentException(sprintf('The "%s" property already exists.', $name));
  312. }
  313. return Validator::validateDoctrineFieldName($name, $this->doctrineHelper->getRegistry());
  314. });
  315. if (!$fieldName) {
  316. return null;
  317. }
  318. $defaultType = 'string';
  319. // try to guess the type by the field name prefix/suffix
  320. // convert to snake case for simplicity
  321. $snakeCasedField = Str::asSnakeCase($fieldName);
  322. if ('_at' === $suffix = substr($snakeCasedField, -3)) {
  323. $defaultType = 'datetime_immutable';
  324. } elseif ('_id' === $suffix) {
  325. $defaultType = 'integer';
  326. } elseif (0 === strpos($snakeCasedField, 'is_')) {
  327. $defaultType = 'boolean';
  328. } elseif (0 === strpos($snakeCasedField, 'has_')) {
  329. $defaultType = 'boolean';
  330. } elseif ('uuid' === $snakeCasedField) {
  331. $defaultType = 'uuid';
  332. } elseif ('guid' === $snakeCasedField) {
  333. $defaultType = 'guid';
  334. }
  335. $type = null;
  336. $types = $this->getTypesMap();
  337. $allValidTypes = array_merge(
  338. array_keys($types),
  339. EntityRelation::getValidRelationTypes(),
  340. ['relation']
  341. );
  342. while (null === $type) {
  343. $question = new Question('Field type (enter <comment>?</comment> to see all types)', $defaultType);
  344. $question->setAutocompleterValues($allValidTypes);
  345. $type = $io->askQuestion($question);
  346. if ('?' === $type) {
  347. $this->printAvailableTypes($io);
  348. $io->writeln('');
  349. $type = null;
  350. } elseif (!\in_array($type, $allValidTypes)) {
  351. $this->printAvailableTypes($io);
  352. $io->error(sprintf('Invalid type "%s".', $type));
  353. $io->writeln('');
  354. $type = null;
  355. }
  356. }
  357. if ('relation' === $type || \in_array($type, EntityRelation::getValidRelationTypes())) {
  358. return $this->askRelationDetails($io, $entityClass, $type, $fieldName);
  359. }
  360. // this is a normal field
  361. $data = ['fieldName' => $fieldName, 'type' => $type];
  362. if ('string' === $type) {
  363. // default to 255, avoid the question
  364. $data['length'] = $io->ask('Field length', 255, [Validator::class, 'validateLength']);
  365. } elseif ('decimal' === $type) {
  366. // 10 is the default value given in \Doctrine\DBAL\Schema\Column::$_precision
  367. $data['precision'] = $io->ask('Precision (total number of digits stored: 100.00 would be 5)', 10, [Validator::class, 'validatePrecision']);
  368. // 0 is the default value given in \Doctrine\DBAL\Schema\Column::$_scale
  369. $data['scale'] = $io->ask('Scale (number of decimals to store: 100.00 would be 2)', 0, [Validator::class, 'validateScale']);
  370. }
  371. if ($io->confirm('Can this field be null in the database (nullable)', false)) {
  372. $data['nullable'] = true;
  373. }
  374. return $data;
  375. }
  376. private function printAvailableTypes(ConsoleStyle $io): void
  377. {
  378. $allTypes = $this->getTypesMap();
  379. if ('Hyper' === getenv('TERM_PROGRAM')) {
  380. $wizard = 'wizard 🧙';
  381. } else {
  382. $wizard = '\\' === \DIRECTORY_SEPARATOR ? 'wizard' : 'wizard 🧙';
  383. }
  384. $typesTable = [
  385. 'main' => [
  386. 'string' => [],
  387. 'text' => [],
  388. 'boolean' => [],
  389. 'integer' => ['smallint', 'bigint'],
  390. 'float' => [],
  391. ],
  392. 'relation' => [
  393. 'relation' => 'a '.$wizard.' will help you build the relation',
  394. EntityRelation::MANY_TO_ONE => [],
  395. EntityRelation::ONE_TO_MANY => [],
  396. EntityRelation::MANY_TO_MANY => [],
  397. EntityRelation::ONE_TO_ONE => [],
  398. ],
  399. 'array_object' => [
  400. 'array' => ['simple_array'],
  401. 'json' => [],
  402. 'object' => [],
  403. 'binary' => [],
  404. 'blob' => [],
  405. ],
  406. 'date_time' => [
  407. 'datetime' => ['datetime_immutable'],
  408. 'datetimetz' => ['datetimetz_immutable'],
  409. 'date' => ['date_immutable'],
  410. 'time' => ['time_immutable'],
  411. 'dateinterval' => [],
  412. ],
  413. ];
  414. $printSection = function (array $sectionTypes) use ($io, &$allTypes) {
  415. foreach ($sectionTypes as $mainType => $subTypes) {
  416. unset($allTypes[$mainType]);
  417. $line = sprintf(' * <comment>%s</comment>', $mainType);
  418. if (\is_string($subTypes) && $subTypes) {
  419. $line .= sprintf(' (%s)', $subTypes);
  420. } elseif (\is_array($subTypes) && !empty($subTypes)) {
  421. $line .= sprintf(' (or %s)', implode(', ', array_map(function ($subType) {
  422. return sprintf('<comment>%s</comment>', $subType);
  423. }, $subTypes)));
  424. foreach ($subTypes as $subType) {
  425. unset($allTypes[$subType]);
  426. }
  427. }
  428. $io->writeln($line);
  429. }
  430. $io->writeln('');
  431. };
  432. $io->writeln('<info>Main types</info>');
  433. $printSection($typesTable['main']);
  434. $io->writeln('<info>Relationships / Associations</info>');
  435. $printSection($typesTable['relation']);
  436. $io->writeln('<info>Array/Object Types</info>');
  437. $printSection($typesTable['array_object']);
  438. $io->writeln('<info>Date/Time Types</info>');
  439. $printSection($typesTable['date_time']);
  440. $io->writeln('<info>Other Types</info>');
  441. // empty the values
  442. $allTypes = array_map(function () {
  443. return [];
  444. }, $allTypes);
  445. $printSection($allTypes);
  446. }
  447. private function createEntityClassQuestion(string $questionText): Question
  448. {
  449. $question = new Question($questionText);
  450. $question->setValidator([Validator::class, 'notBlank']);
  451. $question->setAutocompleterValues($this->doctrineHelper->getEntitiesForAutocomplete());
  452. return $question;
  453. }
  454. private function askRelationDetails(ConsoleStyle $io, string $generatedEntityClass, string $type, string $newFieldName)
  455. {
  456. // ask the targetEntity
  457. $targetEntityClass = null;
  458. while (null === $targetEntityClass) {
  459. $question = $this->createEntityClassQuestion('What class should this entity be related to?');
  460. $answeredEntityClass = $io->askQuestion($question);
  461. // find the correct class name - but give priority over looking
  462. // in the Entity namespace versus just checking the full class
  463. // name to avoid issues with classes like "Directory" that exist
  464. // in PHP's core.
  465. if (class_exists($this->getEntityNamespace().'\\'.$answeredEntityClass)) {
  466. $targetEntityClass = $this->getEntityNamespace().'\\'.$answeredEntityClass;
  467. } elseif (class_exists($answeredEntityClass)) {
  468. $targetEntityClass = $answeredEntityClass;
  469. } else {
  470. $io->error(sprintf('Unknown class "%s"', $answeredEntityClass));
  471. continue;
  472. }
  473. }
  474. // help the user select the type
  475. if ('relation' === $type) {
  476. $type = $this->askRelationType($io, $generatedEntityClass, $targetEntityClass);
  477. }
  478. $askFieldName = function (string $targetClass, string $defaultValue) use ($io) {
  479. return $io->ask(
  480. sprintf('New field name inside %s', Str::getShortClassName($targetClass)),
  481. $defaultValue,
  482. function ($name) use ($targetClass) {
  483. // it's still *possible* to create duplicate properties - by
  484. // trying to generate the same property 2 times during the
  485. // same make:entity run. property_exists() only knows about
  486. // properties that *originally* existed on this class.
  487. if (property_exists($targetClass, $name)) {
  488. throw new \InvalidArgumentException(sprintf('The "%s" class already has a "%s" property.', $targetClass, $name));
  489. }
  490. return Validator::validateDoctrineFieldName($name, $this->doctrineHelper->getRegistry());
  491. }
  492. );
  493. };
  494. $askIsNullable = function (string $propertyName, string $targetClass) use ($io) {
  495. return $io->confirm(sprintf(
  496. 'Is the <comment>%s</comment>.<comment>%s</comment> property allowed to be null (nullable)?',
  497. Str::getShortClassName($targetClass),
  498. $propertyName
  499. ));
  500. };
  501. $askOrphanRemoval = function (string $owningClass, string $inverseClass) use ($io) {
  502. $io->text([
  503. 'Do you want to activate <comment>orphanRemoval</comment> on your relationship?',
  504. sprintf(
  505. 'A <comment>%s</comment> is "orphaned" when it is removed from its related <comment>%s</comment>.',
  506. Str::getShortClassName($owningClass),
  507. Str::getShortClassName($inverseClass)
  508. ),
  509. sprintf(
  510. 'e.g. <comment>$%s->remove%s($%s)</comment>',
  511. Str::asLowerCamelCase(Str::getShortClassName($inverseClass)),
  512. Str::asCamelCase(Str::getShortClassName($owningClass)),
  513. Str::asLowerCamelCase(Str::getShortClassName($owningClass))
  514. ),
  515. '',
  516. sprintf(
  517. 'NOTE: If a <comment>%s</comment> may *change* from one <comment>%s</comment> to another, answer "no".',
  518. Str::getShortClassName($owningClass),
  519. Str::getShortClassName($inverseClass)
  520. ),
  521. ]);
  522. return $io->confirm(sprintf('Do you want to automatically delete orphaned <comment>%s</comment> objects (orphanRemoval)?', $owningClass), false);
  523. };
  524. $askInverseSide = function (EntityRelation $relation) use ($io) {
  525. if ($this->isClassInVendor($relation->getInverseClass())) {
  526. $relation->setMapInverseRelation(false);
  527. return;
  528. }
  529. // recommend an inverse side, except for OneToOne, where it's inefficient
  530. $recommendMappingInverse = EntityRelation::ONE_TO_ONE !== $relation->getType();
  531. $getterMethodName = 'get'.Str::asCamelCase(Str::getShortClassName($relation->getOwningClass()));
  532. if (EntityRelation::ONE_TO_ONE !== $relation->getType()) {
  533. // pluralize!
  534. $getterMethodName = Str::singularCamelCaseToPluralCamelCase($getterMethodName);
  535. }
  536. $mapInverse = $io->confirm(
  537. sprintf(
  538. '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>?',
  539. Str::getShortClassName($relation->getInverseClass()),
  540. Str::getShortClassName($relation->getOwningClass()),
  541. Str::asLowerCamelCase(Str::getShortClassName($relation->getInverseClass())),
  542. $getterMethodName
  543. ),
  544. $recommendMappingInverse
  545. );
  546. $relation->setMapInverseRelation($mapInverse);
  547. };
  548. switch ($type) {
  549. case EntityRelation::MANY_TO_ONE:
  550. $relation = new EntityRelation(
  551. EntityRelation::MANY_TO_ONE,
  552. $generatedEntityClass,
  553. $targetEntityClass
  554. );
  555. $relation->setOwningProperty($newFieldName);
  556. $relation->setIsNullable($askIsNullable(
  557. $relation->getOwningProperty(),
  558. $relation->getOwningClass()
  559. ));
  560. $askInverseSide($relation);
  561. if ($relation->getMapInverseRelation()) {
  562. $io->comment(sprintf(
  563. '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.',
  564. Str::getShortClassName($relation->getInverseClass()),
  565. Str::getShortClassName($relation->getOwningClass())
  566. ));
  567. $relation->setInverseProperty($askFieldName(
  568. $relation->getInverseClass(),
  569. Str::singularCamelCaseToPluralCamelCase(Str::getShortClassName($relation->getOwningClass()))
  570. ));
  571. // orphan removal only applies if the inverse relation is set
  572. if (!$relation->isNullable()) {
  573. $relation->setOrphanRemoval($askOrphanRemoval(
  574. $relation->getOwningClass(),
  575. $relation->getInverseClass()
  576. ));
  577. }
  578. }
  579. break;
  580. case EntityRelation::ONE_TO_MANY:
  581. // we *actually* create a ManyToOne, but populate it differently
  582. $relation = new EntityRelation(
  583. EntityRelation::MANY_TO_ONE,
  584. $targetEntityClass,
  585. $generatedEntityClass
  586. );
  587. $relation->setInverseProperty($newFieldName);
  588. $io->comment(sprintf(
  589. '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.',
  590. Str::getShortClassName($relation->getOwningClass()),
  591. Str::getShortClassName($relation->getInverseClass())
  592. ));
  593. $relation->setOwningProperty($askFieldName(
  594. $relation->getOwningClass(),
  595. Str::asLowerCamelCase(Str::getShortClassName($relation->getInverseClass()))
  596. ));
  597. $relation->setIsNullable($askIsNullable(
  598. $relation->getOwningProperty(),
  599. $relation->getOwningClass()
  600. ));
  601. if (!$relation->isNullable()) {
  602. $relation->setOrphanRemoval($askOrphanRemoval(
  603. $relation->getOwningClass(),
  604. $relation->getInverseClass()
  605. ));
  606. }
  607. break;
  608. case EntityRelation::MANY_TO_MANY:
  609. $relation = new EntityRelation(
  610. EntityRelation::MANY_TO_MANY,
  611. $generatedEntityClass,
  612. $targetEntityClass
  613. );
  614. $relation->setOwningProperty($newFieldName);
  615. $askInverseSide($relation);
  616. if ($relation->getMapInverseRelation()) {
  617. $io->comment(sprintf(
  618. '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.',
  619. Str::getShortClassName($relation->getInverseClass()),
  620. Str::getShortClassName($relation->getOwningClass())
  621. ));
  622. $relation->setInverseProperty($askFieldName(
  623. $relation->getInverseClass(),
  624. Str::singularCamelCaseToPluralCamelCase(Str::getShortClassName($relation->getOwningClass()))
  625. ));
  626. }
  627. break;
  628. case EntityRelation::ONE_TO_ONE:
  629. $relation = new EntityRelation(
  630. EntityRelation::ONE_TO_ONE,
  631. $generatedEntityClass,
  632. $targetEntityClass
  633. );
  634. $relation->setOwningProperty($newFieldName);
  635. $relation->setIsNullable($askIsNullable(
  636. $relation->getOwningProperty(),
  637. $relation->getOwningClass()
  638. ));
  639. $askInverseSide($relation);
  640. if ($relation->getMapInverseRelation()) {
  641. $io->comment(sprintf(
  642. '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.',
  643. Str::getShortClassName($relation->getInverseClass()),
  644. Str::getShortClassName($relation->getOwningClass())
  645. ));
  646. $relation->setInverseProperty($askFieldName(
  647. $relation->getInverseClass(),
  648. Str::asLowerCamelCase(Str::getShortClassName($relation->getOwningClass()))
  649. ));
  650. }
  651. break;
  652. default:
  653. throw new \InvalidArgumentException('Invalid type: '.$type);
  654. }
  655. return $relation;
  656. }
  657. private function askRelationType(ConsoleStyle $io, string $entityClass, string $targetEntityClass)
  658. {
  659. $io->writeln('What type of relationship is this?');
  660. $originalEntityShort = Str::getShortClassName($entityClass);
  661. $targetEntityShort = Str::getShortClassName($targetEntityClass);
  662. $rows = [];
  663. $rows[] = [
  664. EntityRelation::MANY_TO_ONE,
  665. 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),
  666. ];
  667. $rows[] = ['', ''];
  668. $rows[] = [
  669. EntityRelation::ONE_TO_MANY,
  670. 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),
  671. ];
  672. $rows[] = ['', ''];
  673. $rows[] = [
  674. EntityRelation::MANY_TO_MANY,
  675. 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),
  676. ];
  677. $rows[] = ['', ''];
  678. $rows[] = [
  679. EntityRelation::ONE_TO_ONE,
  680. 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),
  681. ];
  682. $io->table([
  683. 'Type',
  684. 'Description',
  685. ], $rows);
  686. $question = new Question(sprintf(
  687. 'Relation type? [%s]',
  688. implode(', ', EntityRelation::getValidRelationTypes())
  689. ));
  690. $question->setAutocompleterValues(EntityRelation::getValidRelationTypes());
  691. $question->setValidator(function ($type) {
  692. if (!\in_array($type, EntityRelation::getValidRelationTypes())) {
  693. throw new \InvalidArgumentException(sprintf('Invalid type: use one of: %s', implode(', ', EntityRelation::getValidRelationTypes())));
  694. }
  695. return $type;
  696. });
  697. return $io->askQuestion($question);
  698. }
  699. private function createClassManipulator(string $path, ConsoleStyle $io, bool $overwrite, string $className): ClassSourceManipulator
  700. {
  701. $useAttributes = $this->doctrineHelper->doesClassUsesAttributes($className) && $this->doctrineHelper->isDoctrineSupportingAttributes();
  702. $useAnnotations = $this->doctrineHelper->isClassAnnotated($className) || !$useAttributes;
  703. $manipulator = new ClassSourceManipulator($this->fileManager->getFileContents($path), $overwrite, $useAnnotations, true, $useAttributes);
  704. $manipulator->setIo($io);
  705. return $manipulator;
  706. }
  707. private function getPathOfClass(string $class): string
  708. {
  709. return (new ClassDetails($class))->getPath();
  710. }
  711. private function isClassInVendor(string $class): bool
  712. {
  713. $path = $this->getPathOfClass($class);
  714. return $this->fileManager->isPathInVendor($path);
  715. }
  716. private function regenerateEntities(string $classOrNamespace, bool $overwrite, Generator $generator): void
  717. {
  718. $regenerator = new EntityRegenerator($this->doctrineHelper, $this->fileManager, $generator, $this->entityClassGenerator, $overwrite);
  719. $regenerator->regenerateEntities($classOrNamespace);
  720. }
  721. private function getPropertyNames(string $class): array
  722. {
  723. if (!class_exists($class)) {
  724. return [];
  725. }
  726. $reflClass = new \ReflectionClass($class);
  727. return array_map(function (\ReflectionProperty $prop) {
  728. return $prop->getName();
  729. }, $reflClass->getProperties());
  730. }
  731. /** @legacy Drop when Annotations are no longer supported */
  732. private function doesEntityUseAnnotationMapping(string $className): bool
  733. {
  734. if (!class_exists($className)) {
  735. $otherClassMetadatas = $this->doctrineHelper->getMetadata(Str::getNamespace($className).'\\', true);
  736. // if we have no metadata, we should assume this is the first class being mapped
  737. if (empty($otherClassMetadatas)) {
  738. return false;
  739. }
  740. $className = reset($otherClassMetadatas)->getName();
  741. }
  742. return $this->doctrineHelper->isClassAnnotated($className);
  743. }
  744. /** @legacy Drop when Annotations are no longer supported */
  745. private function doesEntityUseAttributeMapping(string $className): bool
  746. {
  747. if (!$this->phpCompatUtil->canUseAttributes()) {
  748. return false;
  749. }
  750. if (!class_exists($className)) {
  751. $otherClassMetadatas = $this->doctrineHelper->getMetadata(Str::getNamespace($className).'\\', true);
  752. // if we have no metadata, we should assume this is the first class being mapped
  753. if (empty($otherClassMetadatas)) {
  754. return false;
  755. }
  756. $className = reset($otherClassMetadatas)->getName();
  757. }
  758. return $this->doctrineHelper->doesClassUsesAttributes($className);
  759. }
  760. private function getEntityNamespace(): string
  761. {
  762. return $this->doctrineHelper->getEntityNamespace();
  763. }
  764. private function getTypesMap(): array
  765. {
  766. $types = Type::getTypesMap();
  767. // remove deprecated json_array if it exists
  768. if (\defined(sprintf('%s::JSON_ARRAY', Type::class))) {
  769. unset($types[Type::JSON_ARRAY]);
  770. }
  771. return $types;
  772. }
  773. }