SchemaSnippetsMaker.php 16 KB

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