PHPORM-439 Initialize the make:document command (#3)

* Setup test

* Draft implementation of make:document command

* doctrine/mongodb-odm-bundle has now an official recipe

* Add tests to MongoDBHelper

* Update attribute namespace for ODM v2.16

* Fix tests

* Require version 1.x from symfony/maker-bundle, as it exist in my fork and in the symfony repo

* Add symfony/phpunit-bridge because it's symlinked in test environment

'https://github.com/symfony/maker-bundle/pull/480#pullrequestreview-304282682'

* Add mongodb-atlas-local container to tests

* Add PHP 8.5 polyfill
This commit is contained in:
Jérôme Tamarelle
2026-01-30 12:01:03 +01:00
committed by GitHub
parent f80b9c201b
commit e59afbe9e5
20 changed files with 1130 additions and 19 deletions

View File

@@ -8,12 +8,82 @@ on:
branches:
- "*.x"
env:
fail-fast: true
jobs:
phpunit:
name: "PHPUnit"
uses: "doctrine/.github/.github/workflows/continuous-integration.yml@13.1.0"
with:
php-versions: '["8.4", "8.5"]'
phpunit-options-lowest: "--do-not-fail-on-deprecation"
secrets:
CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}"
runs-on: "ubuntu-22.04"
services:
mongodb:
image: "mongodb/mongodb-atlas-local:latest"
ports:
- "27017:27017"
strategy:
matrix:
php-version:
- "8.4"
- "8.5"
dependencies:
- "highest"
include:
- php-version: "8.4"
dependencies: "lowest"
steps:
- name: "Checkout"
uses: "actions/checkout@v6"
with:
fetch-depth: 2
- name: "Install PHP with PCOV"
uses: "shivammathur/setup-php@v2"
with:
php-version: "${{ matrix.php-version }}"
extensions: "mongodb"
coverage: "pcov"
ini-file: "development"
- name: "Install dependencies with Composer"
uses: "ramsey/composer-install@v3"
with:
dependency-versions: "${{ matrix.dependencies }}"
composer-options: "--prefer-dist"
- name: "Run PHPUnit"
run: >
vendor/bin/phpunit --coverage-clover=coverage.xml
${{ matrix.dependencies == 'lowest' && '--do-not-fail-on-deprecation' || '' }}
- name: "Upload coverage file"
uses: "actions/upload-artifact@v5"
with:
name: "phpunit-${{ matrix.php-version }}-${{ matrix.dependencies }}.coverage"
path: "coverage.xml"
upload_coverage:
name: "Upload coverage to Codecov"
runs-on: "ubuntu-22.04"
needs:
- "phpunit"
steps:
- name: "Checkout"
uses: "actions/checkout@v6"
with:
fetch-depth: 2
- name: "Download coverage files"
uses: "actions/download-artifact@v6"
with:
path: "reports"
- name: "Upload to Codecov"
uses: "codecov/codecov-action@v5"
with:
directory: "reports"
env:
CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}"

1
.gitignore vendored
View File

@@ -3,5 +3,6 @@
/composer.lock
/phpcs.xml
/phpunit.xml
/tests/tmp
/var
/vendor

View File

