Browse Source

schema validation : add snippets making, and various fixes

Olivier Massot 1 year ago
parent
commit
3f482b9009

+ 1 - 0
.gitignore

@@ -63,3 +63,4 @@ opentalent_test.sql
 ###< fake database for applications tests ###
 
 .php-cs-fixer.cache
+/schema_validation_snippets/

+ 2 - 1
composer.json

@@ -34,6 +34,7 @@
     "lorenzo/pinky": "^1.0",
     "myclabs/php-enum": "^1.7",
     "nelmio/cors-bundle": "^2.1",
+    "nette/php-generator": "^4.1",
     "odolbeau/phone-number-bundle": "^3.1",
     "opentalent/phpdocx": "dev-master",
     "phpdocumentor/reflection-docblock": "^5.2",
@@ -87,7 +88,7 @@
     "phpstan/phpstan-phpunit": "^1.3",
     "phpstan/phpstan-symfony": "^1.3",
     "phpunit/phpunit": "^9.6",
-    "rector/rector": "^0.15.13",
+    "rector/rector": "^1.2",
     "symfony/browser-kit": "6.3.*",
     "symfony/css-selector": "6.3.*",
     "symfony/debug-bundle": "6.3.*",

+ 23 - 4
src/Commands/Doctrine/SchemaValidateCommand.php

@@ -7,14 +7,11 @@ namespace App\Commands\Doctrine;
 use App\Service\Doctrine\SchemaValidation\Difference;
 use App\Service\Doctrine\SchemaValidation\DiffTypeEnum;
 use App\Service\Doctrine\SchemaValidation\SchemaValidationService;
-use Doctrine\ORM\Tools\SchemaTool;
-use JetBrains\PhpStorm\Pure;
 use Symfony\Component\Console\Attribute\AsCommand;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Style\SymfonyStyle;
 
 /**
  * Overrides the default doctrine:schema:update command.
@@ -25,7 +22,6 @@ use Symfony\Component\Console\Style\SymfonyStyle;
 )]
 class SchemaValidateCommand extends Command
 {
-    #[Pure]
     public function __construct(
         private readonly SchemaValidationService $schemaValidationService,
     ) {
@@ -49,6 +45,12 @@ class SchemaValidateCommand extends Command
             InputOption::VALUE_NONE,
             "Print the result in CSV format."
         );
+        $this->addOption(
+            'snippets',
+            null,
+            InputOption::VALUE_NONE,
+            "Make snippets of the missing classes and fields"
+        );
     }
 
     protected function execute(InputInterface $input, OutputInterface $output): int
@@ -75,9 +77,20 @@ class SchemaValidateCommand extends Command
             $output->writeln("No difference found");
         }
 
+        if ($input->getOption('snippets')) {
+            $this->schemaValidationService->makeSnippets($diff);
+            $output->writeln("Snippets generated");
+        }
+
         return 0;
     }
 
+    /**
+     * @param OutputInterface $output
+     * @param string $entity
+     * @param Difference|array<Difference> $differences
+     * @return void
+     */
     protected function printVerbose(OutputInterface $output, string $entity, Difference | array $differences): void {
         $output->writeln($entity);
 
@@ -92,6 +105,12 @@ class SchemaValidateCommand extends Command
         $output->writeln("\n");
     }
 
