Support adding search indexes to documents (#17)

* Create test trait for skipping functional tests

So that tests can be skipped based on feature support

* Support adding search indexes to Documents
This commit is contained in:
Pauline Vos
2026-03-11 16:35:22 +01:00
committed by GitHub
parent 648be6c8d1
commit e36e10e95c
11 changed files with 482 additions and 26 deletions

View File

@@ -1,3 +1,4 @@
The <info>%command.name%</info> command adds an index to an existing document class.
Supports regular, unique, and search indexes.
<info>php %command.full_name% BlogPost</info>

View File

@@ -6,6 +6,7 @@ parameters:
- tests
excludePaths:
- tests/tmp
- tests/fixtures/utils/MongoDBFunctionalTestCase.php
ignoreErrors:
- identifier: class.notFound

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Doctrine\Bundle\MongoDBMakerBundle\Maker\Enum;
use function array_map;
enum IndexType: string
{
case Regular = 'Regular';
case Unique = 'Unique';
case Search = 'Search';
/** @return string[] */
public static function stringValues(): array
{
return array_map(static fn ($case) => $case->value, self::cases());
}
}

View File

@@ -5,13 +5,16 @@ declare(strict_types=1);
namespace Doctrine\Bundle\MongoDBMakerBundle\Maker;
use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle;
use Doctrine\Bundle\MongoDBMakerBundle\Maker\Enum\IndexType;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\ClassSourceManipulator;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\MongoDBHelper;
use Doctrine\ODM\MongoDB\Mapping\Attribute\Id;
use Doctrine\ODM\MongoDB\Mapping\Attribute\SearchIndex;
use Exception;
use InvalidArgumentException;
use ReflectionClass;
use ReflectionProperty;
use RuntimeException;
use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\FileManager;
@@ -24,7 +27,6 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use function array_filter;
use function array_key_first;
@@ -36,6 +38,7 @@ use function dirname;
use function file_get_contents;
use function in_array;
use function preg_match;
use function reset;
use function sprintf;
use function strtolower;
@@ -130,7 +133,7 @@ final class MakeIndex extends AbstractMaker implements MakerInterface
sourceCode: $this->fileManager->getFileContents($documentPath),
);
$this->addIndexRecursive($io, $manipulator, $fields, $documentPath);
$this->addIndexRecursive($io, $manipulator, $reflectionClass->getProperties(), $documentPath);
}
/** @return string[] */
@@ -141,19 +144,27 @@ final class MakeIndex extends AbstractMaker implements MakerInterface
return $matches;
}
/** @param string[] $fields */
/**
* @param ReflectionProperty[] $fields
*
* @throws Exception
*/
private function addIndexRecursive(ConsoleStyle $io, ClassSourceManipulator $manipulator, array $fields, string $documentPath): void
{
$io->writeln('');
$unique = $io->askQuestion(new ConfirmationQuestion('Should this index be unique?', false));
$keys = $this->askForKeys($io, $fields);
$question = new ChoiceQuestion(
'What type of index do you want to create?',
IndexType::stringValues(),
IndexType::Regular->value,
);
$this->createAttribute($unique, $keys, $manipulator);
$typeStr = $io->askQuestion($question);
$type = IndexType::from($typeStr);
$this->createAttribute($io, $type, $fields, $manipulator);
$this->fileManager->dumpFile($documentPath, $manipulator->getSourceCode());
$io->info('Index added.');
$createAnother = $io->ask('Would you like to add another index to this document? (y/n)', 'n', static function ($answer) {
if (! in_array(strtolower($answer), ['y', 'n'], true)) {
throw new InvalidArgumentException('Please enter "y" or "n".');
@@ -176,13 +187,29 @@ final class MakeIndex extends AbstractMaker implements MakerInterface
}
/**
* @param array<string,string> $keys
* @param ReflectionProperty[] $fields
*
* @throws Exception
*/
private function createAttribute(bool $unique, array $keys, ClassSourceManipulator $manipulator): void
private function createAttribute(ConsoleStyle $io, IndexType $type, array $fields, ClassSourceManipulator $manipulator): void
{
$attributeClass = $unique ? 'ODM\UniqueIndex' : 'ODM\Index';
match ($type) {
IndexType::Regular => $this->createIndexAttribute('ODM\Index', $io, $fields, $manipulator),
IndexType::Unique => $this->createIndexAttribute('ODM\UniqueIndex', $io, $fields, $manipulator),
IndexType::Search => $this->createSearchIndexAttribute($io, $manipulator, $fields),
};
}
/**
* @param ReflectionProperty[] $fields
*
* @throws Exception
*/
private function createIndexAttribute(string $attributeClass, ConsoleStyle $io, array $fields, ClassSourceManipulator $manipulator): void
{
$fields = self::filterIdentifiers($fields);
$keys = $this->askForKeys($io, $fields);
$createClassAttribute = count($keys) > 1;
if ($createClassAttribute) {
@@ -193,20 +220,66 @@ final class MakeIndex extends AbstractMaker implements MakerInterface
$field = array_key_first($keys);
$manipulator->addAttributeToProperty($attributeClass, (string) $field, ['order' => $keys[$field]]);
$io->info('Index added.');
}
/** @param ReflectionProperty[] $fields */
private function createSearchIndexAttribute(
ConsoleStyle $io,
ClassSourceManipulator $manipulator,
array $fields,
): void {
$name = $io->ask('What is the name of the index?', 'default');
$isDynamic = $io->confirm('Is the search index dynamic?');
if ($isDynamic) {
$manipulator->addAttributeToClass(SearchIndex::class, ['dynamic' => true, 'name' => $name]);
} else {
$selectedFields = self::selectFields($fields, $io);
$options = [];
foreach ($selectedFields as $field) {
$property = array_filter($fields, static fn (ReflectionProperty $prop) => $prop->name === $field);
$property = reset($property);
if (! $property) {
throw new RuntimeException(sprintf('Field "%s" not found.', $field));
}
$type = MongoDBHelper::guessSearchIndexTypeForProperty($property);
$options[$field] = $type ? ['type' => $type] : [];
}
$manipulator->addAttributeToClass(SearchIndex::class, ['fields' => $options, 'name' => $name]);
}
$io->info(sprintf(
'Search index added.%s',
$isDynamic ? '' : ' Remember to review the generated field mappings in the `SearchIndex` attribute and adjust them as needed.',
));
}
/**
* @param string[] $fields
* @param ReflectionProperty[] $properties
*
* @return string[]
*/
private static function getPropertyNames(array $properties): array
{
return array_map(static fn (ReflectionProperty $prop) => $prop->name, $properties);
}
/**
* @param ReflectionProperty[] $fields
*
* @return array<string,string> $keys
*/
private function askForKeys(ConsoleStyle $io, array $fields): array
{
$question = new ChoiceQuestion('Select one or more keys for the index (or <return> to finish)', $fields);
$question->setMultiselect(true);
$selection = $io->askQuestion($question);
$selection = self::selectFields($fields, $io);
$keys = [];
foreach ($selection as $key) {
@@ -221,4 +294,32 @@ final class MakeIndex extends AbstractMaker implements MakerInterface
return $keys;
}
/**
* @param ReflectionProperty[] $properties
*
* @return ReflectionProperty[]
*/
private static function filterIdentifiers(array $properties): array
{
/** Filter any identifiers. We don't consider them valid candidates for regular and unique indexes */
return array_values(array_filter(
$properties,
static fn (ReflectionProperty $prop) => empty($prop->getAttributes(Id::class)),
));
}
/**
* @param ReflectionProperty[] $fields
*
* @return string[]
*/
private static function selectFields(array $fields, ConsoleStyle $io): array
{
$options = self::getPropertyNames($fields);
$question = new ChoiceQuestion('Select one or more fields for the index (or <return> to finish)', $options);
$question->setMultiselect(true);
return $io->askQuestion($question);
}
}

View File

@@ -6,19 +6,25 @@ namespace Doctrine\Bundle\MongoDBMakerBundle\MongoDB;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Mapping\Attribute\EmbedMany;
use Doctrine\ODM\MongoDB\Mapping\Attribute\EmbedOne;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
use Doctrine\ODM\MongoDB\Types\Type;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\Mapping\AbstractClassMetadataFactory;
use MongoDB\BSON\ObjectId;
use ReflectionClass;
use ReflectionException;
use ReflectionNamedType;
use ReflectionProperty;
use Symfony\Bundle\MakerBundle\Str;
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
use Symfony\Component\Uid\Uuid;
use Throwable;
use function array_filter;
use function array_first;
use function array_flip;
use function array_keys;
@@ -27,6 +33,7 @@ use function assert;
use function count;
use function explode;
use function implode;
use function in_array;
use function sort;
use function sprintf;
use function str_contains;
@@ -233,4 +240,63 @@ final class MongoDBHelper
return sprintf('Type::%s', $constants[$fieldType]);
}
/**
* Given a property, make a best-effort guess for its search index type.
*/
public static function guessSearchIndexTypeForProperty(ReflectionProperty $property): string|null
{
if (! $property->hasType()) {
return null;
}
$type = $property->getType();
assert($type instanceof ReflectionNamedType);
$typeString = $type->getName();
if ($type->isBuiltin()) {
if ($typeString === 'array' && self::hasEmbeddingAttribute($property)) {
return 'embeddedDocuments';
}
return self::deriveSearchIndexTypeFromNativeType($typeString);
}
if ($typeString === Uuid::class) {
return 'uuid';
}
if ($typeString === ObjectId::class) {
return 'objectId';
}
if (in_array($typeString, [DateTime::class, DateTimeImmutable::class, DateTimeInterface::class])) {
return 'date';
}
if (self::hasEmbeddingAttribute($property)) {
return 'document';
}
return null;
}
private static function deriveSearchIndexTypeFromNativeType(string $type): string|null
{
return match ($type) {
'bool' => 'bool',
'float', 'int' => 'number',
'string' => 'string',
'object' => 'document',
default => null,
};
}
private static function hasEmbeddingAttribute(ReflectionProperty $property): bool
{
return ! empty(array_filter(
$property->getAttributes(),
static fn ($attr) => in_array($attr->getName(), [EmbedMany::class, EmbedOne::class]),
));
}
}

View File

@@ -25,6 +25,8 @@ class MakeIndexTest extends MakerTestCase
{
return self::buildMakerTest()
->preRun(static function (MakerTestRunner $runner) use ($withDatabase): void {
$runner->runConsole('doctrine:mongodb:schema:update', ['--force' => true]);
if (! $withDatabase) {
return;
}
@@ -39,6 +41,16 @@ class MakeIndexTest extends MakerTestCase
sprintf('make-index/documents/User.php'),
sprintf('src/Document/User.php'),
);
$runner->copy(
sprintf('make-index/documents/SearchableUser.php'),
sprintf('src/Document/SearchableUser.php'),
);
$runner->copy(
sprintf('utils/MongoDBFunctionalTestCase.php'),
sprintf('src/MongoDBFunctionalTestCase.php'),
);
});
}
@@ -55,7 +67,7 @@ class MakeIndexTest extends MakerTestCase
$runner->runMaker([
// document class name
'User',
// index should not be unique
// should be a regular index
'',
// add an index on `lastName`
'1',
@@ -80,7 +92,7 @@ class MakeIndexTest extends MakerTestCase
$runner->runMaker([
// document class name
'User',
// index should not be unique
// should be a regular index
'',
// add an index on `lastName`
'1',
@@ -89,7 +101,7 @@ class MakeIndexTest extends MakerTestCase
// create another index
'y',
// index should be unique
'y',
'Unique',
// add an index on `firstName`
'0',
// `desc` order for `firstName`
@@ -115,7 +127,7 @@ class MakeIndexTest extends MakerTestCase
$runner->runMaker([
// document class name
'User',
// index should be unique
// should be a regular index
'',
// select `firstName` and `lastName` as keys for the index
'0,1',
@@ -135,6 +147,30 @@ class MakeIndexTest extends MakerTestCase
]);
}),
];
yield 'it creates search indexes' => [
self::createMakeIndexTest()
->run(static function (MakerTestRunner $runner): void {
$runner->runMaker([
// document class name
'SearchableUser',
// should be a search index
'Search',
// Default index name
'',
// should be statically mapped
'n',
'lastName',
]);
self::runSearchIndexTest($runner, [
'default' => [
'type' => 'search',
'fields' => ['lastName' => 'string'],
],
]);
}),
];
}
/** @param array<string, mixed> $data */
@@ -148,4 +184,16 @@ class MakeIndexTest extends MakerTestCase
$runner->runTests();
}
/** @param array<string,mixed> $data */
private static function runSearchIndexTest(MakerTestRunner $runner, array $data = []): void
{
$runner->renderTemplateFile(
'make-index/GeneratedSearchIndexesTest.php.twig',
'tests/GeneratedSearchIndexesTest.php',
['data' => $data],
);
$runner->runTests();
}
}

