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

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