SchemaSnippetsMaker.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Service\Doctrine\SchemaValidation;
  4. use ApiPlatform\Metadata\ApiResource;
  5. use App\Service\Utils\EntityUtils;
  6. use App\Service\Utils\FileUtils;
  7. use App\Service\Utils\Path;
  8. use Doctrine\Common\Collections\Collection;
  9. use Doctrine\ORM\Mapping\ClassMetadataInfo;
  10. use Doctrine\ORM\Mapping\Column;
  11. use Doctrine\ORM\Mapping\Entity;
  12. use Doctrine\ORM\Mapping\JoinColumn;
  13. use Doctrine\ORM\Mapping\ManyToMany;
  14. use Doctrine\ORM\Mapping\ManyToOne;
  15. use Doctrine\ORM\Mapping\OneToMany;
  16. use Doctrine\ORM\Mapping\OneToOne;
  17. use Nette\InvalidStateException;
  18. use Nette\PhpGenerator\Attribute;
  19. use Nette\PhpGenerator\ClassType;
  20. use Nette\PhpGenerator\Method;
  21. use Nette\PhpGenerator\Parameter;
  22. use Nette\PhpGenerator\PhpFile;
  23. use Nette\PhpGenerator\PhpNamespace;
  24. use Nette\PhpGenerator\Property;
  25. use Nette\PhpGenerator\PsrPrinter;
  26. /**
  27. * Service produisant les snippets des entités, propriétés et méthodes
  28. * manquantes dans les entités de la V2.
  29. *
  30. * Les résultats sont enregistrés dans le répertoire `~/schema_validation_snippets`.
  31. *
  32. * @see https://github.com/nette/php-generator
  33. */
  34. class SchemaSnippetsMaker
  35. {
  36. public function __construct(
  37. private readonly EntityUtils $entityUtils,
  38. ) {
  39. }
  40. /**
  41. * Make entities snippets from a 'diff' array,
  42. * as generated by SchemaValidationService::validateSchema().
  43. *
  44. * @param array<string, Difference|array<Difference>> $diff
  45. */
  46. public function makeSnippets(array $diff): void
  47. {
  48. $snippetsDir = $this->getSnippetsDir();
  49. $this->prepareSnippetsDir($snippetsDir);
  50. foreach ($diff as $entity => $differences) {
  51. if (empty($differences)) {
  52. continue;
  53. }
  54. $class = $this->makeSnippetEntityClass($entity);
  55. $methods = [];
  56. $collections = [];
  57. $expectedFields = [];
  58. if (!is_array($differences)) {
  59. // New entity
  60. foreach ($differences->getExpectedType() as $field => $type) {
  61. $expectedFields[$field] = $type;
  62. }
  63. } else {
  64. // Existing entity
  65. foreach ($differences as $field => $difference) {
  66. $expectedFields[$field] = $difference->getExpectedType();
  67. }
  68. }
  69. foreach ($expectedFields as $field => $expectedType) {
  70. $prop = is_array($expectedType) ?
  71. $this->makeSnippetEntityCollectionProp($field, $expectedType) :
  72. $this->makeSnippetEntitySimpleProp($field, $expectedType);
  73. $class->addMember($prop);
  74. if ($prop->getType() === Collection::class) {
  75. $collections[] = $prop;
  76. }
  77. $methods = [...$methods, ...$this->makeMethodsSnippetForProp($prop)];
  78. }
  79. if ($collections) {
  80. $class->addMember(
  81. $this->makeSnippetConstructor($collections)
  82. );
  83. }
  84. foreach ($methods as $method) {
  85. $class->addMember($method);
  86. }
  87. $file = $this->makeFileSnippet();
  88. $namespace = $this->makeNamespaceSnippet($entity);
  89. if (is_array($differences)) {
  90. foreach ($this->getEntityToImportFromRelations($differences) as $use) {
  91. $namespace->addUse($use);
  92. }
  93. }
  94. try {
  95. $namespace->add($class);
  96. } catch (InvalidStateException $e) {
  97. var_dump($e->getMessage());
  98. continue;
  99. }
  100. $file->addNamespace($namespace);
  101. $fileName = $this->getSnippetPath($snippetsDir, $entity);
  102. $this->writeSnippet($file, $fileName);
  103. }
  104. }
  105. /**
  106. * Make the getters / setters for the given property.
  107. *
  108. * @return array<Method>
  109. */
  110. protected function makeMethodsSnippetForProp(Property $prop): array
  111. {
  112. $methods = [];
  113. if ($prop->getType() !== Collection::class) {
  114. $methods[] = $this->makeSnippetGetterForProp($prop);
  115. $methods[] = $this->makeSnippetSetterForProp($prop);
  116. } else {
  117. $methods[] = $this->makeSnippetGetterForProp($prop);
  118. $methods[] = $this->makeSnippetAdderForCollection($prop);
  119. $methods[] = $this->makeSnippetRemoverForCollection($prop);
  120. }
  121. return $methods;
  122. }
  123. /**
  124. * Génère un objet ClassType.
  125. *
  126. * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/ClassType.html
  127. */
  128. protected function makeSnippetEntityClass(string $entity): ClassType
  129. {
  130. $class = new ClassType($entity);
  131. $class->setAttributes([
  132. new Attribute(ApiResource::class, ['operations' => []]),
  133. new Attribute(Entity::class, []),
  134. ]);
  135. return $class;
  136. }
  137. /**
  138. * Retourne le chemin absolu vers le répertoire dans lesquels sont créés les snippets.
  139. */
  140. protected function getSnippetsDir(): string
  141. {
  142. return Path::join(Path::getProjectDir(), 'schema_validation_snippets');
  143. }
  144. /**
  145. * Vide et ajuste les droits du répertoire des snippets.
  146. */
  147. protected function prepareSnippetsDir(string $snippetsDir): void
  148. {
  149. if (is_dir($snippetsDir)) {
  150. FileUtils::rrmDir($snippetsDir);
  151. }
  152. mkdir($snippetsDir, 0777, true);
  153. }
  154. /**
  155. * Retourne le chemin absolu du snippet donné.
  156. */
  157. protected function getSnippetPath(string $snippetsDir, string $entity): string
  158. {
  159. try {
  160. $fullName = $this->entityUtils->getFullNameFromEntityName($entity);
  161. } catch (\LogicException) {
  162. $fullName = '_NameSpaceNotFound/'.$entity;
  163. }
  164. $relativePath = str_replace('\\', '/', $fullName).'.php';
  165. return Path::join($snippetsDir, $relativePath);
  166. }
  167. /**
  168. * Créé le fichier du snippet sur le disque.
  169. */
  170. protected function writeSnippet(PhpFile $phpFile, string $fileName): void
  171. {
  172. if (!is_dir(dirname($fileName))) {
  173. mkdir(dirname($fileName), 0777, true);
  174. }
  175. $printer = new PsrPrinter();
  176. $content = $printer->printFile($phpFile);
  177. $content = $this->postProcessFileContent($content);
  178. $f = fopen($fileName, 'w+');
  179. try {
  180. fwrite($f, $content);
  181. } finally {
  182. fclose($f);
  183. }
  184. }
  185. /**
  186. * Parcourt les propriétés de type relations OneToOne ou ManyToOne pour lister
  187. * les classes d'entités à importer (pour le typage de ces propriétés).
  188. *
  189. * @param array<string, Difference> $differences
  190. *
  191. * @return array<string>
  192. */
  193. protected function getEntityToImportFromRelations(array $differences): array
  194. {
  195. $imports = [];
  196. foreach ($differences as $field => $difference) {
  197. if (
  198. !is_array($difference->getExpectedType())
  199. || !in_array(isset($difference->getExpectedType()['type']), [ClassMetadataInfo::ONE_TO_ONE, ClassMetadataInfo::MANY_TO_ONE])
  200. || !isset($difference->getExpectedType()['targetEntity'])
  201. ) {
  202. continue;
  203. }
  204. $entityName = $this->entityUtils->getEntityNameFromFullName($difference->getExpectedType()['targetEntity']);
  205. if (!$entityName) {
  206. continue;
  207. }
  208. try {
  209. $fullName = $this->entityUtils->getFullNameFromEntityName($entityName);
  210. } catch (\LogicException) {
  211. continue;
  212. }
  213. $imports[] = $fullName;
  214. }
  215. return $imports;
  216. }
  217. /**
  218. * Obtient le namespace pour l'entité donnée.
  219. */
  220. protected function getNamespaceValue(string $entity): string
  221. {
  222. try {
  223. $fullQualifiedName = str_contains($entity, '\\') ?
  224. $entity :
  225. $this->entityUtils->getFullNameFromEntityName($entity);
  226. return $this->entityUtils->getNamespaceFromName($fullQualifiedName);
  227. } catch (\LogicException) {
  228. return 'App\\Entity';
  229. }
  230. }
  231. /**
  232. * @param array<string, string|array<string>> $type
  233. */
  234. protected function getRelationTargetEntityName(array $type): string
  235. {
  236. $targetEntityName = $this->entityUtils->getEntityNameFromFullName($type['targetEntity']);
  237. try {
  238. return $this->entityUtils->getFullNameFromEntityName($targetEntityName);
  239. } catch (\LogicException) {
  240. return 'mixed';
  241. }
  242. }
  243. /**
  244. * Construit l'objet PhpFile.
  245. *
  246. * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/PhpFile.html
  247. */
  248. protected function makeFileSnippet(): PhpFile
  249. {
  250. $file = new PhpFile();
  251. $file->addComment('This file is auto-generated.');
  252. $file->setStrictTypes();
  253. return $file;
  254. }
  255. /**
  256. * Construit l'objet PhpNamespace.
  257. *
  258. * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/PhpNamespace.html
  259. */
  260. protected function makeNamespaceSnippet(string $entity): PhpNamespace
  261. {
  262. $namespaceValue = $this->getNamespaceValue($entity);
  263. $namespace = new PhpNamespace($namespaceValue);
  264. $namespace->addUse(ApiResource::class);
  265. $namespace->addUse('Doctrine\Common\Collections\ArrayCollection');
  266. $namespace->addUse('Doctrine\Common\Collections\Collection');
  267. $namespace->addUse('Doctrine\ORM\Mapping', 'ORM');
  268. return $namespace;
  269. }
  270. /**
  271. * Construit l'objet Property pour le champs 'id' d'une entité.
  272. *
  273. * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
  274. */
  275. protected function makeIdPropertySnippet(): Property
  276. {
  277. $prop = new Property('id');
  278. $prop->setPrivate();
  279. $prop->setType('int');
  280. $prop->addAttribute('ORM\Id');
  281. $prop->addAttribute('ORM\Column');
  282. $prop->addAttribute('ORM\GeneratedValue');
  283. return $prop;
  284. }
  285. protected function getPhpTypeFromDoctrineType(string $doctrineType): string
  286. {
  287. return [
  288. 'text' => 'string',
  289. 'boolean' => 'bool',
  290. 'integer' => 'int',
  291. 'datetime' => '?\DateTimeInterface',
  292. 'json_array' => 'array',
  293. ][$doctrineType] ?? 'mixed';
  294. }
  295. /**
  296. * Make a Property object for a simple field (not a relation).
  297. *
  298. * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
  299. */
  300. protected function makeSnippetEntitySimpleProp(string $name, string $type): Property
  301. {
  302. if ($name === 'id') {
  303. return $this->makeIdPropertySnippet();
  304. }
  305. $php_type = $this->getPhpTypeFromDoctrineType($type);
  306. $prop = new Property($name);
  307. $prop->setProtected();
  308. $prop->setType($php_type);
  309. if ($type === 'text') {
  310. $prop->addAttribute(Column::class, ['length' => 255, 'options' => ['nullable' => true]]);
  311. } elseif ($type === 'integer') {
  312. $prop->addAttribute(Column::class, ['type' => 'integer', 'options' => ['nullable' => true]]);
  313. } elseif ($type === 'boolean') {
  314. $prop->addAttribute(Column::class, ['options' => ['default' => false]]);
  315. } elseif ($type === 'datetime') {
  316. $prop->addAttribute(Column::class, ['type' => 'date', 'options' => ['nullable' => true]]);
  317. } elseif ($type === 'json_array') {
  318. $prop->addAttribute(Column::class, ['type' => 'json', 'options' => ['nullable' => true]]);
  319. } else {
  320. $prop->addAttribute(Column::class, []);
  321. }
  322. return $prop;
  323. }
  324. /**
  325. * Make a Property object for a relation field.
  326. *
  327. * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
  328. *
  329. * @param array<mixed> $type
  330. */
  331. protected function makeSnippetEntityCollectionProp(string $name, array $type): Property
  332. {
  333. $prop = new Property($name);
  334. $prop->setProtected();
  335. $prop->setType('Collection');
  336. if (
  337. isset($type['type'])
  338. && $type['type'] === ClassMetadataInfo::ONE_TO_ONE || $type['type'] === ClassMetadataInfo::MANY_TO_ONE
  339. ) {
  340. $targetEntityName = $this->getRelationTargetEntityName($type);
  341. $prop->setType($targetEntityName);
  342. } else {
  343. $prop->setType(Collection::class);
  344. }
  345. $options = [];
  346. if (isset($type['mappedBy'])) {
  347. $options['mappedBy'] = $type['mappedBy'];
  348. }
  349. if (isset($type['targetEntity'])) {
  350. $options['targetEntity'] = $this->entityUtils->getEntityNameFromFullName($type['targetEntity']).'::class';
  351. }
  352. if (isset($type['inversedBy'])) {
  353. $options['inversedBy'] = $type['inversedBy'];
  354. }
  355. if (isset($type['cascade'])) {
  356. $options['cascade'] = $type['cascade'];
  357. } else {
  358. $options['cascade'] = ['persist'];
  359. }
  360. if (isset($type['orphanRemoval']) && ($type['type'] === ClassMetadataInfo::ONE_TO_MANY || $type['type'] === ClassMetadataInfo::MANY_TO_MANY)) {
  361. $options['orphanRemoval'] = $type['orphanRemoval'];
  362. }
  363. $relationClassNames = [
  364. ClassMetadataInfo::ONE_TO_MANY => OneToMany::class,
  365. ClassMetadataInfo::MANY_TO_MANY => ManyToMany::class,
  366. ClassMetadataInfo::MANY_TO_ONE => ManyToOne::class,
  367. ClassMetadataInfo::ONE_TO_ONE => OneToOne::class,
  368. ];
  369. $prop->addAttribute($relationClassNames[$type['type']], $options);
  370. if ($type['type'] === ClassMetadataInfo::MANY_TO_ONE) {
  371. $prop->addAttribute(JoinColumn::class, ['referencedColumnName' => 'id', 'nullable' => false, 'onDelete' => 'SET NULL']);
  372. }
  373. return $prop;
  374. }
  375. /**
  376. * Make the '__construct' method with collections initialization.
  377. *
  378. * @param array<Property> $collections
  379. */
  380. protected function makeSnippetConstructor(array $collections): Method
  381. {
  382. $constructor = new Method('__construct');
  383. $constructor->setPublic();
  384. foreach ($collections as $collection) {
  385. $constructor->addBody('$this->'.$collection->getName().' = new ArrayCollection();');
  386. }
  387. return $constructor;
  388. }
  389. /**
  390. * Make a 'getter' method for the given property.
  391. */
  392. protected function makeSnippetGetterForProp(Property $prop): Method
  393. {
  394. $method = new Method('get'.ucfirst($prop->getName()));
  395. $method->setReturnType($prop->getType());
  396. $method->setBody('return $this->'.$prop->getName().';');
  397. return $method;
  398. }
  399. /**
  400. * Make a 'setter' method for the given property.
  401. */
  402. protected function makeSnippetSetterForProp(Property $prop): Method
  403. {
  404. $method = new Method('set'.ucfirst($prop->getName()));
  405. $parameter = new Parameter($prop->getName());
  406. $parameter->setType($prop->getType());
  407. $method->setParameters([$parameter]);
  408. $method->setReturnType('self');
  409. $method->setBody(
  410. implode(
  411. "\n",
  412. [
  413. '$this->'.$prop->getName().' = $'.$prop->getName().';',
  414. 'return $this;',
  415. ]
  416. )
  417. );
  418. return $method;
  419. }
  420. protected function getTargetEntityNameFromCollectionProp(Property $prop): ?string
  421. {
  422. if ($prop->getType() !== Collection::class) {
  423. throw new \LogicException('The property must be a collection');
  424. }
  425. foreach ($prop->getAttributes() as $attribute) {
  426. if (
  427. $attribute instanceof Attribute
  428. && ($attribute->getName() === OneToMany::class || $attribute->getName() === ManyToMany::class)
  429. ) {
  430. $targetEntityName = $attribute->getArguments()['targetEntity'];
  431. if (!$targetEntityName) {
  432. return null;
  433. }
  434. // Normalize result (it could be a FQN, or a '::class' notation)
  435. $targetEntityName = str_replace('::class', '', $targetEntityName);
  436. if (!str_contains($targetEntityName, '\\')) {
  437. try {
  438. $targetEntityName = $this->entityUtils->getFullNameFromEntityName($targetEntityName);
  439. } catch (\LogicException) {
  440. return null;
  441. }
  442. }
  443. return $targetEntityName;
  444. }
  445. }
  446. return null;
  447. }
  448. /**
  449. * Make an 'adder' method for the given property.
  450. */
  451. protected function makeSnippetAdderForCollection(Property $prop): Method
  452. {
  453. $singularPropName = rtrim($prop->getName(), 's');
  454. $method = new Method('add'.ucfirst($singularPropName));
  455. $targetEntityName = $this->getTargetEntityNameFromCollectionProp($prop);
  456. $parameter = new Parameter($singularPropName);
  457. $parameter->setType($targetEntityName ?? 'mixed');
  458. $method->setParameters([$parameter]);
  459. $method->setReturnType('self');
  460. $method->setBody(
  461. implode(
  462. "\n",
  463. [
  464. 'if (!$this->'.$prop->getName().'->contains($'.$singularPropName.')) {',
  465. ' $this->'.$prop->getName().'[] = $'.$singularPropName.';',
  466. '}',
  467. '',
  468. 'return $this;',
  469. ]
  470. )
  471. );
  472. return $method;
  473. }
  474. /**
  475. * Make a 'remover' method for the given property.
  476. */
  477. protected function makeSnippetRemoverForCollection(Property $prop): Method
  478. {
  479. $singularPropName = rtrim($prop->getName(), 's');
  480. $method = new Method('remove'.ucfirst($singularPropName));
  481. $targetEntityName = $this->getTargetEntityNameFromCollectionProp($prop);
  482. $parameter = new Parameter($singularPropName);
  483. $parameter->setType($targetEntityName ?? 'mixed');
  484. $method->setParameters([$parameter]);
  485. $method->setReturnType('self');
  486. $method->setBody(
  487. implode(
  488. "\n",
  489. [
  490. '$this->'.$prop->getName().'->removeElement($'.$singularPropName.');',
  491. '',
  492. 'return $this;',
  493. ]
  494. )
  495. );
  496. return $method;
  497. }
  498. /**
  499. * Perform some post-fixes on the file content.
  500. */
  501. protected function postProcessFileContent(string $content): string
  502. {
  503. return preg_replace("/targetEntity: '(\w+)::class'/", 'targetEntity: $1::class', $content);
  504. }
  505. }