@@ -3,19 +3,26 @@
"description": "Symfony MakerBundle for MongoDB ODM",
"keywords": ["mongodb", "maker", "odm", "dev"],
"type": "symfony-bundle",
"repositories": [
{ "type": "github", "url": "https://github.com/GromNaN/symfony-maker-bundle" }
],
"require": {
"php": "^8.4",
"ext-mongodb": "^2.1",
"doctrine/mongodb-odm-bundle": "^5.5",
"symfony/maker-bundle": "^1.65",
"symfony/http-kernel": "^7.4|^8"
"doctrine/mongodb-odm": "^2.16",
"doctrine/mongodb-odm-bundle": "^5.6",
"symfony/http-kernel": "^7.4|^8",
"symfony/maker-bundle": "^1@dev",
"symfony/polyfill-php85": "^1.33"
},
"require-dev": {
"doctrine/coding-standard": "^14",
"phpstan/phpstan": "^2.1.30",
"phpstan/phpstan-phpunit": "^2.0.7",
"phpunit/phpunit": "^12.5",
"symfony/framework-bundle": "^7.4|^8"
"symfony/phpunit-bridge": "^8",
"symfony/framework-bundle": "^7.4|^8",
"twig/twig": "^3.22"
},
"license": "MIT",
"autoload": {

View File

@@ -0,0 +1,3 @@
The <info>%command.name%</info> command creates or updates a document and repository class.
<info>php %command.full_name% BlogPost</info>

View File

@@ -14,11 +14,29 @@ declare(strict_types=1);
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use Doctrine\Bundle\MongoDBMakerBundle\Maker\MakeDocument;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\DocumentClassGenerator;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\MongoDBHelper;
return static function (ContainerConfigurator $container): void {
$services = $container->services();
$services->set('doctrine_mongodb_maker.mongodb_helper', MongoDBHelper::class)
->args([
service('doctrine_mongodb'),
]);
$services->set('doctrine_mongodb_maker.document_class_generator', DocumentClassGenerator::class)
->args([
service('maker.generator'),
service('doctrine_mongodb_maker.mongodb_helper'),
]);
$services->set('doctrine_mongodb_maker.maker.make_document', MakeDocument::class)
->args([])
->args([
service('maker.file_manager'),
service('doctrine_mongodb_maker.mongodb_helper'),
service('maker.generator'),
service('doctrine_mongodb_maker.document_class_generator'),
])
->tag('maker.command');
};

View File

@@ -13,7 +13,9 @@
<file>config</file>
<file>src</file>
<file>tests</file>
<file>tests/TestKernel.php</file>
<file>tests/BundleTest.php</file>
<file>tests/Maker</file>
<rule ref="Doctrine" />
<rule ref="SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint" />

View File

@@ -4,6 +4,8 @@ parameters:
paths:
- src
- tests
excludePaths:
- tests/tmp
includes:
- vendor/phpstan/phpstan-phpunit/extension.neon

View File

@@ -6,9 +6,15 @@
failOnAllIssues="true"
displayDetailsOnAllIssues="true"
>
<php>
<ini name="error_reporting" value="-1" />
<env name="MONGODB_URI" value="mongodb://localhost:27017" />
</php>
<testsuites>
<testsuite name="Doctrine MongoDB Maker Bundle Test Suite">
<directory>./tests/</directory>
<exclude>./tests/tmp</exclude>
</testsuite>
</testsuites>

View File

@@ -4,39 +4,390 @@ declare(strict_types=1);
namespace Doctrine\Bundle\MongoDBMakerBundle\Maker;
use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\DocumentClassGenerator;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\MongoDBHelper;
use Doctrine\ODM\MongoDB\Types\Type;
use InvalidArgumentException;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\FileManager;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputAwareMakerInterface;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
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\Bundle\MakerBundle\Validator;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\Question;
use function array_key_exists;
use function array_keys;
use function array_map;
use function class_exists;
use function dirname;
use function file_get_contents;
use function implode;
use function in_array;
use function preg_match;
use function sprintf;
use function str_starts_with;
use function substr;
final class MakeDocument extends AbstractMaker implements InputAwareMakerInterface
{
use UidTrait;
public function __construct(
private FileManager $fileManager,
private MongoDBHelper $mongoDBHelper,
private Generator|null $generator = null,
private DocumentClassGenerator|null $documentClassGenerator = null,
) {
$this->generator ??= new Generator($fileManager, 'App\\');
$this->documentClassGenerator ??= new DocumentClassGenerator($this->generator, $this->mongoDBHelper);
}
public static function getCommandName(): string
{
return 'doctrine:mongodb:make:document';
return 'make:document';
}
public static function getCommandDescription(): string
{
return 'Creates a new MongoDB ODM document class';
return 'Create or update a MongoDB ODM document class';
}
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
{
// TODO: Implement configureCommand() method.
$command
->addArgument('name', InputArgument::OPTIONAL, sprintf('Class name of the document to create or update (e.g. <fg=yellow>%s</>)', Str::asClassName(Str::getRandomTerm())))
->addOption('regenerate', null, InputOption::VALUE_NONE, 'Instead of adding new fields, simply generate the methods (e.g. getter/setter) for existing fields')
->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite any existing getter/setter methods')
->setHelp((string) file_get_contents(dirname(__DIR__, 2) . '/config/help/MakeDocument.txt'));
$this->addWithUuidOption($command);
$inputConfig->setArgumentAsNonInteractive('name');
}
public function configureDependencies(DependencyBuilder $dependencies, InputInterface|null $input = null): void
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
{
// TODO: Implement configureDependencies() method.
$documentClassName = $input->getArgument('name');
if ($documentClassName && empty($this->verifyDocumentName($documentClassName))) {
return;
}
if ($input->getOption('regenerate')) {
$io->block([
'This command will generate any missing methods (e.g. getters & setters) for a class or all classes in a namespace.',
'To overwrite any existing methods, re-run this command with the --overwrite flag',
], null, 'fg=yellow');
$classOrNamespace = $io->ask('Enter a class or namespace to regenerate', $this->getDocumentNamespace(), Validator::notBlank(...));
$input->setArgument('name', $classOrNamespace);
return;
}
$this->checkIsUsingUid($input);
$argument = $command->getDefinition()->getArgument('name');
$question = $this->createDocumentClassQuestion($argument->getDescription());
$documentClassName ??= $io->askQuestion($question);
while ($this->verifyDocumentName($documentClassName)) {
if ($io->confirm(sprintf('"%s" contains one or more non-ASCII characters, which can be problematic with MongoDB. It is recommended to use only ASCII characters for document names. Continue anyway?', $documentClassName), false)) {
break;
}
$documentClassName = $io->askQuestion($question);
}
$input->setArgument('name', $documentClassName);
}
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
{
// TODO: Implement generate() method.
$overwrite = $input->getOption('overwrite');
// the regenerate option has entirely custom behavior
if ($input->getOption('regenerate')) {
$io->comment('Document regeneration is not yet implemented.');
$this->writeSuccessMessage($io);
return;
}
$documentClassDetails = $generator->createClassNameDetails(
$input->getArgument('name'),
'Document\\',
);
$classExists = class_exists($documentClassDetails->getFullName());
if (! $classExists) {
$documentPath = $this->documentClassGenerator->generateDocumentClass(
documentClassDetails: $documentClassDetails,
idType: $this->getIdType(),
);
$generator->writeChanges();
$io->text([
'',
'Document generated! Now let\'s add some fields!',
'You can always add more fields later manually or by re-running this command.',
]);
} else {
$documentPath = $this->getPathOfClass($documentClassDetails->getFullName());
$io->text('Your document already exists! So let\'s add some new fields!');
}
$currentFields = $this->getPropertyNames($documentClassDetails->getFullName());
$manipulator = $this->createClassManipulator($documentPath, $io, $overwrite);
$isFirstField = true;
while (true) {
$newField = $this->askForNextField($io, $currentFields, $documentClassDetails->getFullName(), $isFirstField);
$isFirstField = false;
if ($newField === null) {
break;
}
$manipulator->addEntityField($newField);
$currentFields[] = $newField->propertyName;
$this->fileManager->dumpFile($documentPath, $manipulator->getSourceCode());
}
$this->writeSuccessMessage($io);
$io->text([
'Next: Add more fields with the same command, or start using your document!',
'',
]);
}
public function configureDependencies(DependencyBuilder $dependencies, InputInterface|null $input = null): void
{
$dependencies->addClassDependency(
DoctrineMongoDBBundle::class,
'doctrine/mongodb-odm-bundle',
);
}
/** @param string[] $fields */
private function askForNextField(ConsoleStyle $io, array $fields, string $documentClass, bool $isFirstField): ClassProperty|null
{
$io->writeln('');
if ($isFirstField) {
$questionText = 'New property name (press <return> to stop adding fields)';
} else {
$questionText = 'Add another property? Enter the property name (or press <return> to stop adding fields)';
}
$fieldName = $io->ask($questionText, null, function ($name) use ($fields) {
// allow it to be empty
if (! $name) {
return $name;
}
if (in_array($name, $fields, true)) {
throw new InvalidArgumentException(sprintf('The "%s" property already exists.', $name));
}
return Validator::validateDoctrineFieldName($name, $this->mongoDBHelper->getRegistry());
});
if (! $fieldName) {
return null;
}
$defaultType = 'string';
// try to guess the type by the field name prefix/suffix
$snakeCasedField = Str::asSnakeCase($fieldName);
$suffix = substr($snakeCasedField, -3);
if ($suffix === '_at') {
$defaultType = 'date_immutable';
} elseif ($suffix === '_id') {
$defaultType = 'int';
} elseif (str_starts_with($snakeCasedField, 'is_')) {
$defaultType = 'bool';
} elseif (str_starts_with($snakeCasedField, 'has_')) {
$defaultType = 'bool';
}
$type = null;
$types = $this->getTypesMap();
$allValidTypes = array_keys($types);
while ($type === null) {
$question = new Question('Field type (enter <comment>?</comment> to see all types)', $defaultType);
$question->setAutocompleterValues($allValidTypes);
$type = $io->askQuestion($question);
if ($type === '?') {
$this->printAvailableTypes($io);
$io->writeln('');
$type = null;
} elseif (! in_array($type, $allValidTypes, true)) {
$this->printAvailableTypes($io);
$io->error(sprintf('Invalid type "%s".', $type));
$io->writeln('');
$type = null;
}
}
// this is a normal field
$classProperty = new ClassProperty(propertyName: $fieldName, type: $type);
if ($type === 'string') {
// MongoDB doesn't have length constraints at the database level
// but we can still track it for validation purposes
$classProperty->length = null;
}
if ($io->confirm('Can this field be null in the database (nullable)', false)) {
$classProperty->nullable = true;
}
return $classProperty;
}
private function printAvailableTypes(ConsoleStyle $io): void
{
$allTypes = $this->getTypesMap();
$typesTable = [
'main' => [
'string' => [],
'int' => [],
'float' => [],
'bool' => [],
],
'array_object' => [
'hash' => [],
'collection' => [],
'object_id' => [],
],
'date_time' => [
'date' => ['date_immutable'],
'timestamp' => [],
],
];
$printSection = static function (array $sectionTypes) use ($io, &$allTypes): void {
foreach ($sectionTypes as $mainType => $subTypes) {
if (! array_key_exists($mainType, $allTypes)) {
continue;
}
foreach ($subTypes as $key => $potentialType) {
if (! array_key_exists($potentialType, $allTypes)) {
unset($subTypes[$key]);
}
unset($allTypes[$potentialType]);
}
unset($allTypes[$mainType]);
$line = sprintf(' * <comment>%s</comment>', $mainType);
if ($subTypes !== []) {
$line .= sprintf(' or %s', implode(' or ', array_map(
static fn ($subType) => sprintf('<comment>%s</comment>', $subType),
$subTypes,
)));
}
$io->writeln($line);
}
$io->writeln('');
};
$io->writeln('<info>Main Types</info>');
$printSection($typesTable['main']);
$io->writeln('<info>Array/Object Types</info>');
$printSection($typesTable['array_object']);
$io->writeln('<info>Date/Time Types</info>');
$printSection($typesTable['date_time']);
$io->writeln('<info>Other Types</info>');
$allTypes = array_map(static fn () => [], $allTypes);
$printSection($allTypes);
}
private function createDocumentClassQuestion(string $questionText): Question
{
$question = new Question($questionText);
$question->setValidator(Validator::notBlank(...));
$question->setAutocompleterValues($this->mongoDBHelper->getDocumentsForAutocomplete());
return $question;
}
/** @return string[] */
private function verifyDocumentName(string $documentName): array
{
preg_match('/([^\x00-\x7F]+)/u', $documentName, $matches);
return $matches;
}
private function createClassManipulator(string $path, ConsoleStyle $io, bool $overwrite): ClassSourceManipulator
{
$manipulator = new ClassSourceManipulator(
sourceCode: $this->fileManager->getFileContents($path),
overwrite: $overwrite,
);
$manipulator->setIo($io);
return $manipulator;
}
private function getPathOfClass(string $class): string
{
return (new ClassDetails($class))->getPath();
}
/** @return string[] */
private function getPropertyNames(string $class): array
{
if (! class_exists($class)) {
return [];
}
$reflClass = new ReflectionClass($class);
return array_map(static fn (ReflectionProperty $prop) => $prop->getName(), $reflClass->getProperties());
}
private function getDocumentNamespace(): string
{
return $this->mongoDBHelper->getDocumentNamespace();
}
/** @return array<string, string> */
private function getTypesMap(): array
{
return Type::getTypesMap();
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Doctrine\Bundle\MongoDBMakerBundle\MongoDB;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\Maker\Common\EntityIdTypeEnum;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
use function dirname;
/** @internal */
final class DocumentClassGenerator
{
public function __construct(
private Generator $generator,
private MongoDBHelper $mongoDBHelper,
) {
}
public function generateDocumentClass(
ClassNameDetails $documentClassDetails,
bool $generateRepositoryClass = true,
EntityIdTypeEnum $idType = EntityIdTypeEnum::INT,
): string {
$repoClassDetails = $this->generator->createClassNameDetails(
$documentClassDetails->getRelativeName(),
'Repository\\',
'Repository',
);
$collectionName = $this->mongoDBHelper->getPotentialCollectionName($documentClassDetails->getFullName());
$useStatements = new UseStatementGenerator([
$repoClassDetails->getFullName(),
['Doctrine\\ODM\\MongoDB\\Mapping\\Attribute' => 'ODM'],
]);
$templatePath = dirname(__DIR__, 2) . '/templates/mongodb/Document.tpl.php';
$documentPath = $this->generator->generateClass(
$documentClassDetails->getFullName(),
$templatePath,
[
'use_statements' => $useStatements,
'repository_class_name' => $repoClassDetails->getShortName(),
'collection_name' => $collectionName,
'id_type' => $idType,
],
);
if ($generateRepositoryClass) {
$this->generateRepositoryClass(
$repoClassDetails->getFullName(),
$documentClassDetails->getFullName(),
true,
);
}
return $documentPath;
}
public function generateRepositoryClass(
string $repositoryClass,
string $documentClass,
bool $includeExampleComments = true,
): void {
$shortDocumentClass = Str::getShortClassName($documentClass);
$useStatements = new UseStatementGenerator([
$documentClass,
DocumentManager::class,
DocumentRepository::class,
]);
$templatePath = dirname(__DIR__, 2) . '/templates/mongodb/Repository.tpl.php';
$this->generator->generateClass(
$repositoryClass,
$templatePath,
[
'use_statements' => $useStatements,
'document_class_name' => $shortDocumentClass,
'include_example_comments' => $includeExampleComments,
],
);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Doctrine\Bundle\MongoDBMakerBundle\MongoDB;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bundle\MakerBundle\Str;
use function array_first;
use function array_keys;
use function array_pop;
use function count;
use function explode;
use function implode;
use function str_contains;
/** @internal */
final class MongoDBHelper
{
private const string DEFAULT_DOCUMENT_NAMESPACE = 'App\\Document';
public function __construct(
private ManagerRegistry $registry,
) {
}
public function getRegistry(): ManagerRegistry
{
return $this->registry;
}
public function getPotentialCollectionName(string $className): string
{
$shortClassName = Str::getShortClassName($className);
return Str::asSnakeCase($shortClassName);
}
public function getDocumentNamespace(): string
{
$documentManager = $this->registry->getManager();
if (! $documentManager instanceof DocumentManager) {
return self::DEFAULT_DOCUMENT_NAMESPACE;
}
$metadataDriver = $documentManager->getConfiguration()->getMetadataDriverImpl();
$documentNamespaces = [];
if ($metadataDriver === null) {
return self::DEFAULT_DOCUMENT_NAMESPACE;
}
foreach ($metadataDriver->getAllClassNames() as $className) {
$parts = explode('\\', $className);
if (count($parts) <= 1) {
continue;
}
array_pop($parts);
$documentNamespaces[implode('\\', $parts)] = true;
}
$namespaces = array_keys($documentNamespaces);
// Prefer 'Document' namespace if it exists
foreach ($namespaces as $namespace) {
if (str_contains($namespace, '\\Document')) {
return $namespace;
}
}
return array_first($namespaces) ?? self::DEFAULT_DOCUMENT_NAMESPACE;
}
/** @return string[] */
public function getDocumentsForAutocomplete(): array
{
$documentManager = $this->registry->getManager();
if (! $documentManager instanceof DocumentManager) {
return [];
}
$metadataDriver = $documentManager->getConfiguration()->getMetadataDriverImpl();
$allDocuments = [];
if ($metadataDriver === null) {
return [];
}
foreach ($metadataDriver->getAllClassNames() as $className) {
$allDocuments[] = $className;
$allDocuments[] = Str::getShortClassName($className);
}
return $allDocuments;
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Symfony\Bundle\MakerBundle\Maker\Common\EntityIdTypeEnum;
?>
<?= "<?php\n" ?>
namespace <?= $namespace ?>;
<?= $use_statements; ?>
#[ODM\Document(repositoryClass: <?= $repository_class_name ?>::class)]
#[ODM\Collection(name: '<?= $collection_name ?>')]
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;
}
<?php elseif (EntityIdTypeEnum::ULID === $id_type): ?>
#[ODM\Id(strategy: 'UUID')]
private ?string $id = null;
public function getId(): ?string
{
return $this->id;
}
<?php else: ?>
#[ODM\Id]
private ?string $id = null;
public function getId(): ?string
{
return $this->id;
}
<?php endif ?>
}

View File

@@ -0,0 +1,40 @@
<?= "<?php\n" ?>
namespace <?= $namespace ?>;
<?= $use_statements; ?>
/**
* @extends DocumentRepository<<?= $document_class_name ?>>
*/
class <?= $class_name ?> extends DocumentRepository
{
public function __construct(DocumentManager $dm)
{
parent::__construct($dm, $dm->getUnitOfWork(), $dm->getClassMetadata(<?= $document_class_name ?>::class));
}
<?php if ($include_example_comments): ?>
/**
* @return <?= $document_class_name ?>[]
*/
public function findByExampleField(mixed $value): array
{
return $this->createQueryBuilder()
->field('exampleField')->equals($value)
->getQuery()
->execute()
->toArray();
}
public function findOneBySomeField(mixed $value): ?<?= $document_class_name ?>
{
return $this->createQueryBuilder()
->field('exampleField')->equals($value)
->getQuery()
->getSingleResult();
}
<?php endif ?>
}

View File

@@ -52,7 +52,7 @@ class BundleTest extends TestCase
$commandLoader = $kernel->getContainer()->get('console.command_loader');
self::assertInstanceOf(CommandLoaderInterface::class, $commandLoader);
self::assertTrue($commandLoader->has('doctrine:mongodb:make:document'));
self::assertTrue($commandLoader->has('make:document'));
$kernel->shutdown();
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Symfony MakerBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Doctrine\Bundle\MongoDBMakerBundle\Tests\Maker;
use Doctrine\Bundle\MongoDBMakerBundle\Maker\MakeDocument;
use Generator;
use Symfony\Bundle\MakerBundle\Test\MakerTestCase;
use Symfony\Bundle\MakerBundle\Test\MakerTestDetails;
use Symfony\Bundle\MakerBundle\Test\MakerTestRunner;
use Symfony\Component\HttpKernel\KernelInterface;
use function getenv;
class MakeDocumentTest extends MakerTestCase
{
protected function getMakerClass(): string
{
return MakeDocument::class;
}
private static function createMakeDocumentTest(bool $withDatabase = true): MakerTestDetails
{
return self::buildMakerTest()
->preRun(static function (MakerTestRunner $runner) use ($withDatabase): void {
if (! $withDatabase) {
return;
}
$runner->replaceInFile(
'.env',
'mongodb://localhost:27017',
(string) getenv('MONGODB_URI'),
);
});
}
protected function createKernel(): KernelInterface
{
return new MakerTestKernel('dev', true);
}
public static function getTestDetails(): Generator
{
yield 'it_creates_a_new_class_basic' => [
self::createMakeDocumentTest()
->run(static function (MakerTestRunner $runner): void {
$runner->runMaker([
// document class name
'User',
// no additional fields
'',
]);
self::runDocumentTest($runner);
}),
];
}
/** @param array<string, mixed> $data */
private static function runDocumentTest(MakerTestRunner $runner, array $data = []): void
{
$runner->renderTemplateFile(
'make-document/GeneratedDocumentTest.php.twig',
'tests/GeneratedDocumentTest.php',
['data' => $data],
);
$runner->runTests();
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Doctrine\Bundle\MongoDBMakerBundle\Tests\Maker;
use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDBMakerBundle;
use Symfony\Bundle\MakerBundle\Test\MakerTestKernel as MakerBundleTestKernel;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class MakerTestKernel extends MakerBundleTestKernel
{
public function registerBundles(): iterable
{
yield from parent::registerBundles();
yield new DoctrineMongoDBBundle();
yield new MongoDBMakerBundle();
}
public function registerContainerConfiguration(LoaderInterface $loader): void
{
parent::registerContainerConfiguration($loader);
$loader->load(static function (ContainerBuilder $container): void {
$container->loadFromExtension('doctrine_mongodb', [
'default_connection' => 'default',
'connections' => [
'default' => ['server' => 'mongodb://localhost:27017'],
],
'document_managers' => [
'default' => ['auto_mapping' => true],
],
]);
});
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Doctrine MongoDB Maker Bundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Doctrine\Bundle\MongoDBMakerBundle\Tests\MongoDB;
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\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Generator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
class MongoDBHelperTest extends TestCase
{
private MongoDBHelper $helper;
private ManagerRegistry&Stub $registry;
protected function setUp(): void
{
$this->registry = $this->createStub(ManagerRegistry::class);
$this->helper = new MongoDBHelper($this->registry);
}
#[DataProvider('getPotentialCollectionNameDataProvider')]
public function testGetPotentialCollectionName(string $className, string $expectedCollectionName): void
{
$result = $this->helper->getPotentialCollectionName($className);
$this->assertSame($expectedCollectionName, $result);
}
/** @return Generator<string, array{0: string, 1: string}> */
public static function getPotentialCollectionNameDataProvider(): Generator
{
yield 'simple class name' => [
'App\\Document\\User',
'user',
];
yield 'camel case to snake case' => [
'App\\Document\\UserProfile',
'user_profile',
];
yield 'already snake case' => [
'App\\Document\\user_account',
'user_account',
];
}
public function testGetDocumentNamespaceWhenDocumentManagerIsNotAvailable(): void
{
$this->registry->method('getManager')->willReturn($this->createStub(ObjectManager::class));
$result = $this->helper->getDocumentNamespace();
$this->assertSame('App\\Document', $result);
}
/** @param class-string[] $classNames */
#[DataProvider('getDocumentNamespaceWithDocumentsDataProvider')]
public function testGetDocumentNamespaceWithDocuments(array $classNames, string $expectedNamespace): void
{
$documentManagerMock = $this->createStub(DocumentManager::class);
$configMock = $this->createStub(Configuration::class);
$driverMock = $this->createStub(AttributeDriver::class);
$driverMock->method('getAllClassNames')->willReturn($classNames);
$configMock->method('getMetadataDriverImpl')->willReturn($driverMock);
$documentManagerMock->method('getConfiguration')->willReturn($configMock);
$this->registry->method('getManager')->willReturn($documentManagerMock);
$result = $this->helper->getDocumentNamespace();
$this->assertSame($expectedNamespace, $result);
}
/** @return Generator<string, array<int, mixed>> */
public static function getDocumentNamespaceWithDocumentsDataProvider(): Generator
{
yield 'single class with Document namespace' => [
['App\\Document\\User'],
'App\\Document',
];
yield 'multiple classes in Document namespace' => [
['App\\Document\\User', 'App\\Document\\Post'],
'App\\Document',
];
yield 'classes in Document and other namespace' => [
['App\\Models\\Post', 'App\\Document\\User'],
'App\\Document',
];
yield 'single class without Document namespace' => [
['App\\Entity\\User'],
'App\\Entity',
];
yield 'multiple classes without Document namespace' => [
['App\\Entity\\User', 'App\\Entity\\Post'],
'App\\Entity',
];
yield 'multiple different namespaces without Document' => [
['App\\Entity\\User', 'App\\Models\\Post'],
'App\\Entity',
];
yield 'multiple different namespaces with Document' => [
['App\\Entity\\User', 'App\\Document\\Post', 'App\\Models\\Product'],
'App\\Document',
];
yield 'nested Document namespace' => [
['Doctrine\\Bundle\\MongoDBMakerBundle\\Document\\BlogPost'],
'Doctrine\\Bundle\\MongoDBMakerBundle\\Document',
];
yield 'single class without namespace' => [
['SingleClass'],
'App\\Document',
];
yield 'empty class list' => [
[],
'App\\Document',
];
}
}

62
tests/TestKernel.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Doctrine\Bundle\MongoDBMakerBundle\Tests;
use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDBMakerBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\MakerBundle\DependencyInjection\CompilerPass\MakeCommandRegistrationPass;
use Symfony\Bundle\MakerBundle\MakerBundle;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel;
class TestKernel extends Kernel implements CompilerPassInterface
{
use MicroKernelTrait;
public function registerBundles(): iterable
{
return [
new FrameworkBundle(),
new DoctrineMongoDBBundle(),
new MakerBundle(),
new MongoDBMakerBundle(),
];
}
public function registerContainerConfiguration(LoaderInterface $loader): void
{
$loader->load(static function (ContainerBuilder $container): void {
$container->loadFromExtension('framework', ['secret' => 'S0ME_SECRET']);
$container->loadFromExtension('doctrine_mongodb', [
'default_connection' => 'default',
'connections' => [
'default' => ['server' => 'mongodb://localhost:27017'],
],
'document_managers' => [
'default' => ['auto_mapping' => true],
],
]);
$container->loadFromExtension('maker', []);
$container->loadFromExtension('mongodb_maker', ['generate_final_documents' => true]);
});
}
public function process(ContainerBuilder $container): void
{
/**
* Makes all makers public to help the tests
*
* @see \Symfony\Bundle\MakerBundle\Test\MakerTestKernel::process()
*/
foreach ($container->findTaggedServiceIds(MakeCommandRegistrationPass::MAKER_TAG) as $id => $tags) {
$defn = $container->getDefinition($id);
$defn->setPublic(true);
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Tests;
use App\Document\User;
use Doctrine\ODM\MongoDB\DocumentManager;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class GeneratedDocumentTest extends KernelTestCase
{
public function testGeneratedDocument()
{
self::bootKernel();
/** @var DocumentManager $dm */
$dm = self::$kernel->getContainer()
->get('doctrine_mongodb')
->getManager();
$dm->createQueryBuilder(User::class)
->remove()
->getQuery()
->execute();
$user = new User();
{% for field, value in data %}
$user->{{ field }} = '{{ value }}';
{% endfor %}
$dm->persist($user);
$dm->flush();
$actualUser = $dm->getRepository(User::class)->findAll();
$this->assertcount(1, $actualUser);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Document;
use Doctrine\ODM\MongoDB\Mapping\Attribute as ODM;
#[ODM\Document]
class User
{
#[ODM\Id]
public int|null $id = null;
#[ODM\Field]
public string|null $firstName = null;
}