diff --git a/src/DependencyInjection/CompilerPass/SetDoctrineAnnotatedPrefixesPass.php b/src/DependencyInjection/CompilerPass/SetDoctrineAnnotatedPrefixesPass.php index f51fc944f..e62104ad9 100644 --- a/src/DependencyInjection/CompilerPass/SetDoctrineAnnotatedPrefixesPass.php +++ b/src/DependencyInjection/CompilerPass/SetDoctrineAnnotatedPrefixesPass.php @@ -59,10 +59,9 @@ public function process(ContainerBuilder $container) $methodCalls[$i] = $arguments; } - $isAnnotated = false !== strpos($arguments[0], '_annotation_metadata_driver'); $annotatedPrefixes[$managerName][] = [ $arguments[1], - $isAnnotated ? new Reference($arguments[0]) : null, + new Reference($arguments[0]), ]; } diff --git a/src/Doctrine/DoctrineHelper.php b/src/Doctrine/DoctrineHelper.php index 4e0aab143..04857c0ee 100644 --- a/src/Doctrine/DoctrineHelper.php +++ b/src/Doctrine/DoctrineHelper.php @@ -16,6 +16,7 @@ use Doctrine\Common\Persistence\Mapping\MappingException as LegacyPersistenceMappingException; use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\Driver\AttributeDriver; use Doctrine\ORM\Mapping\MappingException as ORMMappingException; use Doctrine\ORM\Mapping\NamingStrategy; use Doctrine\ORM\Tools\DisconnectedClassMetadataFactory; @@ -23,6 +24,7 @@ use Doctrine\Persistence\Mapping\AbstractClassMetadataFactory; use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\Mapping\Driver\AnnotationDriver; +use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\Mapping\Driver\MappingDriverChain; use Doctrine\Persistence\Mapping\MappingException as PersistenceMappingException; use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; @@ -122,6 +124,43 @@ public function isClassAnnotated(string $className): bool return false; } + public function doesClassUsesAttributes(string $className): bool + { + /** @var EntityManagerInterface $em */ + $em = $this->getRegistry()->getManagerForClass($className); + + if (null === $em) { + throw new \InvalidArgumentException(sprintf('Cannot find the entity manager for class "%s"', $className)); + } + + if (null === $this->annotatedPrefixes) { + // doctrine-bundle <= 2.2 + $metadataDriver = $em->getConfiguration()->getMetadataDriverImpl(); + + if (!$this->isInstanceOf($metadataDriver, MappingDriverChain::class)) { + return $metadataDriver instanceof AttributeDriver; + } + + foreach ($metadataDriver->getDrivers() as $namespace => $driver) { + if (0 === strpos($className, $namespace)) { + return $driver instanceof AttributeDriver; + } + } + + return $metadataDriver->getDefaultDriver() instanceof AttributeDriver; + } + + $managerName = array_search($em, $this->getRegistry()->getManagers(), true); + + foreach ($this->annotatedPrefixes[$managerName] as [$prefix, $attributeDriver]) { + if (0 === strpos($className, $prefix)) { + return null !== $attributeDriver; + } + } + + return false; + } + public function getEntitiesForAutocomplete(): array { $entities = []; @@ -268,4 +307,27 @@ public function isKeyword(string $name): bool return $connection->getDatabasePlatform()->getReservedKeywordsList()->isKeyword($name); } + + /** + * this method try to find the correct MappingDriver for the given namespace/class + * To determine which MappingDriver belongs to the class we check the prefixes configured in Doctrine and use the + * prefix that has the closest match to the given $namespace. + */ + public function getMappingDriverForNamespace(string $namespace): ?MappingDriver + { + $lowestCharacterDiff = null; + $foundDriver = null; + + foreach ($this->annotatedPrefixes as $key => $mappings) { + foreach ($mappings as [$prefix, $driver]) { + $diff = substr_compare($namespace, $prefix, 0); + + if (null === $lowestCharacterDiff || $diff < $lowestCharacterDiff) { + $foundDriver = $driver; + } + } + } + + return $foundDriver; + } } diff --git a/src/Doctrine/EntityClassGenerator.php b/src/Doctrine/EntityClassGenerator.php index 20644ec3c..cbc3eeea1 100644 --- a/src/Doctrine/EntityClassGenerator.php +++ b/src/Doctrine/EntityClassGenerator.php @@ -33,7 +33,7 @@ public function __construct(Generator $generator, DoctrineHelper $doctrineHelper $this->doctrineHelper = $doctrineHelper; } - public function generateEntityClass(ClassNameDetails $entityClassDetails, bool $apiResource, bool $withPasswordUpgrade = false, bool $generateRepositoryClass = true, bool $broadcast = false): string + public function generateEntityClass(ClassNameDetails $entityClassDetails, bool $apiResource, bool $withPasswordUpgrade = false, bool $generateRepositoryClass = true, bool $broadcast = false, bool $doctrineAttributes = false): string { $repoClassDetails = $this->generator->createClassNameDetails( $entityClassDetails->getRelativeName(), @@ -53,6 +53,7 @@ public function generateEntityClass(ClassNameDetails $entityClassDetails, bool $ 'broadcast' => $broadcast, 'should_escape_table_name' => $this->doctrineHelper->isKeyword($tableName), 'table_name' => $tableName, + 'doctrine_use_attributes' => $doctrineAttributes, ] ); diff --git a/src/Maker/MakeEntity.php b/src/Maker/MakeEntity.php index b880c63ff..ff5339d16 100644 --- a/src/Maker/MakeEntity.php +++ b/src/Maker/MakeEntity.php @@ -13,6 +13,9 @@ use ApiPlatform\Core\Annotation\ApiResource; use Doctrine\DBAL\Types\Type; +use Doctrine\ORM\Mapping\Driver\AttributeDriver; +use Doctrine\Persistence\Mapping\Driver\AnnotationDriver; +use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Symfony\Bundle\MakerBundle\ConsoleStyle; use Symfony\Bundle\MakerBundle\DependencyBuilder; use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper; @@ -160,6 +163,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen 'Entity\\' ); + $mappingDriver = $this->doctrineHelper->getMappingDriverForNamespace($entityClassDetails->getFullName()); $classExists = class_exists($entityClassDetails->getFullName()); if (!$classExists) { $broadcast = $input->getOption('broadcast'); @@ -168,7 +172,8 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $input->getOption('api-resource'), false, true, - $broadcast + $broadcast, + $mappingDriver instanceof AttributeDriver ); if ($broadcast) { @@ -186,8 +191,11 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $generator->writeChanges(); } - if (!$this->doesEntityUseAnnotationMapping($entityClassDetails->getFullName())) { - throw new RuntimeCommandException(sprintf('Only annotation mapping is supported by make:entity, but the %s class uses a different format. If you would like this command to generate the properties & getter/setter methods, add your mapping configuration, and then re-run this command with the --regenerate flag.', $entityClassDetails->getFullName())); + if ( + !$this->doesEntityUseAnnotationMapping($entityClassDetails->getFullName()) + && !$this->doesEntityUseAttributeMapping($entityClassDetails->getFullName()) + ) { + throw new RuntimeCommandException(sprintf('Only annotation or attribute mapping is supported by make:entity, but the %s class uses a different format. If you would like this command to generate the properties & getter/setter methods, add your mapping configuration, and then re-run this command with the --regenerate flag.', $entityClassDetails->getFullName())); } if ($classExists) { @@ -204,7 +212,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen } $currentFields = $this->getPropertyNames($entityClassDetails->getFullName()); - $manipulator = $this->createClassManipulator($entityPath, $io, $overwrite); + $manipulator = $this->createClassManipulator($entityPath, $io, $overwrite, $mappingDriver); $isFirstField = true; while (true) { @@ -232,7 +240,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen $otherManipulator = $manipulator; } else { $otherManipulatorFilename = $this->getPathOfClass($newField->getInverseClass()); - $otherManipulator = $this->createClassManipulator($otherManipulatorFilename, $io, $overwrite); + $otherManipulator = $this->createClassManipulator($otherManipulatorFilename, $io, $overwrite, $mappingDriver); } switch ($newField->getType()) { case EntityRelation::MANY_TO_ONE: @@ -247,7 +255,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen // the new field being added to THIS entity is the inverse $newFieldName = $newField->getInverseProperty(); $otherManipulatorFilename = $this->getPathOfClass($newField->getOwningClass()); - $otherManipulator = $this->createClassManipulator($otherManipulatorFilename, $io, $overwrite); + $otherManipulator = $this->createClassManipulator($otherManipulatorFilename, $io, $overwrite, $mappingDriver); // The *other* class will receive the ManyToOne $otherManipulator->addManyToOneRelation($newField->getOwningRelation()); @@ -791,9 +799,13 @@ private function askRelationType(ConsoleStyle $io, string $entityClass, string $ return $io->askQuestion($question); } - private function createClassManipulator(string $path, ConsoleStyle $io, bool $overwrite): ClassSourceManipulator + private function createClassManipulator(string $path, ConsoleStyle $io, bool $overwrite, ?MappingDriver $mappingDriver = null): ClassSourceManipulator { - $manipulator = new ClassSourceManipulator($this->fileManager->getFileContents($path), $overwrite); + $useAnnotations = null === $mappingDriver || $mappingDriver instanceof AnnotationDriver && !$mappingDriver instanceof AttributeDriver; + $useAttributes = $mappingDriver instanceof AttributeDriver; + + $manipulator = new ClassSourceManipulator($this->fileManager->getFileContents($path), $overwrite, $useAnnotations, true, $useAttributes); + $manipulator->setIo($io); return $manipulator; @@ -848,6 +860,26 @@ private function doesEntityUseAnnotationMapping(string $className): bool return $this->doctrineHelper->isClassAnnotated($className); } + private function doesEntityUseAttributeMapping(string $className): bool + { + if (\PHP_VERSION < 80000) { + return false; + } + + if (!class_exists($className)) { + $otherClassMetadatas = $this->doctrineHelper->getMetadata(Str::getNamespace($className).'\\', true); + + // if we have no metadata, we should assume this is the first class being mapped + if (empty($otherClassMetadatas)) { + return false; + } + + $className = reset($otherClassMetadatas)->getName(); + } + + return $this->doctrineHelper->doesClassUsesAttributes($className); + } + private function getEntityNamespace(): string { return $this->doctrineHelper->getEntityNamespace(); diff --git a/src/Resources/skeleton/doctrine/Entity.tpl.php b/src/Resources/skeleton/doctrine/Entity.tpl.php index 9c013803e..c56a04940 100644 --- a/src/Resources/skeleton/doctrine/Entity.tpl.php +++ b/src/Resources/skeleton/doctrine/Entity.tpl.php @@ -9,29 +9,39 @@ use Symfony\UX\Turbo\Attribute\Broadcast; + /** * @ApiResource() * @Broadcast() - * @ORM\Entity(repositoryClass=::class) + * @ORM\Entity(repositoryClass=::class) + * @ORM\Table(name="``") */ + #[ApiResource] #[Broadcast] + +#[ORM\Entity(repositoryClass: ::class)] + #[ORM\Table(name: '``')] + class { - /** + /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") */ - private $id; + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private $id; public function getId(): ?int { diff --git a/src/Util/ClassSourceManipulator.php b/src/Util/ClassSourceManipulator.php index 52c670c7d..d8615ea5d 100644 --- a/src/Util/ClassSourceManipulator.php +++ b/src/Util/ClassSourceManipulator.php @@ -41,6 +41,7 @@ final class ClassSourceManipulator private $overwrite; private $useAnnotations; + private $useAttributes; private $fluentMutators; private $parser; private $lexer; @@ -55,7 +56,7 @@ final class ClassSourceManipulator private $pendingComments = []; - public function __construct(string $sourceCode, bool $overwrite = false, bool $useAnnotations = true, bool $fluentMutators = true) + public function __construct(string $sourceCode, bool $overwrite = false, bool $useAnnotations = true, bool $fluentMutators = true, bool $useAttributes = false) { $this->overwrite = $overwrite; $this->useAnnotations = $useAnnotations; @@ -71,6 +72,7 @@ public function __construct(string $sourceCode, bool $overwrite = false, bool $u $this->printer = new PrettyPrinter(); $this->setSourceCode($sourceCode); + $this->useAttributes = $useAttributes; } public function setIo(ConsoleStyle $io) @@ -83,18 +85,23 @@ public function getSourceCode(): string return $this->sourceCode; } - public function addEntityField(string $propertyName, array $columnOptions, array $comments = []) + public function addEntityField(string $propertyName, array $columnOptions, array $comments = [], array $attributes = []) { $typeHint = $this->getEntityTypeHint($columnOptions['type']); $nullable = $columnOptions['nullable'] ?? false; $isId = (bool) ($columnOptions['id'] ?? false); - $comments[] = $this->buildAnnotationLine('@ORM\Column', $columnOptions); + if ($this->useAnnotations) { + $comments[] = $this->buildAnnotationLine('@ORM\Column', $columnOptions); + } elseif ($this->useAttributes) { + $attributes[] = $this->buildAttributeNode('ORM\Column', $columnOptions); + } + $defaultValue = null; if ('array' === $typeHint) { $defaultValue = new Node\Expr\Array_([], ['kind' => Node\Expr\Array_::KIND_SHORT]); } - $this->addProperty($propertyName, $comments, $defaultValue); + $this->addProperty($propertyName, $comments, $defaultValue, $attributes); $this->addGetter( $propertyName, @@ -123,7 +130,16 @@ public function addEmbeddedEntity(string $propertyName, string $className) ), ]; - $this->addProperty($propertyName, $annotations); + $attributes = [ + $this->buildAttributeNode( + 'ORM\\Embedded', + [ + 'class' => new ClassNameValue($className, $typeHint), + ] + ), + ]; + + $this->addProperty($propertyName, $annotations, null, $attributes); // logic to avoid re-adding the same ArrayCollection line $addEmbedded = true; @@ -311,7 +327,7 @@ public function createMethodLevelBlankLine() return $this->createBlankLineNode(self::CONTEXT_CLASS_METHOD); } - public function addProperty(string $name, array $annotationLines = [], $defaultValue = null) + public function addProperty(string $name, array $annotationLines = [], $defaultValue = null, array $attributes = []) { if ($this->propertyExists($name)) { // we never overwrite properties @@ -323,6 +339,12 @@ public function addProperty(string $name, array $annotationLines = [], $defaultV $newPropertyBuilder->setDocComment($this->createDocBlock($annotationLines)); } + if ($attributes && $this->useAttributes) { + foreach ($attributes as $attribute) { + $newPropertyBuilder->addAttribute($attribute); + } + } + if (null !== $defaultValue) { $newPropertyBuilder->setDefault($defaultValue); } @@ -502,13 +524,23 @@ private function addSingularRelation(BaseRelation $relation) ), ]; + $attributes = [ + $this->buildAttributeNode( + $relation instanceof RelationManyToOne ? 'ORM\\ManyToOne' : 'ORM\\OneToOne', + $annotationOptions + ), + ]; + if (!$relation->isNullable() && $relation->isOwning()) { $annotations[] = $this->buildAnnotationLine('@ORM\\JoinColumn', [ 'nullable' => false, ]); + $attributes[] = $this->buildAttributeNode('ORM\\JoinColumn', [ + 'nullable' => false, + ]); } - $this->addProperty($relation->getPropertyName(), $annotations); + $this->addProperty($relation->getPropertyName(), $annotations, null, $attributes); $this->addGetter( $relation->getPropertyName(), @@ -579,8 +611,14 @@ private function addCollectionRelation(BaseCollectionRelation $relation) $annotationOptions ), ]; + $attributes = [ + $this->buildAttributeNode( + $relation instanceof RelationManyToMany ? 'ORM\\ManyToMany' : 'ORM\\OneToMany', + $annotationOptions + ), + ]; - $this->addProperty($relation->getPropertyName(), $annotations); + $this->addProperty($relation->getPropertyName(), $annotations, null, $attributes); // logic to avoid re-adding the same ArrayCollection line $addArrayCollection = true; @@ -1326,4 +1364,100 @@ private function addMethodParams(Builder\Method $methodBuilder, array $params) $methodBuilder->addParam($param); } } + + /** + * builds a PHPParser Expr Node based on the value given in $value + * throws an Exception when the given $value is not resolvable by this method. + * + * @param mixed $value + * + * @throws \Exception + */ + private function buildNodeExprByValue($value): Node\Expr + { + switch (\gettype($value)) { + case 'string': + $nodeValue = new Node\Scalar\String_($value); + break; + case 'integer': + $nodeValue = new Node\Scalar\LNumber($value); + break; + case 'double': + $nodeValue = new Node\Scalar\DNumber($value); + break; + case 'boolean': + $nodeValue = new Node\Expr\ConstFetch(new Node\Name($value ? 'true' : 'false')); + break; + case 'array': + $context = $this; + $arrayItems = array_map(static function ($key, $value) use ($context) { + return new Node\Expr\ArrayItem( + $context->buildNodeExprByValue($value), + !\is_int($key) ? $context->buildNodeExprByValue($key) : null + ); + }, array_keys($value), array_values($value)); + $nodeValue = new Node\Expr\Array_($arrayItems, ['kind' => Node\Expr\Array_::KIND_SHORT]); + break; + default: + $nodeValue = null; + } + + if (null === $nodeValue) { + if ($value instanceof ClassNameValue) { + $nodeValue = new Node\Expr\ConstFetch(new Node\Name(sprintf('%s::class', $value->getShortName()))); + } else { + throw new \Exception(sprintf('Cannot build a node expr for value of type "%s"', \gettype($value))); + } + } + + return $nodeValue; + } + + /** + * builds an PHPParser attribute node. + * + * @param string $attributeClass the attribute class which should be used for the attribute + * @param array $options the named arguments for the attribute ($key = argument name, $value = argument value) + */ + private function buildAttributeNode(string $attributeClass, array $options) + { + $options = $this->sortOptionsByClassConstructorParameters($options, $attributeClass); + + $context = $this; + $nodeArguments = array_map(static function ($option, $value) use ($context) { + return new Node\Arg($context->buildNodeExprByValue($value), false, false, [], new Node\Identifier($option)); + }, array_keys($options), array_values($options)); + + return new Node\Attribute( + new Node\Name($attributeClass), + $nodeArguments + ); + } + + /** + * sort the given options based on the constructor parameters for the given $classString + * this prevents code inspections warnings for IDEs like intellij/phpstorm. + * + * option keys that are not found in the constructor will be added at the end of the sorted array + */ + private function sortOptionsByClassConstructorParameters(array $options, string $classString): array + { + if ('ORM\\' === substr($classString, 0, 4)) { + $classString = sprintf('Doctrine\\ORM\\Mapping\\%s', substr($classString, 4)); + } + + $constructorParameterNames = array_map(static function (\ReflectionParameter $reflectionParameter) { + return $reflectionParameter->getName(); + }, (new \ReflectionClass($classString))->getConstructor()->getParameters()); + + $sorted = []; + foreach ($constructorParameterNames as $name) { + if (\array_key_exists($name, $options)) { + $sorted[$name] = $options[$name]; + unset($options[$name]); + } + } + + return array_merge($sorted, $options); + } } diff --git a/tests/Maker/MakeEntityTest.php b/tests/Maker/MakeEntityTest.php index bc040a2d7..ebcd199f7 100644 --- a/tests/Maker/MakeEntityTest.php +++ b/tests/Maker/MakeEntityTest.php @@ -506,7 +506,7 @@ public function getTestDetails() ->configureDatabase(false) ->setCommandAllowedToFail(true) ->assert(function (string $output, string $directory) { - $this->assertStringContainsString('Only annotation mapping is supported', $output); + $this->assertStringContainsString('Only annotation or attribute mapping is supported', $output); }), ]; @@ -529,7 +529,7 @@ public function getTestDetails() ->configureDatabase(false) ->setCommandAllowedToFail(true) ->assert(function (string $output, string $directory) { - $this->assertStringContainsString('Only annotation mapping is supported', $output); + $this->assertStringContainsString('Only annotation or attribute mapping is supported', $output); }), ];