> $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); $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 $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>> $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>> $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>> $type * * @return array */ 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 $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); } }