From 3118fb3700a39173154c8de62b3c4f16112163df Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 4 Feb 2026 08:41:59 +0100 Subject: [PATCH] Support adding fields in make command --- composer.json | 3 +- src/Maker/MakeDocument.php | 9 +- src/MongoDB/ClassSourceManipulator.php | 587 ++++++++++++++++++ src/MongoDB/MongoDBHelper.php | 90 +++ templates/mongodb/Document.tpl.php | 21 +- tests/Maker/MakeDocumentTest.php | 30 + tests/MongoDB/MongoDBHelperTest.php | 80 +++ .../GeneratedDocumentTest.php.twig | 2 +- 8 files changed, 797 insertions(+), 25 deletions(-) create mode 100644 src/MongoDB/ClassSourceManipulator.php diff --git a/composer.json b/composer.json index fef5459..7864d18 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "doctrine/mongodb-odm-bundle": "^5.6", "symfony/http-kernel": "^7.4|^8", "symfony/maker-bundle": "^1@dev", - "symfony/polyfill-php85": "^1.33" + "symfony/polyfill-php85": "^1.33", + "symfony/uid": "^7.4|^8" }, "require-dev": { "doctrine/coding-standard": "^14", diff --git a/src/Maker/MakeDocument.php b/src/Maker/MakeDocument.php index a62d97b..4f68777 100644 --- a/src/Maker/MakeDocument.php +++ b/src/Maker/MakeDocument.php @@ -5,6 +5,7 @@ namespace Doctrine\Bundle\MongoDBMakerBundle\Maker; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; +use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\ClassSourceManipulator; use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\DocumentClassGenerator; use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\MongoDBHelper; use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\Validator; @@ -23,7 +24,6 @@ use Symfony\Bundle\MakerBundle\Str; use Symfony\Bundle\MakerBundle\Util\ClassDetails; use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassProperty; -use Symfony\Bundle\MakerBundle\Util\ClassSourceManipulator; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -152,7 +152,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen } $currentFields = $this->getPropertyNames($documentClassDetails->getFullName()); - $manipulator = $this->createClassManipulator($documentPath, $io, $overwrite); + $manipulator = $this->createClassManipulator($documentPath, $io); $isFirstField = true; while (true) { @@ -163,7 +163,7 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen break; } - $manipulator->addEntityField($newField); + $manipulator->addDocumentField($newField); $currentFields[] = $newField->propertyName; $this->fileManager->dumpFile($documentPath, $manipulator->getSourceCode()); @@ -351,11 +351,10 @@ private function verifyDocumentName(string $documentName): array return $matches; } - private function createClassManipulator(string $path, ConsoleStyle $io, bool $overwrite): ClassSourceManipulator + private function createClassManipulator(string $path, ConsoleStyle $io): ClassSourceManipulator { $manipulator = new ClassSourceManipulator( sourceCode: $this->fileManager->getFileContents($path), - overwrite: $overwrite, ); $manipulator->setIo($io); diff --git a/src/MongoDB/ClassSourceManipulator.php b/src/MongoDB/ClassSourceManipulator.php new file mode 100644 index 0000000..2de201c --- /dev/null +++ b/src/MongoDB/ClassSourceManipulator.php @@ -0,0 +1,587 @@ +lexer = new Lexer\Emulative( + PhpVersion::fromString('8.4'), + ); + $this->parser = new Parser\Php7($this->lexer); + + $this->printer = new PrettyPrinter(); + + $this->setSourceCode($sourceCode); + } + + public function setIo(ConsoleStyle $io): void + { + $this->io = $io; + } + + public function getSourceCode(): string + { + return $this->sourceCode; + } + + /** @throws ReflectionException */ + public function addDocumentField(ClassProperty $mapping): void + { + $typeHint = MongoDBHelper::getPropertyTypeForField($mapping->type); + if ($typeHint && MongoDBHelper::canFieldTypeBeInferredByPropertyType($mapping->type, $typeHint)) { + $mapping->needsTypeHint = false; + } + + if ($mapping->needsTypeHint) { + $typeConstant = MongoDBHelper::getTypeConstant($mapping->type); + if ($typeConstant) { + $this->addUseStatementIfNecessary(Type::class); + $mapping->type = $typeConstant; + } + } + + $nullable = $mapping->nullable ?? false; + + $attributes[] = $this->buildAttributeNode(Field::class, $mapping->getAttributes(), 'ODM'); + + $defaultValue = null; + + if ($mapping->enumType !== null) { + if ($typeHint === 'array') { + // still need to add the use statement + $this->addUseStatementIfNecessary($mapping->enumType); + + if (! $nullable) { + $defaultValue = new Node\Expr\Array_([], ['kind' => Node\Expr\Array_::KIND_SHORT]); + } + } else { + $typeHint = $this->addUseStatementIfNecessary($mapping->enumType); + } + } elseif ($typeHint === 'array' && ! $nullable) { + $defaultValue = new Node\Expr\Array_([], ['kind' => Node\Expr\Array_::KIND_SHORT]); + } elseif ($typeHint && $typeHint[0] === '\\' && strpos($typeHint, '\\', 1) !== false) { + $typeHint = $this->addUseStatementIfNecessary(substr($typeHint, 1)); + } + + $propertyType = $typeHint; + if ($propertyType && ! $defaultValue && $propertyType !== 'mixed' && $nullable) { + // all property types + $propertyType .= '|null'; + } + + $this->addProperty( + mapping: $mapping, + defaultValue: $defaultValue, + attributes: $attributes, + comments: $mapping->comments, + propertyType: $propertyType, + ); + } + + /** + * @param array $attributes + * @param string[] $comments + */ + public function addProperty(ClassProperty $mapping, mixed $defaultValue, array $attributes = [], array $comments = [], string|null $propertyType = null): void + { + $name = $mapping->propertyName; + if ($this->propertyExists($name)) { + // we never overwrite properties + return; + } + + $newPropertyBuilder = (new Builder\Property($name))->makePublic(); + + if ($propertyType !== null) { + $newPropertyBuilder->setType($propertyType); + } + + if ($this->useAttributesForDoctrineMapping) { + foreach ($attributes as $attribute) { + $newPropertyBuilder->addAttribute($attribute); + } + } + + if ($comments) { + $newPropertyBuilder->setDocComment($this->createDocBlock($comments)); + } + + if ($defaultValue !== null || $mapping->nullable) { + $newPropertyBuilder->setDefault($defaultValue); + } + + $newPropertyNode = $newPropertyBuilder->getNode(); + + $this->addNodeAfterProperties($newPropertyNode); + } + + /** @return string The alias to use when referencing this class */ + public function addUseStatementIfNecessary(string $class): string + { + $shortClassName = Str::getShortClassName($class); + if ($this->isInSameNamespace($class)) { + return $shortClassName; + } + + $namespaceNode = $this->getNamespaceNode(); + + $targetIndex = null; + $addLineBreak = false; + $lastUseStmtIndex = null; + foreach ($namespaceNode->stmts as $index => $stmt) { + if ($stmt instanceof Node\Stmt\Use_) { + // I believe this is an array to account for use statements with {} + foreach ($stmt->uses as $use) { + $alias = $use->alias ? $use->alias->name : $use->name->getLast(); + + // the use statement already exists? Don't add it again + if ($class === (string) $use->name) { + return $alias; + } + + if (str_starts_with($class, $alias)) { + return $class; + } + + if ($alias === $shortClassName) { + // we have a conflicting alias! + // to be safe, use the fully-qualified class name + // everywhere and do not add another use statement + return '\\' . $class; + } + } + + // if $class is alphabetically before this use statement, place it before + // only set $targetIndex the first time you find it + if ($targetIndex === null && Str::areClassesAlphabetical($class, (string) $stmt->uses[0]->name)) { + $targetIndex = $index; + } + + $lastUseStmtIndex = $index; + } elseif ($stmt instanceof Node\Stmt\Class_) { + if ($targetIndex !== null) { + // we already found where to place the use statement + + break; + } + + // we hit the class! If there were any use statements, + // then put this at the bottom of the use statement list + if ($lastUseStmtIndex !== null) { + $targetIndex = $lastUseStmtIndex + 1; + } else { + $targetIndex = $index; + $addLineBreak = true; + } + + break; + } + } + + if ($targetIndex === null) { + throw new Exception('Could not find a class!'); + } + + $newUseNode = (new Builder\Use_($class, Node\Stmt\Use_::TYPE_NORMAL))->getNode(); + array_splice( + $namespaceNode->stmts, + $targetIndex, + 0, + $addLineBreak ? [$newUseNode, $this->createBlankLineNode(self::CONTEXT_OUTSIDE_CLASS)] : [$newUseNode], + ); + + $this->updateSourceCodeFromNewStmts(); + + return $shortClassName; + } + + /** + * Builds a PHPParser attribute node. + * + * @param string $attributeClass The attribute class which should be used for the attribute E.g. #[Column()] + * @param array $options The named arguments for the attribute ($key = argument name, $value = argument value) + * @param ?string $attributePrefix If a prefix is provided, the node is built using the prefix. E.g. #[ORM\Column()] + * + * @throws Exception + */ + public function buildAttributeNode(string $attributeClass, array $options, string|null $attributePrefix = null): Node\Attribute + { + $options = $this->sortOptionsByClassConstructorParameters($options, $attributeClass); + + $nodeArguments = array_map(function (string $option, mixed $value) { + $context = $this; + + if ($value === null) { + return new Node\NullableType(new Node\Identifier($option)); + } + + // Use the Doctrine Types constant + if ($option === 'type' && str_starts_with($value, 'Type::')) { + return new Node\Arg( + new Node\Expr\ConstFetch(new Node\Name($value)), + false, + false, + [], + new Node\Identifier($option), + ); + } + + if ($option === 'enumType') { + return new Node\Arg( + new Node\Expr\ConstFetch(new Node\Name(Str::getShortClassName($value) . '::class')), + false, + false, + [], + new Node\Identifier($option), + ); + } + + return new Node\Arg($context->buildNodeExprByValue($value), false, false, [], new Node\Identifier($option)); + }, array_keys($options), array_values($options)); + + $class = $attributePrefix ? sprintf('%s\\%s', $attributePrefix, Str::getShortClassName($attributeClass)) : Str::getShortClassName($attributeClass); + + return new Node\Attribute( + new Node\Name($class), + $nodeArguments, + ); + } + + private function updateSourceCodeFromNewStmts(): void + { + $newCode = $this->printer->printFormatPreserving( + $this->newStmts, + $this->oldStmts, + $this->oldTokens, + ); + + // replace the 3 "fake" items that may be in the code (allowing for different indentation) + $newCode = preg_replace('/(\ |\t)*private\ \$__EXTRA__LINE;/', '', $newCode); + $newCode = preg_replace('/use __EXTRA__LINE;/', '', $newCode); + $newCode = preg_replace('/(\ |\t)*\$__EXTRA__LINE;/', '', $newCode); + + // process comment lines + foreach ($this->pendingComments as $i => $comment) { + // sanity check + $placeholder = sprintf('$__COMMENT__VAR_%d;', $i); + if (! str_contains($newCode, $placeholder)) { + // this can happen if a comment is createSingleLineCommentNode() + // is called, but then that generated code is ultimately not added + continue; + } + + $newCode = str_replace($placeholder, '// ' . $comment, $newCode); + } + + $this->pendingComments = []; + + $this->setSourceCode($newCode); + } + + private function setSourceCode(string $sourceCode): void + { + $this->sourceCode = $sourceCode; + $this->oldStmts = $this->parser->parse($sourceCode); + + $this->oldTokens = $this->parser->getTokens(); + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NodeVisitor\CloningVisitor()); + $traverser->addVisitor(new NodeVisitor\NameResolver(null, ['replaceNodes' => false])); + $this->newStmts = $traverser->traverse($this->oldStmts); + } + + private function getClassNode(): Node\Stmt\Class_ + { + $node = $this->findFirstNode(static fn ($node) => $node instanceof Node\Stmt\Class_); + + if (! $node) { + throw new Exception('Could not find class node'); + } + + return $node; + } + + private function getNamespaceNode(): Node\Stmt\Namespace_ + { + $node = $this->findFirstNode(static fn ($node) => $node instanceof Node\Stmt\Namespace_); + + if (! $node) { + throw new Exception('Could not find namespace node'); + } + + return $node; + } + + private function findFirstNode(callable $filterCallback): Node|null + { + $traverser = new NodeTraverser(); + $visitor = new NodeVisitor\FirstFindingVisitor($filterCallback); + $traverser->addVisitor($visitor); + $traverser->traverse($this->newStmts); + + return $visitor->getFoundNode(); + } + + /** @param Node[] $ast */ + private function findLastNode(callable $filterCallback, array $ast): Node|null + { + $traverser = new NodeTraverser(); + $visitor = new NodeVisitor\FindingVisitor($filterCallback); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + $nodes = $visitor->getFoundNodes(); + $node = end($nodes); + + return $node === false ? null : $node; + } + + private function createBlankLineNode(string $context): Node\Stmt\Use_|Node|Node\Stmt\Property|Node\Expr\Variable + { + return match ($context) { + self::CONTEXT_OUTSIDE_CLASS => new Builder\Use_( + '__EXTRA__LINE', + Node\Stmt\Use_::TYPE_NORMAL, + ) + ->getNode(), + self::CONTEXT_CLASS => (new Builder\Property('__EXTRA__LINE')) + ->makePrivate() + ->getNode(), + self::CONTEXT_CLASS_METHOD => new Node\Expr\Variable( + '__EXTRA__LINE', + ), + default => throw new Exception('Unknown context: ' . $context), + }; + } + + /** @param string[] $commentLines */ + private function createDocBlock(array $commentLines): string + { + $docBlock = "/**\n"; + foreach ($commentLines as $commentLine) { + if ($commentLine) { + $docBlock .= sprintf(" *%s\n", $commentLine); + } else { + // avoid the empty, extra space on blank lines + $docBlock .= " *\n"; + } + } + + return $docBlock . "\n */"; + } + + private function isInSameNamespace(string $class): bool + { + $namespace = substr($class, 0, strrpos($class, '\\')); + + return $this->getNamespaceNode()->name->toCodeString() === $namespace; + } + + /** + * Adds this new node where a new property should go. + * + * Useful for adding properties, or adding a constructor. + */ + private function addNodeAfterProperties(Node $newNode): void + { + $classNode = $this->getClassNode(); + + // try to add after last property + $targetNode = $this->findLastNode(static fn ($node) => $node instanceof Node\Stmt\Property, [$classNode]); + + // otherwise, try to add after the last constant + if (! $targetNode) { + $targetNode = $this->findLastNode(static fn ($node) => $node instanceof Node\Stmt\ClassConst, [$classNode]); + } + + // otherwise, try to add after the last trait + if (! $targetNode) { + $targetNode = $this->findLastNode(static fn ($node) => $node instanceof Node\Stmt\TraitUse, [$classNode]); + } + + // add the new property after this node + if ($targetNode) { + $index = array_search($targetNode, $classNode->stmts); + + array_splice( + $classNode->stmts, + $index + 1, + 0, + [$this->createBlankLineNode(self::CONTEXT_CLASS), $newNode], + ); + + $this->updateSourceCodeFromNewStmts(); + + return; + } + + // put right at the beginning of the class + // add an empty line, unless the class is totally empty + if (! empty($classNode->stmts)) { + array_unshift($classNode->stmts, $this->createBlankLineNode(self::CONTEXT_CLASS)); + } + + array_unshift($classNode->stmts, $newNode); + $this->updateSourceCodeFromNewStmts(); + } + + private function propertyExists(string $propertyName): bool + { + foreach ($this->getClassNode()->stmts as $i => $node) { + if ($node instanceof Node\Stmt\Property && $node->props[0]->name->toString() === $propertyName) { + return true; + } + } + + return false; + } + + /** + * @param mixed[] $value + * + * @throws Exception + */ + private function buildArrayNode(array $value): Node\Expr\Array_ + { + $context = $this; + $arrayItems = array_map(static fn ($key, $value) => new Node\Expr\ArrayItem( + $context->buildNodeExprByValue($value), + ! is_int($key) ? $context->buildNodeExprByValue($key) : null, + ), array_keys($value), array_values($value)); + + return new Node\Expr\Array_($arrayItems, ['kind' => Node\Expr\Array_::KIND_SHORT]); + } + + private function buildClassNameValueNode(mixed $value): Node\Expr\ConstFetch + { + if (! ($value instanceof ClassNameValue)) { + throw new Exception(sprintf('Cannot build a node expr for value of type "%s"', get_debug_type($value))); + } + + return new Node\Expr\ConstFetch( + new Node\Name( + sprintf('%s::class', $value->isSelf() ? 'self' : $value->getShortName()), + ), + ); + } + + /** + * 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. + * + * @throws Exception + */ + private function buildNodeExprByValue(mixed $value): Node\Expr + { + return match (gettype($value)) { + 'string' => new Node\Scalar\String_($value), + 'integer' => new Node\Scalar\LNumber($value), + 'double' => new Node\Scalar\DNumber($value), + 'boolean' => new Node\Expr\ConstFetch(new Node\Name($value ? 'true' : 'false')), + 'array' => $this->buildArrayNode($value), + default => $this->buildClassNameValueNode($value), + }; + } + + /** + * 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 + * + * @param array $options + * + * @return array + * + * @throws ReflectionException + */ + private function sortOptionsByClassConstructorParameters(array $options, string $classString): array + { + if (str_starts_with($classString, 'ODM\\')) { + $classString = sprintf('Doctrine\\ODM\\MongoDB\\Mapping\\%s', substr($classString, 4)); + } + + $constructorParameterNames = array_map(static fn (ReflectionParameter $reflectionParameter) => $reflectionParameter->getName(), (new ReflectionClass($classString))->getConstructor()->getParameters()); + + $sorted = []; + foreach ($constructorParameterNames as $name) { + if (! array_key_exists($name, $options)) { + continue; + } + + $sorted[$name] = $options[$name]; + unset($options[$name]); + } + + return array_merge($sorted, $options); + } +} diff --git a/src/MongoDB/MongoDBHelper.php b/src/MongoDB/MongoDBHelper.php index c030972..636c68b 100644 --- a/src/MongoDB/MongoDBHelper.php +++ b/src/MongoDB/MongoDBHelper.php @@ -4,16 +4,25 @@ namespace Doctrine\Bundle\MongoDBMakerBundle\MongoDB; +use DateTime; +use DateTimeImmutable; use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Types\Type; use Doctrine\Persistence\ManagerRegistry; +use ReflectionClass; +use ReflectionException; +use ReflectionNamedType; use Symfony\Bundle\MakerBundle\Str; +use Symfony\Component\Uid\Uuid; use function array_first; +use function array_flip; use function array_keys; use function array_pop; use function count; use function explode; use function implode; +use function sprintf; use function str_contains; /** @internal */ @@ -98,4 +107,85 @@ public function getDocumentsForAutocomplete(): array return $allDocuments; } + + public static function canFieldTypeBeInferredByPropertyType(string $fieldType, string $propertyType): bool + { + return match ($propertyType) { + '\\' . DateTime::class => $fieldType === Type::DATE, + '\\' . DateTimeImmutable::class => $fieldType === Type::DATE_IMMUTABLE, + Uuid::class => $fieldType === Type::UUID, + 'array' => $fieldType === Type::HASH, + 'bool' => $fieldType === Type::BOOL, + 'float' => $fieldType === Type::FLOAT, + 'int' => $fieldType === Type::INT, + 'string' => $fieldType === Type::STRING, + default => false, + }; + } + + /** @throws ReflectionException */ + public static function getPropertyTypeForField(string $fieldType): string|null + { + $propertyType = match ($fieldType) { + Type::STRING, + Type::BINDATA, + Type::BINDATABYTEARRAY, + Type::BINDATACUSTOM, + Type::BINDATAFUNC, + Type::BINDATAMD5, + Type::BINDATAUUID, + Type::BINDATAUUIDRFC4122, + Type::DECIMAL128, + Type::ID, + Type::TIMESTAMP => 'string', + Type::INT => 'int', + Type::FLOAT => 'float', + Type::BOOL => 'bool', + Type::HASH, + Type::COLLECTION, + Type::OBJECTID, + Type::VECTOR_FLOAT32, + Type::VECTOR_INT8, + Type::VECTOR_PACKED_BIT => 'array', + Type::DATE => '\\' . DateTime::class, + Type::DATE_IMMUTABLE => '\\' . DateTimeImmutable::class, + Type::UUID => '\\' . Uuid::class, + default => null, + }; + + $typesMap = Type::getTypesMap(); + if ($propertyType !== null || ! isset($typesMap[$fieldType])) { + return $propertyType; + } + + $reflection = new ReflectionClass($typesMap[$fieldType]); + $returnType = $reflection->getMethod('convertToPHPValue')->getReturnType(); + + /* + * we do not support union and intersection types + */ + if (! $returnType instanceof ReflectionNamedType) { + return null; + } + + return $returnType->isBuiltin() ? $returnType->getName() : '\\' . $returnType->getName(); + } + + /** + * Given the string "field type", this returns the "Types::STRING" constant. + * + * This is, effectively, a reverse lookup: given the final string, give us + * the constant to be used in the generated code. + */ + public static function getTypeConstant(string $fieldType): string|null + { + $reflection = new ReflectionClass(Type::class); + $constants = array_flip($reflection->getConstants()); + + if (! isset($constants[$fieldType])) { + return null; + } + + return sprintf('Type::%s', $constants[$fieldType]); + } } diff --git a/templates/mongodb/Document.tpl.php b/templates/mongodb/Document.tpl.php index 1e9ca7e..03bb11a 100644 --- a/templates/mongodb/Document.tpl.php +++ b/templates/mongodb/Document.tpl.php @@ -14,27 +14,12 @@ class { #[ODM\Id(strategy: 'UUID')] - private ?string $id = null; - - public function getId(): ?string - { - return $this->id; - } + public ?string $id = null; #[ODM\Id(strategy: 'UUID')] - private ?string $id = null; - - public function getId(): ?string - { - return $this->id; - } + public ?string $id = null; #[ODM\Id] - private ?string $id = null; - - public function getId(): ?string - { - return $this->id; - } + public ?string $id = null; } diff --git a/tests/Maker/MakeDocumentTest.php b/tests/Maker/MakeDocumentTest.php index 773b6f4..e2e275e 100644 --- a/tests/Maker/MakeDocumentTest.php +++ b/tests/Maker/MakeDocumentTest.php @@ -65,6 +65,36 @@ public static function getTestDetails(): Generator self::runDocumentTest($runner); }), ]; + + yield 'it_creates_a_new_class_with_fields' => [ + self::createMakeDocumentTest() + ->run(static function (MakerTestRunner $runner): void { + $runner->runMaker([ + // document class name + 'User', + // additional fields + 'some_id', + 'uuid', + 'y', + 'firstName', + 'string', + 'n', + 'isActive', + 'bool', + 'n', + 'friends', + 'collection', + 'n', + '', + ]); + + self::runDocumentTest($runner, [ + 'firstName' => 'Pauline', + 'isActive' => true, + 'friends' => ['Alice', 'Bob'], + ]); + }), + ]; } /** @param array $data */ diff --git a/tests/MongoDB/MongoDBHelperTest.php b/tests/MongoDB/MongoDBHelperTest.php index 03f1a6e..f828177 100644 --- a/tests/MongoDB/MongoDBHelperTest.php +++ b/tests/MongoDB/MongoDBHelperTest.php @@ -13,16 +13,24 @@ namespace Doctrine\Bundle\MongoDBMakerBundle\Tests\MongoDB; +use DateTime; +use DateTimeImmutable; use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\MongoDBHelper; use Doctrine\ODM\MongoDB\Configuration; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\Driver\AttributeDriver; +use Doctrine\ODM\MongoDB\Types\Type; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; use Generator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; +use ReflectionException; +use Symfony\Component\Uid\Uuid; + +use function class_exists; +use function get_debug_type; class MongoDBHelperTest extends TestCase { @@ -141,4 +149,76 @@ public static function getDocumentNamespaceWithDocumentsDataProvider(): Generato 'App\\Document', ]; } + + #[DataProvider('canFieldTypeBeInferredByPropertyTypeDataProvider')] + public function testCanFieldTypeBeInferredByPropertyType(string $fieldType, string $propertyType, bool $expectedResult): void + { + $this->assertEquals($expectedResult, MongoDBHelper::canFieldTypeBeInferredByPropertyType($fieldType, $propertyType)); + } + + public static function canFieldTypeBeInferredByPropertyTypeDataProvider(): Generator + { + yield 'string field type with string property type' => [Type::STRING, 'string', true]; + yield 'int field type with int property type' => [Type::INT, 'int', true]; + yield 'float field type with float property type' => [Type::FLOAT, 'float', true]; + yield 'bool field type with bool property type' => [Type::BOOL, 'bool', true]; + yield 'date field type with DateTime property type' => [Type::DATE, '\\' . DateTime::class, true]; + yield 'date_immutable field type with DateTimeImmutable property type' => [Type::DATE_IMMUTABLE, '\\' . DateTimeImmutable::class, true]; + yield 'has field type with array property type' => [Type::HASH, 'array', true]; + yield 'uuid field type with Uuid property type' => [Type::UUID, Uuid::class, true]; + + yield 'object ID with string property type' => [Type::OBJECTID, 'string', false]; + yield 'collection field type with array property type' => [Type::COLLECTION, 'array', false]; + } + + /** @throws ReflectionException */ + #[DataProvider('getPropertyTypeForFieldDataProvider')] + public function testGetPropertyTypeForField(string $fieldType, string|null $expectedPropertyType): void + { + $this->assertSame($expectedPropertyType, MongoDBHelper::getPropertyTypeForField($fieldType)); + } + + public static function getPropertyTypeForFieldDataProvider(): Generator + { + Type::registerType('custom_type', CustomType::class); + + yield [Type::STRING, 'string']; + yield [Type::BINDATA, 'string']; + yield [Type::BINDATABYTEARRAY, 'string']; + yield [Type::BINDATACUSTOM, 'string']; + yield [Type::BINDATAFUNC, 'string']; + yield [Type::BINDATAUUID, 'string']; + yield [Type::BINDATAUUIDRFC4122, 'string']; + yield [Type::DECIMAL128, 'string']; + yield [Type::ID, 'string']; + yield [Type::TIMESTAMP, 'string']; + yield [Type::INT, 'int']; + yield [Type::FLOAT, 'float']; + yield [Type::HASH, 'array']; + yield [Type::COLLECTION, 'array']; + yield [Type::OBJECTID, 'array']; + yield [Type::VECTOR_FLOAT32, 'array']; + yield [Type::VECTOR_INT8, 'array']; + yield [Type::VECTOR_PACKED_BIT, 'array']; + yield [Type::DATE, '\\' . DateTime::class]; + yield [Type::DATE_IMMUTABLE, '\\' . DateTimeImmutable::class]; + yield [Type::UUID, '\\' . Uuid::class]; + yield ['unknown_type', null]; + yield [Type::RAW, null]; + yield ['custom_type', 'string']; + } + + public function testGetTypeConstant(): void + { + $this->assertSame('Type::STRING', MongoDBHelper::getTypeConstant(Type::STRING)); + $this->assertNull(MongoDBHelper::getTypeConstant('unknown_type')); + } +} + +class CustomType extends Type +{ + public function convertToPHPValue(mixed $value): string + { + return 'foo'; + } } diff --git a/tests/fixtures/make-document/GeneratedDocumentTest.php.twig b/tests/fixtures/make-document/GeneratedDocumentTest.php.twig index f457764..acfd55f 100644 --- a/tests/fixtures/make-document/GeneratedDocumentTest.php.twig +++ b/tests/fixtures/make-document/GeneratedDocumentTest.php.twig @@ -23,7 +23,7 @@ class GeneratedDocumentTest extends KernelTestCase $user = new User(); {% for field, value in data %} - $user->{{ field }} = '{{ value }}'; + $user->{{ field }} = {{ value|json_encode|raw }}; {% endfor %} $dm->persist($user); $dm->flush();