Support adding fields in make command (#10)

This commit is contained in:
Pauline Vos
2026-02-11 11:23:33 +01:00
committed by GitHub
parent 5904a7b1fa
commit 8721f4a4f6
8 changed files with 797 additions and 25 deletions

View File

@@ -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",

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
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\Maker\Common\UidTrait;
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 @@ final class MakeDocument extends AbstractMaker implements InputAwareMakerInterfa
}
$currentFields = $this->getPropertyNames($documentClassDetails->getFullName());
$manipulator = $this->createClassManipulator($documentPath, $io, $overwrite);
$manipulator = $this->createClassManipulator($documentPath, $io);
$isFirstField = true;
while (true) {
@@ -163,7 +163,7 @@ final class MakeDocument extends AbstractMaker implements InputAwareMakerInterfa
break;
}
$manipulator->addEntityField($newField);
$manipulator->addDocumentField($newField);
$currentFields[] = $newField->propertyName;
$this->fileManager->dumpFile($documentPath, $manipulator->getSourceCode());
@@ -351,11 +351,10 @@ final class MakeDocument extends AbstractMaker implements InputAwareMakerInterfa
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);

View File

@@ -0,0 +1,587 @@
<?php
declare(strict_types=1);
namespace Doctrine\Bundle\MongoDBMakerBundle\MongoDB;
use Doctrine\ODM\MongoDB\Mapping\Attribute\Field;
use Doctrine\ODM\MongoDB\Types\Type;
use Exception;
use PhpParser\Builder;
use PhpParser\Lexer;
use PhpParser\Node;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor;
use PhpParser\Parser;
use PhpParser\PhpVersion;
use PhpParser\Token;
use ReflectionClass;
use ReflectionException;
use ReflectionParameter;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassNameValue;
use Symfony\Bundle\MakerBundle\Util\ClassSource\Model\ClassProperty;
use Symfony\Bundle\MakerBundle\Util\PrettyPrinter;
use function array_key_exists;
use function array_keys;
use function array_map;
use function array_merge;
use function array_search;
use function array_splice;
use function array_unshift;
use function array_values;
use function end;
use function get_debug_type;
use function gettype;
use function is_int;
use function preg_replace;
use function sprintf;
use function str_contains;
use function str_replace;
use function str_starts_with;
use function strpos;
use function strrpos;
use function substr;
/** @internal */
final class ClassSourceManipulator
{
private const string CONTEXT_OUTSIDE_CLASS = 'outside_class';
private const string CONTEXT_CLASS = 'class';
private const string CONTEXT_CLASS_METHOD = 'class_method';
private Parser $parser;
private Lexer\Emulative $lexer;
private PrettyPrinter $printer;
private ConsoleStyle|null $io = null;
private array|null $oldStmts = null;
/** @var Token[] $oldTokens */
private array $oldTokens = [];
/** @var Node[] $newStmts */
private array $newStmts = [];
/** @var string[] $pendingComments */
private array $pendingComments = [];
public function __construct(
private string $sourceCode,
private readonly bool $useAttributesForDoctrineMapping = true,
) {
$this->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<Node\Attribute|Node\AttributeGroup> $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<string, mixed> $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<string, mixed> $options
*
* @return array<string, mixed>
*
* @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);
}
}

View File

@@ -4,16 +4,25 @@ declare(strict_types=1);
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 @@ final class MongoDBHelper
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]);
}
}

View File

@@ -14,27 +14,12 @@ class <?= $class_name."\n" ?>
{
<?php if (EntityIdTypeEnum::UUID === $id_type): ?>
#[ODM\Id(strategy: 'UUID')]
private ?string $id = null;
public function getId(): ?string
{
return $this->id;
}
public ?string $id = null;
<?php elseif (EntityIdTypeEnum::ULID === $id_type): ?>
#[ODM\Id(strategy: 'UUID')]
private ?string $id = null;
public function getId(): ?string
{
return $this->id;
}
public ?string $id = null;
<?php else: ?>
#[ODM\Id]
private ?string $id = null;
public function getId(): ?string
{
return $this->id;
}
public ?string $id = null;
<?php endif ?>
}

View File

@@ -65,6 +65,36 @@ class MakeDocumentTest extends MakerTestCase
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<string, mixed> $data */

View File

@@ -13,16 +13,24 @@ declare(strict_types=1);
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 @@ class MongoDBHelperTest extends TestCase
'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';
}
}

View File

@@ -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();