View File

@@ -10,6 +10,8 @@ use Symfony\Bundle\MakerBundle\Test\MakerTestKernel as MakerBundleTestKernel;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use function getenv;
class MakerTestKernel extends MakerBundleTestKernel
{
public function registerBundles(): iterable
@@ -24,10 +26,11 @@ class MakerTestKernel extends MakerBundleTestKernel
parent::registerContainerConfiguration($loader);
$loader->load(static function (ContainerBuilder $container): void {
$uri = getenv('MONGODB_URI') ?: 'mongodb://localhost:27017';
$container->loadFromExtension('doctrine_mongodb', [
'default_connection' => 'default',
'connections' => [
'default' => ['server' => 'mongodb://localhost:27017'],
'default' => ['server' => $uri],
],
'document_managers' => [
'default' => ['auto_mapping' => true],

View File

@@ -15,23 +15,25 @@ namespace Doctrine\Bundle\MongoDBMakerBundle\Tests\MongoDB;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Bundle\MongoDBMakerBundle\MongoDB\MongoDBHelper;
use Doctrine\ODM\MongoDB\Configuration;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Mapping\Attribute as ODM;
use Doctrine\ODM\MongoDB\Mapping\Driver\AttributeDriver;
use Doctrine\ODM\MongoDB\Types\Type;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Generator;
use MongoDB\BSON\ObjectId;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionException;
use ReflectionProperty;
use Symfony\Component\Uid\Uuid;
use function class_exists;
use function get_debug_type;
class MongoDBHelperTest extends TestCase
{
private MongoDBHelper $helper;
@@ -213,6 +215,82 @@ class MongoDBHelperTest extends TestCase
$this->assertSame('Type::STRING', MongoDBHelper::getTypeConstant(Type::STRING));
$this->assertNull(MongoDBHelper::getTypeConstant('unknown_type'));
}
#[DataProvider('guessSearchIndexTypeForPropertyDataProvider')]
public function testGuessSearchIndexTypeForProperty(ReflectionProperty $property, string|null $expected): void
{
$this->assertSame($expected, MongoDBHelper::guessSearchIndexTypeForProperty($property));
}
public static function guessSearchIndexTypeForPropertyDataProvider(): Generator
{
$class = new ReflectionClass(TypeMockClass::class);
yield 'bool property' => [
$class->getProperty('bool'),
'bool',
];
yield 'string property' => [
$class->getProperty('string'),
'string',
];
yield 'float property' => [
$class->getProperty('float'),
'number',
];
yield 'int property' => [
$class->getProperty('int'),
'number',
];
yield 'Uuid property' => [
$class->getProperty('uuid'),
'uuid',
];
yield 'ObjectId property' => [
$class->getProperty('someObjectId'),
'objectId',
];
yield 'DateTime' => [
$class->getProperty('dateTime'),
'date',
];
yield 'DateTimeImmutable property' => [
$class->getProperty('dateTimeImmutable'),
'date',
];
yield 'DateTimeInterface property' => [
$class->getProperty('dateTimeInterface'),
'date',
];
yield 'array of objects property' => [
$class->getProperty('arrayOfEmbeddedDocuments'),
'embeddedDocuments',
];
yield 'embedded document property' => [
$class->getProperty('embeddedDocument'),
'document',
];
yield 'nested arrays property' => [
$class->getProperty('nestedArrays'),
null,
];
yield 'mixed property' => [
$class->getProperty('mixed'),
null,
];
}
}
class CustomType extends Type
@@ -222,3 +300,31 @@ class CustomType extends Type
return 'foo';
}
}
class TypeMockClass
{
public ?bool $bool;
public ?string $string;
public ?float $float;
public ?int $int;
public ?Uuid $uuid;
public ?ObjectId $someObjectId;
public ?DateTime $dateTime;
public ?DateTimeImmutable $dateTimeImmutable;
public ?DateTimeInterface $dateTimeInterface;
/** @var string[] */
public array $arrayOfStrings;
/** @var object[] */
#[ODM\EmbedMany]
public array $arrayOfEmbeddedDocuments;
#[ODM\EmbedOne]
public object $embeddedDocument;
/** @var array<string[]> */
public array $nestedArrays;
public mixed $mixed;
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Tests;
use App\MongoDBFunctionalTestCase;
use App\Document\SearchableUser;
use Doctrine\ODM\MongoDB\DocumentManager;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class GeneratedDocumentTest extends KernelTestCase
{
use MongoDBFunctionalTestCase;
public function setup(): void
{
self::bootKernel();
/** @var DocumentManager $dm */
$dm = self::$kernel->getContainer()
->get('doctrine_mongodb')
->getManager();
self::skipIfSearchIndexesNotSupported($dm);
$schemaManager = $dm->getSchemaManager();
$schemaManager->dropDocumentCollection(SearchableUser::class);
}
public function testGeneratedDocument()
{
self::bootKernel();
/** @var DocumentManager $dm */
$dm = self::$kernel->getContainer()
->get('doctrine_mongodb')
->getManager();
$schemaManager = $dm->getSchemaManager();
$schemaManager->createDocumentCollection(SearchableUser::class);
$schemaManager->createDocumentSearchIndexes(SearchableUser::class);
$iterator = $dm->getDocumentCollection(SearchableUser::class)->listSearchIndexes();
while ($current = $iterator->current()) {
if (! isset($indexes[$current['name']])) {
$indexes[$current['name']]['type'] = $current['type'] ?? null;
foreach ($current['latestDefinition']['mappings']['fields'] as $fieldName => $mapping) {
$indexes[$current['name']]['fields'][$fieldName] = $mapping['type'];
}
}
$iterator->next();
}
$expected = json_decode('{{ data|json_encode|raw }}', true);
$this->assertSame($expected, $indexes);
}
}

View File

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

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App;
use Doctrine\ODM\MongoDB\DocumentManager;
use MongoDB\Driver\Command;
use MongoDB\Driver\Exception\ServerException;
/**
* This trait can be used inside generated tests.
* Copy it over to the test file system and use it in the generated test class.
*/
trait MongoDBFunctionalTestCase
{
public static function skipIfSearchIndexesNotSupported(DocumentManager $dm): void
{
try {
$db = $dm->getClient()->selectDatabase($dm->getConfiguration()->getDefaultDB());
$db->dropCollection(__METHOD__);
$db->createCollection(__METHOD__);
$db->getCollection(__METHOD__)->dropSearchIndex('nonexistent-index');
} catch (ServerException $exception) {
// Code 27 = Search index does not exist, which indicates that the feature is supported
if ($exception->getCode() === 27) {
return;
}
self::markTestSkipped($exception->getMessage());
}
}
}