| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751 |
- <?php
- declare(strict_types=1);
- namespace App\Service\Doctrine\SchemaValidation;
- use ApiPlatform\Metadata\ApiResource;
- use App\Service\Utils\EntityUtils;
- use App\Service\Utils\FileUtils;
- use App\Service\Utils\Path;
- use Doctrine\Common\Collections\Collection;
- use Doctrine\ORM\Mapping\ClassMetadataInfo;
- use Doctrine\ORM\Mapping\Column;
- use Doctrine\ORM\Mapping\Entity;
- use Doctrine\ORM\Mapping\InverseJoinColumn;
- use Doctrine\ORM\Mapping\JoinColumn;
- use Doctrine\ORM\Mapping\JoinTable;
- use Doctrine\ORM\Mapping\ManyToMany;
- use Doctrine\ORM\Mapping\ManyToOne;
- use Doctrine\ORM\Mapping\OneToMany;
- use Doctrine\ORM\Mapping\OneToOne;
- use Nette\InvalidStateException;
- use Nette\PhpGenerator\Attribute;
- use Nette\PhpGenerator\ClassType;
- use Nette\PhpGenerator\Method;
- use Nette\PhpGenerator\Parameter;
- use Nette\PhpGenerator\PhpFile;
- use Nette\PhpGenerator\PhpNamespace;
- use Nette\PhpGenerator\Property;
- use Nette\PhpGenerator\PsrPrinter;
- /**
- * Service produisant les snippets des entités, propriétés et méthodes
- * manquantes dans les entités de la V2.
- *
- * Les résultats sont enregistrés dans le répertoire `~/schema_validation_snippets`.
- *
- * @see https://github.com/nette/php-generator
- */
- class SchemaSnippetsMaker
- {
- public function __construct(
- private readonly EntityUtils $entityUtils,
- ) {
- }
- /**
- * Make entities snippets from a 'diff' array,
- * as generated by SchemaValidationService::validateSchema().
- *
- * @param array<string, Difference | array<Difference>> $diff
- */
- public function makeSnippets(array $diff): void
- {
- $snippetsDir = $this->getSnippetsDir();
- $this->prepareSnippetsDir($snippetsDir);
- foreach ($diff as $entity => $differences) {
- if (empty($differences)) {
- continue;
- }
- $class = $this->makeSnippetEntityClass($entity);
- $methods = [];
- $collections = [];
- $expectedFields = [];
- if (!is_array($differences)) {
- // New entity
- foreach ($differences->getExpectedType() as $field => $type) {
- $expectedFields[$field] = $type;
- }
- } else {
- // Existing entity
- foreach ($differences as $field => $difference) {
- $expectedFields[$field] = $difference->getExpectedType();
- }
- }
- foreach ($expectedFields as $field => $expectedType) {
- $prop = is_array($expectedType) ?
- $this->makeSnippetEntityCollectionProp($field, $expectedType) :
- $this->makeSnippetEntitySimpleProp($field, $expectedType);
- $class->addMember($prop);
- if ($prop->getType() === Collection::class) {
- $collections[] = $prop;
- }
- $methods = [...$methods, ...$this->makeMethodsSnippetForProp($prop)];
- }
- if ($collections) {
- $class->addMember(
- $this->makeSnippetConstructor($collections)
- );
- }
- foreach ($methods as $method) {
- $class->addMember($method);
- }
- $file = $this->makeFileSnippet();
- $namespace = $this->makeNamespaceSnippet($entity);
- if (is_array($differences)) {
- foreach ($this->getEntityToImportFromRelations($differences) as $use) {
- $namespace->addUse($use);
- }
- }
- try {
- $namespace->add($class);
- } catch (InvalidStateException $e) {
- var_dump($e->getMessage());
- continue;
- }
- $file->addNamespace($namespace);
- $fileName = $this->getSnippetPath($snippetsDir, $entity);
- $this->writeSnippet($file, $fileName);
- }
- }
- /**
- * Make the getters / setters for the given property.
- *
- * @return array<Method>
- */
- protected function makeMethodsSnippetForProp(Property $prop): array
- {
- $methods = [];
- if ($prop->getType() !== Collection::class) {
- $methods[] = $this->makeSnippetGetterForProp($prop);
- $methods[] = $this->makeSnippetSetterForProp($prop);
- } else {
- $methods[] = $this->makeSnippetGetterForProp($prop);
- $methods[] = $this->makeSnippetAdderForCollection($prop);
- $methods[] = $this->makeSnippetRemoverForCollection($prop);
- }
- return $methods;
- }
- /**
- * Génère un objet ClassType.
- *
- * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/ClassType.html
- */
- protected function makeSnippetEntityClass(string $entity): ClassType
- {
- $class = new ClassType($entity);
- $class->setAttributes([
- new Attribute(ApiResource::class, ['operations' => []]),
- new Attribute(Entity::class, []),
- ]);
- return $class;
- }
- /**
- * Retourne le chemin absolu vers le répertoire dans lesquels sont créés les snippets.
- */
- protected function getSnippetsDir(): string
- {
- return Path::join(Path::getProjectDir(), 'schema_validation_snippets');
- }
- /**
- * Vide et ajuste les droits du répertoire des snippets.
- */
- protected function prepareSnippetsDir(string $snippetsDir): void
- {
- if (is_dir($snippetsDir)) {
- FileUtils::rrmDir($snippetsDir);
- }
- mkdir($snippetsDir, 0777, true);
- }
- /**
- * Retourne le chemin absolu du snippet donné.
- */
- protected function getSnippetPath(string $snippetsDir, string $entity): string
- {
- try {
- $fullName = $this->entityUtils->getFullNameFromEntityName($entity);
- } catch (\LogicException) {
- $fullName = '_NameSpaceNotFound/'.$entity;
- }
- $relativePath = str_replace('\\', '/', $fullName).'.php';
- return Path::join($snippetsDir, $relativePath);
- }
- /**
- * Créé le fichier du snippet sur le disque.
- */
- protected function writeSnippet(PhpFile $phpFile, string $fileName): void
- {
- if (!is_dir(dirname($fileName))) {
- mkdir(dirname($fileName), 0777, true);
- }
- $printer = new PsrPrinter();
- $content = $printer->printFile($phpFile);
- $content = $this->postProcessFileContent($content);
- $f = fopen($fileName, 'w+');
- try {
- fwrite($f, $content);
- } finally {
- fclose($f);
- }
- }
- /**
- * Parcourt les propriétés de type relations OneToOne ou ManyToOne pour lister
- * les classes d'entités à importer (pour le typage de ces propriétés).
- *
- * @param array<string, Difference> $differences
- *
- * @return array<string>
- */
- protected function getEntityToImportFromRelations(array $differences): array
- {
- $imports = [];
- foreach ($differences as $field => $difference) {
- if (
- !is_array($difference->getExpectedType())
- || !in_array(isset($difference->getExpectedType()['type']), [ClassMetadataInfo::ONE_TO_ONE, ClassMetadataInfo::MANY_TO_ONE])
- || !isset($difference->getExpectedType()['targetEntity'])
- ) {
- continue;
- }
- $entityName = $this->entityUtils->getEntityNameFromFullName($difference->getExpectedType()['targetEntity']);
- if (!$entityName) {
- continue;
- }
- try {
- $fullName = $this->entityUtils->getFullNameFromEntityName($entityName);
- } catch (\LogicException) {
- continue;
- }
- $imports[] = $fullName;
- }
- return $imports;
- }
- /**
- * Obtient le namespace pour l'entité donnée.
- */
- protected function getNamespaceValue(string $entity): string
- {
- try {
- $fullQualifiedName = str_contains($entity, '\\') ?
- $entity :
- $this->entityUtils->getFullNameFromEntityName($entity);
- return $this->entityUtils->getNamespaceFromName($fullQualifiedName);
- } catch (\LogicException) {
- return 'App\\Entity';
- }
- }
- /**
- * @param array<string, string|array<string>> $type
- */
- protected function getRelationTargetEntityName(array $type): string
- {
- $targetEntityName = $this->entityUtils->getEntityNameFromFullName($type['targetEntity']);
- try {
- return $this->entityUtils->getFullNameFromEntityName($targetEntityName);
- } catch (\LogicException) {
- return 'mixed';
- }
- }
- /**
- * Construit l'objet PhpFile.
- *
- * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/PhpFile.html
- */
- protected function makeFileSnippet(): PhpFile
- {
- $file = new PhpFile();
- $file->addComment('This file is auto-generated.');
- $file->setStrictTypes();
- return $file;
- }
- /**
- * Construit l'objet PhpNamespace.
- *
- * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/PhpNamespace.html
- */
- protected function makeNamespaceSnippet(string $entity): PhpNamespace
- {
- $namespaceValue = $this->getNamespaceValue($entity);
- $namespace = new PhpNamespace($namespaceValue);
- $namespace->addUse(ApiResource::class);
- $namespace->addUse('Doctrine\Common\Collections\ArrayCollection');
- $namespace->addUse('Doctrine\Common\Collections\Collection');
- $namespace->addUse('Doctrine\ORM\Mapping', 'ORM');
- return $namespace;
- }
- /**
- * Construit l'objet Property pour le champs 'id' d'une entité.
- *
- * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
- */
- protected function makeIdPropertySnippet(): Property
- {
- $prop = new Property('id');
- $prop->setPrivate();
- $prop->setType('int');
- $prop->addAttribute('ORM\Id');
- $prop->addAttribute('ORM\Column');
- $prop->addAttribute('ORM\GeneratedValue');
- return $prop;
- }
- protected function getPhpTypeFromDoctrineType(string $doctrineType): string
- {
- return [
- 'text' => 'string',
- 'boolean' => 'bool',
- 'integer' => 'int',
- 'datetime' => '?\DateTimeInterface',
- 'json_array' => 'array',
- ][$doctrineType] ?? 'mixed';
- }
- /**
- * Make a Property object for a simple field (not a relation).
- *
- * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
- */
- protected function makeSnippetEntitySimpleProp(string $name, string $type): Property
- {
- if ($name === 'id') {
- return $this->makeIdPropertySnippet();
- }
- $php_type = $this->getPhpTypeFromDoctrineType($type);
- $prop = new Property($name);
- $prop->setProtected();
- $prop->setType($php_type);
- $prop->setComment('-- Warning : auto-generated property, checkup the attribute options --');
- if ($type === 'text') {
- $prop->addAttribute(Column::class, ['length' => 255, 'options' => ['nullable' => true]]);
- } elseif ($type === 'integer') {
- $prop->addAttribute(Column::class, ['type' => 'integer', 'options' => ['nullable' => true]]);
- } elseif ($type === 'boolean') {
- $prop->addAttribute(Column::class, ['options' => ['default' => false]]);
- } elseif ($type === 'datetime') {
- $prop->addAttribute(Column::class, ['type' => 'date', 'options' => ['nullable' => true]]);
- } elseif ($type === 'json_array') {
- $prop->addAttribute(Column::class, ['type' => 'json', 'options' => ['nullable' => true]]);
- } else {
- $prop->addAttribute(Column::class, []);
- }
- return $prop;
- }
- /**
- * Make a Property object for a relation field.
- *
- * @see https://api.nette.org/php-generator/master/Nette/PhpGenerator/Property.html
- *
- * @param array<mixed> $type
- */
- protected function makeSnippetEntityCollectionProp(string $name, array $type): Property
- {
- $prop = new Property($name);
- $prop->setProtected();
- $prop->setType('Collection');
- if (
- isset($type['type'])
- && $type['type'] === ClassMetadataInfo::ONE_TO_ONE || $type['type'] === ClassMetadataInfo::MANY_TO_ONE
- ) {
- $targetEntityName = $this->getRelationTargetEntityName($type);
- $prop->setType($targetEntityName);
- } else {
- $prop->setType(Collection::class);
- }
- $attributes = [];
- $attributes[] = $this->makeRelationAttribute($type);
- $joinTable = $this->makeJoinTableAttributes($type);
- if ($joinTable) {
- $attributes[] = $joinTable;
- }
- $attributes = array_merge($attributes, $this->makeJoinColumnAttributes($type));
- $attributes = array_merge($attributes, $this->makeJoinColumnAttributes($type, true));
- $prop->setAttributes($attributes);
- return $prop;
- }
- /**
- * Make the attribute defining the relation (ex: #[ORM\ManyToMany(...)]).
- *
- * @param array<string | array<string | array<string>>> $type
- */
- protected function makeRelationAttribute(array $type): Attribute
- {
- $options = [];
- if (isset($type['mappedBy'])) {
- $options['mappedBy'] = $type['mappedBy'];
- }
- if (isset($type['targetEntity'])) {
- $options['targetEntity'] = $this->entityUtils->getEntityNameFromFullName($type['targetEntity']).'::class';
- }
- if (isset($type['cascade'])) {
- $options['cascade'] = $type['cascade'];
- }
- if (isset($type['inversedBy'])) {
- $options['inversedBy'] = $type['inversedBy'];
- }
- if (isset($type['orphanRemoval']) && ($type['type'] === ClassMetadataInfo::ONE_TO_MANY || $type['type'] === ClassMetadataInfo::MANY_TO_MANY)) {
- $options['orphanRemoval'] = $type['orphanRemoval'];
- }
- $relationClassNames = [
- ClassMetadataInfo::ONE_TO_MANY => OneToMany::class,
- ClassMetadataInfo::MANY_TO_MANY => ManyToMany::class,
- ClassMetadataInfo::MANY_TO_ONE => ManyToOne::class,
- ClassMetadataInfo::ONE_TO_ONE => OneToOne::class,
- ];
- return new Attribute($relationClassNames[$type['type']], $options);
- }
- /**
- * Make the #[ORM\JoinTable] attribute (if a definition exists).
- *
- * @param array<string | array<string | array<string>>> $type
- */
- protected function makeJoinTableAttributes(array $type): ?Attribute
- {
- if (!isset($type['joinTable'])) {
- return null;
- }
- $options = [];
- if (isset($type['joinTable']['name']) && $type['joinTable']['name']) {
- $options['name'] = $type['joinTable']['name'];
- }
- return new Attribute(JoinTable::class, $options);
- }
- /**
- * Make the #[JoinColumn] attributes, if definitions exists.
- *
- * Is `$inverse` is true, make the #[InverseJoinColumn] instead.
- *
- * @param array<string | array<string | array<string>>> $type
- *
- * @return array<Attribute>
- */
- protected function makeJoinColumnAttributes(array $type, bool $inverse = false): array
- {
- $key = $inverse ? 'inverseJoinColumns' : 'joinColumns';
- $definition = $type[$key] ?? $type['joinTable'][$key] ?? [];
- if (empty($definition)) {
- return [];
- }
- $attributes = [];
- foreach ($definition as $joinColDefinition) {
- $options = [];
- if (isset($joinColDefinition['name']) && $joinColDefinition['name'] !== $type['fieldName'].'_id') {
- $options['name'] = $joinColDefinition['name'];
- }
- if (($joinColDefinition['unique'] ?? false) === true) {
- $options['unique'] = true;
- }
- if (($joinColDefinition['nullable'] ?? true) === false) {
- $options['nullable'] = false;
- }
- if (($joinColDefinition['onDelete'] ?? null) !== null) {
- $options['onDelete'] = $joinColDefinition['onDelete'];
- }
- if (($joinColDefinition['columnDefinition'] ?? null) !== null) {
- $options['columnDefinition'] = $joinColDefinition['columnDefinition'];
- }
- if (($joinColDefinition['referencedColumnName'] ?? 'id') !== 'id') {
- $options['referencedColumnName'] = $joinColDefinition['referencedColumnName'];
- }
- if (empty($options)) {
- // Useless attribute
- continue;
- }
- $attributes[] = new Attribute(
- $inverse ? InverseJoinColumn::class : JoinColumn::class,
- $options
- );
- }
- return $attributes;
- }
- /**
- * Make the '__construct' method with collections initialization.
- *
- * @param array<Property> $collections
- */
- protected function makeSnippetConstructor(array $collections): Method
- {
- $constructor = new Method('__construct');
- $constructor->setPublic();
- foreach ($collections as $collection) {
- $constructor->addBody('$this->'.$collection->getName().' = new ArrayCollection();');
- }
- return $constructor;
- }
- /**
- * Make a 'getter' method for the given property.
- */
- protected function makeSnippetGetterForProp(Property $prop): Method
- {
- $method = new Method('get'.ucfirst($prop->getName()));
- $method->setReturnType($prop->getType());
- $method->setBody('return $this->'.$prop->getName().';');
- return $method;
- }
- /**
- * Make a 'setter' method for the given property.
- */
- protected function makeSnippetSetterForProp(Property $prop): Method
- {
- $method = new Method('set'.ucfirst($prop->getName()));
- $parameter = new Parameter($prop->getName());
- $parameter->setType($prop->getType());
- $method->setParameters([$parameter]);
- $method->setReturnType('self');
- $method->setBody(
- implode(
- "\n",
- [
- '$this->'.$prop->getName().' = $'.$prop->getName().';',
- 'return $this;',
- ]
- )
- );
- return $method;
- }
- protected function getTargetEntityNameFromCollectionProp(Property $prop): ?string
- {
- if ($prop->getType() !== Collection::class) {
- throw new \LogicException('The property must be a collection');
- }
- foreach ($prop->getAttributes() as $attribute) {
- if (
- $attribute instanceof Attribute
- && ($attribute->getName() === OneToMany::class || $attribute->getName() === ManyToMany::class)
- ) {
- $targetEntityName = $attribute->getArguments()['targetEntity'];
- if (!$targetEntityName) {
- return null;
- }
- // Normalize result (it could be a FQN, or a '::class' notation)
- $targetEntityName = str_replace('::class', '', $targetEntityName);
- if (!str_contains($targetEntityName, '\\')) {
- try {
- $targetEntityName = $this->entityUtils->getFullNameFromEntityName($targetEntityName);
- } catch (\LogicException) {
- return null;
- }
- }
- return $targetEntityName;
- }
- }
- return null;
- }
- protected function getInverseSetterCallFromCollectionProp(Property $prop, bool $isRemoving = false): ?string
- {
- if (
- $prop->getType() !== Collection::class
- ) {
- throw new \LogicException('The property must be a collection');
- }
- $relationAttr = null;
- foreach ($prop->getAttributes() as $attribute) {
- if ($attribute->getName() === OneToMany::class || $attribute->getName() === ManyToMany::class) {
- $relationAttr = $attribute;
- }
- }
- if (!$relationAttr) {
- throw new \LogicException('Missing relation attribute for collection property '.$prop->getName());
- }
- $inversedBy = $relationAttr->getArguments()['inversedBy'] ?? $relationAttr->getArguments()['mappedBy'] ?? null;
- if (!$inversedBy) {
- var_dump('Could not determine the inverse prop for collection property '.$prop->getName());
- $inversedBy = 'XXXX';
- }
- $attr = $prop->getAttributes()[0];
- if ($attr->getName() === OneToMany::class) {
- $prefix = 'set';
- } else {
- $prefix = $isRemoving ? 'remove' : 'add';
- $inversedBy = $this->singularize($inversedBy);
- }
- return
- $prefix.
- ucfirst($inversedBy).
- (($prefix === 'set' && $isRemoving) ? '(null)' : '($this)');
- }
- /**
- * Make an 'adder' method for the given property.
- */
- protected function makeSnippetAdderForCollection(Property $prop): Method
- {
- $singularPropName = $this->singularize($prop->getName());
- $method = new Method('add'.ucfirst($singularPropName));
- $targetEntityName = $this->getTargetEntityNameFromCollectionProp($prop);
- $parameter = new Parameter($singularPropName);
- $parameter->setType($targetEntityName ?? 'mixed');
- $method->setParameters([$parameter]);
- $inverseSetterCall = $this->getInverseSetterCallFromCollectionProp($prop);
- $method->setReturnType('self');
- $method->setBody(implode(
- "\n",
- [
- 'if (!$this->'.$prop->getName().'->contains($'.$singularPropName.')) {',
- ' $this->'.$prop->getName().'[] = $'.$singularPropName.';',
- ' $'.$singularPropName.'->'.$inverseSetterCall.';',
- '}',
- '',
- 'return $this;',
- ]));
- return $method;
- }
- /**
- * Make a 'remover' method for the given property.
- */
- protected function makeSnippetRemoverForCollection(Property $prop): Method
- {
- $singularPropName = $this->singularize($prop->getName());
- $method = new Method('remove'.ucfirst($singularPropName));
- $targetEntityName = $this->getTargetEntityNameFromCollectionProp($prop);
- $parameter = new Parameter($singularPropName);
- $parameter->setType($targetEntityName ?? 'mixed');
- $method->setParameters([$parameter]);
- $inverseSetterCall = $this->getInverseSetterCallFromCollectionProp($prop, true);
- $method->setReturnType('self');
- $method->setBody(
- implode(
- "\n",
- [
- 'if ($this->'.$prop->getName().'->removeElement($'.$singularPropName.')) {',
- ' $'.$singularPropName.'->'.$inverseSetterCall.';',
- '}',
- '',
- 'return $this;',
- ]
- )
- );
- return $method;
- }
- /**
- * Perform some post-fixes on the file content.
- */
- protected function postProcessFileContent(string $content): string
- {
- return preg_replace("/targetEntity: '(\w+)::class'/", 'targetEntity: $1::class', $content);
- }
- protected function singularize(string $name): string
- {
- $exceptions = ['access'];
- if (in_array(strtolower($name), $exceptions)) {
- return $name;
- }
- return preg_replace('/s$/', '', $name);
- }
- }
|