+    /**
+     * @param OutputInterface $output
+     * @param string $entity
+     * @param Difference|array<Difference> $differences
+     * @return void
+     */
     protected function printCsv(OutputInterface $output, string $entity, Difference | array $differences): void {
         if (!is_array($differences)) {
             $output->writeln(implode(';', [$entity, '', $differences->getType()->value]));

+ 15 - 8
src/Service/Doctrine/SchemaValidation/Difference.php

@@ -11,16 +11,12 @@ class Difference
 
     protected string $entity;
     protected string $property;
-    protected string $expectedType;
-    protected string $actualType;
-    protected string $expectedRelationType;
-    protected string $actualRelationType;
-    protected string $expectedRelationConfiguration;
-    protected string $actualRelationConfiguration;
-
-    public function __construct(DiffTypeEnum $type, ?string $message) {
+    protected string | array $expectedType;
+
+    public function __construct(DiffTypeEnum $type, ?string $message, string | array $expectedType = []) {
         $this->type = $type;
         $this->message = $message;
+        $this->expectedType = $expectedType;
     }
 
     public function getType(): DiffTypeEnum
@@ -44,4 +40,15 @@ class Difference
         $this->message = $message;
         return $this;
     }
+
+    public function getExpectedType(): string | array
+    {
+        return $this->expectedType;
+    }
+
+    public function setExpectedType(string | array $expectedType): self
+    {
+        $this->expectedType = $expectedType;
+        return $this;
+    }
 }

+ 465 - 31
src/Service/Doctrine/SchemaValidation/SchemaValidationService.php

@@ -3,10 +3,30 @@ declare(strict_types=1);
 
 namespace App\Service\Doctrine\SchemaValidation;
 
+use ApiPlatform\Metadata\ApiResource;
 use App\Service\ApiLegacy\ApiLegacyRequestService;
+use App\Service\Utils\FileUtils;
+use App\Service\Utils\Path;
+use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\EntityManagerInterface;
 use Doctrine\ORM\Mapping\ClassMetadataInfo;
+use Doctrine\ORM\Mapping\Column;
+use Doctrine\ORM\Mapping\Entity;
+use Doctrine\ORM\Mapping\JoinColumn;
+use Doctrine\ORM\Mapping\ManyToMany;
+use Doctrine\ORM\Mapping\ManyToOne;
 use Doctrine\ORM\Mapping\MappingException;
+use Doctrine\ORM\Mapping\OneToMany;
+use Doctrine\ORM\Mapping\OneToOne;
+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\Printer;
+use Nette\PhpGenerator\Property;
+use Nette\PhpGenerator\PsrPrinter;
 use RuntimeException;
 use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
 use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
@@ -17,15 +37,17 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
  * Validation du schéma Doctrine par comparaison aux entités en production sur la V1
  *
  * — À supprimer lorsque la migration sera achevée —
- *
  */
 class SchemaValidationService
 {
+    protected array $entityNamesMapping = [];
+
     public function __construct(
         private readonly EntityManagerInterface  $entityManager,
         private readonly ApiLegacyRequestService $apiLegacyRequestService,
     )
-    {}
+    {
+    }
 
     /**
      * Compare the V2 doctrine schema to the one in V1, and return a list of differences,
@@ -33,7 +55,7 @@ class SchemaValidationService
      *
      *     [<entity> → Difference | [<field> → Difference]]
      *
-     * @return array
+     * @return array<string, Difference | array<Difference>>
      * @throws ClientExceptionInterface
      * @throws MappingException
      * @throws RedirectionExceptionInterface
@@ -45,9 +67,7 @@ class SchemaValidationService
         $schemaV1 = $this->getV1Schema();
         $schemaV2 = $this->getV2Schema();
 
-        $diff = $this->getDiff($schemaV1, $schemaV2, $filter);
-
-        return $diff;
+        return $this->getDiff($schemaV1, $schemaV2, $filter);
     }
 
     /**
@@ -62,7 +82,9 @@ class SchemaValidationService
         $schema = [];
 
         foreach ($metadata as $entityMetadata) {
-            $entityClassName = $this->extractClassName($entityMetadata->getName());
+            $entityClassName = $this->fullNameToEntityName($entityMetadata->getName());
+
+            $this->entityNamesMapping[$entityClassName] = $entityMetadata->getName();
 
             $schema[$entityClassName] = [];
 
@@ -78,11 +100,6 @@ class SchemaValidationService
         return $schema;
     }
 
-    protected function extractClassName(string $entity): string {
-        $parts = explode('\\', $entity);
-        return array_pop($parts);
-    }
-
     /**
      * Retrieve the V1 schema
      *
@@ -92,7 +109,8 @@ class SchemaValidationService
      * @throws ServerExceptionInterface
      * @throws TransportExceptionInterface
      */
-    protected function getV1Schema(): array {
+    protected function getV1Schema(): array
+    {
         $response = $this->apiLegacyRequestService->get('/_internal/doctrine/schema');
 
         return json_decode($response->getContent(), true);
@@ -105,7 +123,8 @@ class SchemaValidationService
      * @param array<string, array<string| array<string|int>>> $schemaV2
      * @return array<string, Difference | array<Difference>>
      */
-    protected function getDiff(array $schemaV1, array $schemaV2, ?DiffTypeEnum $filter = null): array {
+    protected function getDiff(array $schemaV1, array $schemaV2, ?DiffTypeEnum $filter = null): array
+    {
         $diff = [
         ];
 
@@ -114,11 +133,29 @@ class SchemaValidationService
             if (!$this->isEntityInSchema($schemaV2, $entity)) {
                 // L'entité n'existe pas en V2
                 if (!$filter || $filter === DiffTypeEnum::MISSING_ENTITY) {
-                    $diff[$entity] = new Difference(DiffTypeEnum::MISSING_ENTITY, "Entity `$entity` is missing in V2");
+                    $diff[$entity] = new Difference(
+                        DiffTypeEnum::MISSING_ENTITY,
+                        "Entity `$entity` is missing in V2",
+                        $fields
+                    );
                 }
                 continue;
             }
 
+            foreach ($fields as $field => $fieldTypeV1) {
+                if (
+                    !$this->isPropertyInSchema($schemaV2, $entity, $field) &&
+                    $this->isRelationField($schemaV1, $entity, $field) &&
+                    $this->isPropertyInSchema($schemaV2, $entity, $field .'s')
+                ) {
+                    // Le champ existe en V2, mais il a été passé au pluriel, par exemple : $contactPoint devenu $contactPoints
+                    // Pour éviter les faux positifs, on renomme le champ dans le schéma v1
+                    $schemaV1[$entity][$field .'s'] = $fieldTypeV1;
+                    unset($schemaV1[$entity][$field]);
+                    $fields = $schemaV1[$entity];
+                }
+            }
+
             $diff[$entity] = [];
 
             foreach ($fields as $field => $fieldTypeV1) {
@@ -126,9 +163,22 @@ class SchemaValidationService
                 if (!$this->isPropertyInSchema($schemaV2, $entity, $field)) {
                     // Le champ n'existe pas en V2
                     if ($this->isRelationField($schemaV1, $entity, $field)) {
-                        $diff[$entity][$field] = new Difference(DiffTypeEnum::MISSING_RELATION, "Relation " . $this->getRelationTypeLabel($fieldTypeV1) . " `$field` is missing in V2");
+                        if ($this->isPropertyInSchema($schemaV2, $entity, $field .'s')) {
+
+                            continue;
+                        }
+
+                        $diff[$entity][$field] = new Difference(
+                            DiffTypeEnum::MISSING_RELATION,
+                            "Relation " . $this->getRelationTypeLabel($fieldTypeV1) . " `$field` is missing in V2",
+                            $fieldTypeV1,
+                        );
                     } else {
-                        $diff[$entity][$field] = new Difference(DiffTypeEnum::MISSING_PROPERTY, "Property `$field` is missing in V2");
+                        $diff[$entity][$field] = new Difference(
+                            DiffTypeEnum::MISSING_PROPERTY,
+                            "Property `$field` is missing in V2",
+                            $fieldTypeV1
+                        );
                     }
                     continue;
                 }
@@ -140,11 +190,19 @@ class SchemaValidationService
                     // Le champ n'est pas une relation en V1
                     if ($fieldTypeV2 !== $fieldTypeV1) {
                         // Le champ a un type différent en V2
-                        $diff[$entity][$field] = new Difference(DiffTypeEnum::DIFFERENT_TYPE, "Property `$field` has a different type (V1: `$fieldTypeV1`, V2: `$fieldTypeV2`)");
+                        $diff[$entity][$field] = new Difference(
+                            DiffTypeEnum::DIFFERENT_TYPE,
+                            "Property `$field` has a different type (V1: `$fieldTypeV1`, V2: `$fieldTypeV2`)",
+                            $fieldTypeV1
+                        );
                     }
                 } elseif (!$this->isRelationField($schemaV2, $entity, $field)) {
                     // Le champ est une relation en V1 mais pas en V2
-                    $diff[$entity][$field] = new Difference(DiffTypeEnum::DIFFERENT_TYPE, "Property $field is a relation in V1 but not in V2");
+                    $diff[$entity][$field] = new Difference(
+                        DiffTypeEnum::DIFFERENT_TYPE,
+                        "Property $field is a relation in V1 but not in V2",
+                        $fieldTypeV1
+                    );
                 } else {
                     // Le champ est une relation dans les deux schémas, on compare leurs configurations
                     $difference = $this->getRelationDiff($fieldTypeV1, $fieldTypeV2);
@@ -158,7 +216,9 @@ class SchemaValidationService
             if ($filter !== null) {
                 $diff[$entity] = array_filter(
                     $diff[$entity],
-                    function (Difference $difference) use ($filter) { return $difference->getType() === $filter; }
+                    function (Difference $difference) use ($filter) {
+                        return $difference->getType() === $filter;
+                    }
                 );
             }
         }
@@ -173,7 +233,8 @@ class SchemaValidationService
      * @param string $entity
      * @return bool
      */
-    protected function isEntityInSchema(array $schema, string $entity): bool {
+    protected function isEntityInSchema(array $schema, string $entity): bool
+    {
         return isset($schema[$entity]);
     }
 
@@ -185,19 +246,21 @@ class SchemaValidationService
      * @param string $property
      * @return bool
      */
-    protected function isPropertyInSchema(array $schema, string $entity, string $property): bool {
+    protected function isPropertyInSchema(array $schema, string $entity, string $property): bool
+    {
         return isset($schema[$entity][$property]);
     }
 
     /**
-     * Is the given field is a relation field.
+     * Is the given field a relation field.
      *
-     * @param array<string, array<string | array<string|int>> $schema
+     * @param array<string, array<string | array<string|int>>> $schema
      * @param string $entity
      * @param string $relation
      * @return bool
      */
-    protected function isRelationField(array $schema, string $entity, string $relation): bool {
+    protected function isRelationField(array $schema, string $entity, string $relation): bool
+    {
         return isset($schema[$entity][$relation]) && is_array($schema[$entity][$relation]);
     }
 
@@ -207,7 +270,8 @@ class SchemaValidationService
      * @param array<string, string|int> $relation
      * @return string
      */
-    protected function getRelationTypeLabel(array $relation): string {
+    protected function getRelationTypeLabel(array $relation): string
+    {
         if ($relation['type'] === ClassMetadataInfo::ONE_TO_ONE) {
             return 'OneToOne';
         } elseif ($relation['type'] === ClassMetadataInfo::MANY_TO_ONE) {
@@ -228,12 +292,13 @@ class SchemaValidationService
      * @param array<string, string> $relationCompared
      * @return Difference|null
      */
-    protected function getRelationDiff(array $relationReference, array $relationCompared): Difference | null
+    protected function getRelationDiff(array $relationReference, array $relationCompared): Difference|null
     {
         if ($relationReference['type'] !== $relationCompared['type']) {
             return new Difference(
                 DiffTypeEnum::DIFFERENT_RELATION_TYPE,
-                "Relation type is different : {$this->getRelationTypeLabel($relationReference)} !== {$this->getRelationTypeLabel($relationCompared)}"
+                "Relation type is different : {$this->getRelationTypeLabel($relationReference)} !== {$this->getRelationTypeLabel($relationCompared)}",
+                $relationReference
             );
         }
 
@@ -242,7 +307,8 @@ class SchemaValidationService
         ) {
             return new Difference(
                 DiffTypeEnum::DIFFERENT_RELATION_CONFIGURATION,
-                "Relation configuration is different (targetEntity)"
+                "Relation configuration is different (targetEntity)",
+                $relationReference
             );
         }
 
@@ -251,7 +317,8 @@ class SchemaValidationService
         ) {
             return new Difference(
                 DiffTypeEnum::DIFFERENT_RELATION_CONFIGURATION,
-                "Relation configuration is different (mappedBy)"
+                "Relation configuration is different (mappedBy)",
+                $relationReference
             );
         }
 
@@ -264,8 +331,375 @@ class SchemaValidationService
      * @param string $fullName
      * @return string
      */
-    protected function fullNameToEntityName(string $fullName): string {
+    protected function fullNameToEntityName(string $fullName): string
+    {
         $parts = explode('\\', $fullName);
         return array_pop($parts);
     }
+
+    protected function getFullNameFromEntityName(string $entityName): ?string
+    {
+        return $this->entityNamesMapping[$entityName] ?? null;
+    }
+
+    protected function getNamespaceFromEntityName(string $entityName): ?string
+    {
+        $fullName = $this->getFullNameFromEntityName($entityName);
+        if (!$fullName) {
+            return null;
+        }
+        $parts = explode('\\', $fullName);
+        array_pop($parts);
+        return implode('\\', $parts);
+    }
+
+    public function makeSnippets(array $diff): void
+    {
+        $snippetsDir = Path::join(Path::getProjectDir(), 'schema_validation_snippets');
+
+        if (is_dir($snippetsDir)) {
+            FileUtils::rrmDir($snippetsDir);
+        }
+        mkdir($snippetsDir, 0777, true);
+
+        $printer = new PsrPrinter;
+
+        foreach ($diff as $entity => $differences) {
+            if (empty($differences)) {
+                continue;
+            }
+
+            $class = $this->makeSnippetEntityClass($entity);
+
+            $methods = [];
+
+            if (is_array($differences)) {
+                foreach ($differences as $field => $difference) {
+                    if (!is_array($difference->getExpectedType())
+                    ) {
+                        $prop = $this->makeSnippetEntitySimpleProp($field, $difference->getExpectedType());
+                        $methods[] = $this->makeSnippetGetterForProp($prop);
+                        $methods[] = $this->makeSnippetSetterForProp($prop);
+
+                    } else {
+                        $prop = $this->makeSnippetEntityCollectionProp($field, $difference->getExpectedType());
+                        $methods[] = $this->makeSnippetGetterForProp($prop);
+
+                        if (
+                            isset($difference->getExpectedType()['type']) &&
+                            in_array($difference->getExpectedType()['type'], [ClassMetadataInfo::ONE_TO_MANY, ClassMetadataInfo::MANY_TO_MANY])
+                        ) {
+                            $methods[] = $this->makeSnippetAdderForCollection($prop);
+                            $methods[] = $this->makeSnippetRemoverForCollection($prop);
+                        } else {
+                            $methods[] = $this->makeSnippetSetterForProp($prop);
+                        }
+                    }
+                    $class->addMember($prop);
+                }
+            } else {
+                $class->addProperty('id', 'int');
+            }
+
+            foreach ($methods as $method) {
+                $class->addMember($method);
+            }
+
+            $file = new PhpFile;
+            $file->addComment('This file is auto-generated.');
+            $file->setStrictTypes();
+
+            $namespaceValue = $this->getNamespaceFromEntityName($entity) ?? ('App\\Entity\\' . $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');
+
+            if (is_array($differences)) {
+                foreach ($this->getEntityToImportFromRelations($differences) as $use) {
+                    $namespace->addUse($use);
+                }
+            }
+
+            $namespace->add($class);
+
+            $file->addNamespace($namespace);
+
+            $fullname = $this->getFullNameFromEntityName($entity) ?? $entity;
+            if ($fullname === $entity) {
+                var_dump('Entity namespace not found: ' . $entity);
+            }
+
+            $relativePath = str_replace('\\', '/', $fullname) . '.php';
+
+            $fileName = Path::join($snippetsDir, $relativePath);
+
+            if (!is_dir(dirname($fileName))) {
+                mkdir(dirname($fileName), 0777, true);
+            }
+
+            $f = fopen($fileName, 'w+');
+            try {
+                fwrite($f, $printer->printFile($file));
+            } finally {
+                fclose($f);
+            }
+        }
+    }
+
+    protected function makeSnippetEntityClass(string $entity): ClassType
+    {
+        $class = new ClassType($entity);
+
+        $class->setAttributes([
+            new Attribute(ApiResource::class, ['operations' => []]),
+            new Attribute(Entity::class, [])
+        ]);
+
+        return $class;
+    }
+
+    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;
+            }
+
+            $fullName = $this->fullNameToEntityName($difference->getExpectedType()['targetEntity']);
+            if (!$fullName) {
+                continue;
+            }
+
+            $entityName = $this->getFullNameFromEntityName($fullName);
+            if (!$entityName) {
+                continue;
+            }
+
+            $imports[] = $entityName;
+        }
+
+        return $imports;
+    }
+
+    protected function makeSnippetEntitySimpleProp(string $name, string $type): Property
+    {
+        $php_type = $type;
+        $php_type = str_replace('text', 'string', $php_type);
+        $php_type = str_replace('boolean', 'bool', $php_type);
+        $php_type = str_replace('integer', 'int', $php_type);
+        $php_type = str_replace('datetime', '?\DateTimeInterface', $php_type);
+        $php_type = str_replace('json_array', 'array', $php_type);
+
+        $prop = new Property($name);
+        $prop->setPrivate();
+        $prop->setType($php_type);
+
+        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;
+    }
+
+    protected function makeSnippetEntityCollectionProp(string $name, array $type): Property
+    {
+        $prop = new Property($name);
+        $prop->setType('Collection');
+
+        if ($type['type'] === ClassMetadataInfo::ONE_TO_MANY) {
+            $prop->setType(Collection::class);
+
+            $options = [];
+            if (isset($type['mappedBy'])) {
+                $options['mappedBy'] = $type['mappedBy'];
+            }
+            if (isset($type['targetEntity'])) {
+                $options['targetEntity'] = $this->fullNameToEntityName($type['targetEntity']) . '::class';
+            }
+            if (isset($type['inversedBy'])) {
+                $options['inversedBy'] = $type['inversedBy'];
+            }
+            if (isset($type['cascade'])) {
+                $options['cascade'] = $type['cascade'];
+            } else {
+                $options['cascade'] = ['persist'];
+            }
+            if (isset($type['orphanRemoval'])) {
+                $options['orphanRemoval'] = $type['orphanRemoval'];
+            }
+
+            $prop->addAttribute(OneToMany::class, $options);
+        } else if ($type['type'] === ClassMetadataInfo::MANY_TO_MANY) {
+            $prop->setType(Collection::class);
+
+            $options = [];
+            if (isset($type['mappedBy'])) {
+                $options['mappedBy'] = $type['mappedBy'];
+            }
+            if (isset($type['targetEntity'])) {
+                $options['targetEntity'] = $this->fullNameToEntityName($type['targetEntity']) . '::class';
+            }
+            if (isset($type['inversedBy'])) {
+                $options['inversedBy'] = $type['inversedBy'];
+            }
+            if (isset($type['cascade'])) {
+                $options['cascade'] = $type['cascade'];
+            } else {
+                $options['cascade'] = ['persist'];
+            }
+            if (isset($type['orphanRemoval'])) {
+                $options['orphanRemoval'] = $type['orphanRemoval'];
+            }
+
+            $prop->addAttribute(ManyToMany::class, $options);
+        } else if ($type['type'] === ClassMetadataInfo::ONE_TO_ONE) {
+            $newType = 'mixed';
+            if (isset($type['targetEntity'])) {
+                $targetEntityName = $this->fullNameToEntityName($type['targetEntity']);
+                $localFullName = $this->getFullNameFromEntityName($targetEntityName);
+                if ($localFullName) {
+                    $newType = $localFullName;
+                }
+            }
+
+            $prop->setType($newType);
+
+            $options = [];
+            if (isset($type['inversedBy'])) {
+                $options['inversedBy'] = $type['inversedBy'];
+            }
+            if (isset($type['targetEntity'])) {
+                $options['targetEntity'] = $this->fullNameToEntityName($type['targetEntity']) . '::class';
+            }
+            if (isset($type['cascade'])) {
+                $options['cascade'] = $type['cascade'];
+            } else {
+                $options['cascade'] = ['persist'];
+            }
+            $prop->addAttribute(OneToOne::class, $options);
+            $prop->addAttribute(JoinColumn::class, []);
+
+        } else if ($type['type'] === ClassMetadataInfo::MANY_TO_ONE) {
+            $newType = 'mixed';
+            if (isset($type['targetEntity'])) {
+                $targetEntityName = $this->fullNameToEntityName($type['targetEntity']);
+                $localFullName = $this->getFullNameFromEntityName($targetEntityName);
+                if ($localFullName) {
+                    $newType = $localFullName;
+                }
+            }
+            $prop->setType($newType);
+
+            $options = [];
+            if (isset($type['cascade'])) {
+                $options['cascade'] = $type['cascade'];
+            }
+            if (isset($type['inversedBy'])) {
+                $options['inversedBy'] = $type['inversedBy'];
+            }
+
+            $prop->addAttribute(ManyToOne::class, $options);
+            $prop->addAttribute(JoinColumn::class, ['referencedColumnName' => 'id', 'nullable' => false, 'onDelete' => 'SET NULL']);
+
+        } else {
+            throw new RuntimeException('Unknown relation type');
+        }
+
+        return $prop;
+    }
+
+    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;
+    }
+
+    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 makeSnippetAdderForCollection(Property $prop): Method {
+
+        $singularPropName = rtrim($prop->getName(), 's');
+
+        $method = new Method('add' . ucfirst($singularPropName));
+
+        $parameter = new Parameter($singularPropName);
+        $parameter->setType($prop->getType());
+        $method->setParameters([$parameter]);
+
+        $method->setReturnType('self');
+        $method->setBody(
+            implode(
+                "\n",
+                [
+                    'if (!$this->' . $prop->getName() . '->contains($' . $singularPropName . ')) {',
+                    '    $this->' . $prop->getName() . '[] = $' . $singularPropName . ';',
+                    '}',
+                    '',
+                    'return $this;',
+                ]
+            )
+        );
+        return $method;
+    }
+
+    protected function makeSnippetRemoverForCollection(Property $prop): Method {
+        $singularPropName = rtrim($prop->getName(), 's');
+
+        $method = new Method('remove' . ucfirst($singularPropName));
+
+        $parameter = new Parameter($singularPropName);
+        $parameter->setType($prop->getType());
+        $method->setParameters([$parameter]);
+
+        $method->setReturnType('self');
+        $method->setBody(
+            implode(
+                "\n",
+                [
+                    '$this->' . $prop->getName() . '->removeElement($' . $singularPropName . ');',
+                    '',
+                    'return $this;',
+                ]
+            )
+        );
+        return $method;
+    }
 }

+ 19 - 0
src/Service/Utils/FileUtils.php

@@ -76,4 +76,23 @@ class FileUtils
     {
         return file_get_contents($path);
     }
+
+    /**
+     * Recursively remove a directory
+     *
+     * @param string $path
+     * @return void
+     */
+    public static function rrmDir(string $path): void
+    {
+        if (!is_dir($path)) {
+            throw new \RuntimeException(sprintf('Path %s is not a directory', $path));
+        }
+
+        $files = glob($path.'/*');
+        foreach ($files as $file) {
+            is_dir($file) ? self::rrmDir($file) : unlink($file);
+        }
+        rmdir($path);
+    }
 }