> $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 */ 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 $differences * * @return array */ 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> $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); 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 $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); } $options = []; if (isset($type['mappedBy'])) { $options['mappedBy'] = $type['mappedBy']; } if (isset($type['targetEntity'])) { $options['targetEntity'] = $this->entityUtils->getEntityNameFromFullName($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']) && ($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, ]; $prop->addAttribute($relationClassNames[$type['type']], $options); if ($type['type'] === ClassMetadataInfo::MANY_TO_ONE) { $prop->addAttribute(JoinColumn::class, ['referencedColumnName' => 'id', 'nullable' => false, 'onDelete' => 'SET NULL']); } return $prop; } /** * Make the '__construct' method with collections initialization. * * @param array $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; } /** * Make an 'adder' method for the given property. */ protected function makeSnippetAdderForCollection(Property $prop): Method { $singularPropName = rtrim($prop->getName(), 's'); $method = new Method('add'.ucfirst($singularPropName)); $targetEntityName = $this->getTargetEntityNameFromCollectionProp($prop); $parameter = new Parameter($singularPropName); $parameter->setType($targetEntityName ?? 'mixed'); $method->setParameters([$parameter]); $method->setReturnType('self'); $method->setBody( implode( "\n", [ 'if (!$this->'.$prop->getName().'->contains($'.$singularPropName.')) {', ' $this->'.$prop->getName().'[] = $'.$singularPropName.';', '}', '', 'return $this;', ] ) ); return $method; } /** * Make a 'remover' method for the given property. */ protected function makeSnippetRemoverForCollection(Property $prop): Method { $singularPropName = rtrim($prop->getName(), 's'); $method = new Method('remove'.ucfirst($singularPropName)); $targetEntityName = $this->getTargetEntityNameFromCollectionProp($prop); $parameter = new Parameter($singularPropName); $parameter->setType($targetEntityName ?? 'mixed'); $method->setParameters([$parameter]); $method->setReturnType('self'); $method->setBody( implode( "\n", [ '$this->'.$prop->getName().'->removeElement($'.$singularPropName.');', '', '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); } }