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== $repository_class_name ?>::class)
+ * @ORM\Entity(repositoryClass== $repository_class_name ?>::class)
+
* @ORM\Table(name="`= $table_name ?>`")
*/
+
#[ApiResource]
#[Broadcast]
+
+#[ORM\Entity(repositoryClass: = $repository_class_name ?>::class)]
+ #[ORM\Table(name: '`= $table_name ?>`')]
+
class = $class_name."\n" ?>
{
- /**
+ /**
* @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);
}),
];