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

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