From 279025c3f28640a70d3576d01a6a087384c56917 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Thu, 18 Apr 2024 15:07:13 -0400 Subject: [PATCH 01/15] Model search index mappings --- doctrine-mongo-mapping.xsd | 91 ++++++++++++ .../Mapping/Annotations/SearchIndex.php | 36 +++++ .../ODM/MongoDB/Mapping/ClassMetadata.php | 56 ++++++++ .../Mapping/Driver/AttributeDriver.php | 25 ++++ .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 136 ++++++++++++++++++ .../Mapping/AbstractMappingDriverTestCase.php | 95 ++++++++++++ ....Mapping.AbstractMappingDriverUser.dcm.xml | 15 ++ 7 files changed, 454 insertions(+) create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index d4838f1aa3..a7ddecca34 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -100,6 +100,7 @@ + @@ -466,6 +467,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php new file mode 100644 index 0000000000..e985fa6b60 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php @@ -0,0 +1,36 @@ +|null $fields + * @param list|null $analyzers + * @param bool|array|null $storedSource + * @param list|null $synonyms + */ + public function __construct( + public ?string $name = null, + public ?bool $dynamic = null, + public ?array $fields = null, + public ?string $analyzer = null, + public ?string $searchAnalyzer = null, + public ?array $analyzers = null, + public $storedSource = null, + public ?array $synonyms = null, + ) { + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 874050d9b5..e0080ac2be 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -218,6 +218,21 @@ * keys: IndexKeys, * options: IndexOptions * } + * @psalm-type SearchIndexDefinition = array{ + * mappings: array{ + * dynamic?: bool, + * fields?: array, + * }, + * analyzer?: string, + * searchAnalyzer?: string, + * analyzers?: array, + * storedSource?: array|bool, + * synonyms?: array, + * } + * @psalm-type SearchIndexMapping = array{ + * name?: string, + * definition: SearchIndexDefinition + * } * @psalm-type ShardKeys = array * @psalm-type ShardOptions = array * @psalm-type ShardKey = array{ @@ -458,6 +473,13 @@ */ public $indexes = []; + /** + * READ-ONLY: The array of search indexes for the document collection. + * + * @var list + */ + public $searchIndexes = []; + /** * READ-ONLY: Keys and options describing shard key. Only for sharded collections. * @@ -1157,6 +1179,40 @@ public function hasIndexes(): bool return $this->indexes !== []; } + /** + * Add a search index for this Document. + * + * @psalm-param SearchIndexDefinition $definition + */ + public function addSearchIndex(array $definition, ?string $name = null): void + { + $searchIndex = ['definition' => $definition]; + + if ($name !== null) { + $searchIndex['name'] = $name; + } + + $this->searchIndexes[] = $searchIndex; + } + + /** + * Returns the array of search indexes for this Document. + * + * @psalm-return list + */ + public function getSearchIndexes(): array + { + return $this->searchIndexes; + } + + /** + * Checks whether this document has search indexes or not. + */ + public function hasSearchIndexes(): bool + { + return $this->searchIndexes !== []; + } + /** * Set shard key for this Document. * diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php index 7597074047..f0099c28ed 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AttributeDriver.php @@ -8,6 +8,7 @@ use Doctrine\ODM\MongoDB\Events; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Doctrine\ODM\MongoDB\Mapping\Annotations\AbstractIndex; +use Doctrine\ODM\MongoDB\Mapping\Annotations\SearchIndex; use Doctrine\ODM\MongoDB\Mapping\Annotations\ShardKey; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; @@ -98,6 +99,10 @@ public function loadMetadataForClass($className, PersistenceClassMetadata $metad $this->addIndex($metadata, $attribute); } + if ($attribute instanceof ODM\SearchIndex) { + $this->addSearchIndex($metadata, $attribute); + } + if ($attribute instanceof ODM\Indexes) { trigger_deprecation( 'doctrine/mongodb-odm', @@ -349,6 +354,26 @@ private function addIndex(ClassMetadata $class, AbstractIndex $index, array $key $class->addIndex($keys, $options); } + /** @param ClassMetadata $class */ + private function addSearchIndex(ClassMetadata $class, SearchIndex $index): void + { + $definition = []; + + foreach (['dynamic', 'fields'] as $key) { + if (isset($index->$key)) { + $definition['mappings'][$key] = $index->$key; + } + } + + foreach (['analyzer', 'searchAnalyzer', 'analyzers', 'storedSource', 'synonyms'] as $key) { + if (isset($index->$key)) { + $definition[$key] = $index->$key; + } + } + + $class->addSearchIndex($definition, $index->name ?? null); + } + /** * @param ClassMetadata $class * diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 07be585afb..e6dcbb1641 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -190,6 +190,12 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C } } + if (isset($xmlRoot->{'search-indexes'})) { + foreach ($xmlRoot->{'search-indexes'}->{'search-index'} as $searchIndex) { + $this->addSearchIndex($metadata, $searchIndex); + } + } + if (isset($xmlRoot->{'shard-key'})) { $this->setShardKey($metadata, $xmlRoot->{'shard-key'}[0]); } @@ -571,6 +577,136 @@ private function addIndex(ClassMetadata $class, SimpleXMLElement $xmlIndex): voi $class->addIndex($keys, $options); } + /** @param ClassMetadata $class */ + private function addSearchIndex(ClassMetadata $class, SimpleXMLElement $searchIndex): void + { + $definition = []; + + if (isset($searchIndex['dynamic'])) { + $definition['mappings']['dynamic'] = $this->convertXMLElementValue((string) $searchIndex['dynamic']); + } + + foreach ($searchIndex->field as $field) { + $name = (string) $field['name']; + $fieldDefinition = $this->getSearchIndexFieldDefinition($field); + + // If the field is indexed with multiple data types, collect the definitions in a list. + // See: https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings/#index-field-as-multiple-data-types + if (isset($definition['mappings']['fields'][$name])) { + if (! array_is_list($definition['mappings']['fields'][$name])) { + $definition['mappings']['fields'][$name] = [$definition['mappings']['fields'][$name]]; + } + + $definition['mappings']['fields'][$name][] = $fieldDefinition; + } else { + $definition['mappings']['fields'][$name] = $fieldDefinition; + } + } + + foreach (['analyzer', 'searchAnalyzer', 'storedSource'] as $key) { + if (isset($searchIndex[$key])) { + $definition[$key] = $this->convertXMLElementValue((string) $searchIndex[$key]); + } + } + + foreach ($searchIndex->{'stored-source'} as $storedSource) { + $type = (string) $storedSource['type']; + $fields = []; + + foreach ($storedSource->field as $field) { + $fields[] = (string) $field['name']; + } + + if (isset($definition['storedSource'])) { + throw new InvalidArgumentException('Search index definition already has a "storedSource" option'); + } + + if ($type !== 'include' && $type !== 'exclude') { + throw new InvalidArgumentException(sprintf('Type "%s" is unsupported for ', $type)); + } + + $definition['storedSource'] = [$type => $fields]; + } + + foreach ($searchIndex->synonym as $synonym) { + $definition['synonyms'][] = [ + 'analyzer' => (string) $synonym['analyzer'], + 'name' => (string) $synonym['name'], + 'source' => ['collection' => (string) $synonym['sourceCollection']], + ]; + } + + $name = isset($searchIndex['name']) ? (string) $searchIndex['name'] : null; + + $class->addSearchIndex($definition, $name); + } + + private function getSearchIndexFieldDefinition(SimpleXMLElement $field): array + { + $fieldDefinition = []; + + foreach ($field->field as $nestedField) { + $name = (string) $nestedField['name']; + $nestedFieldDefinition = $this->getSearchIndexFieldDefinition($field); + + // If the field is indexed with multiple data types, collect the definitions in a list. + // See: https://www.mongodb.com/docs/atlas/atlas-search/define-field-mappings/#index-field-as-multiple-data-types + if (isset($fieldDefinition[$name])) { + if (! array_is_list($fieldDefinition['fields'][$name])) { + $fieldDefinition['fields'][$name] = [$fieldDefinition['fields'][$name]]; + } + + $fieldDefinition['fields'][$name][] = $fieldDefinition; + } else { + $fieldDefinition['fields'][$name] = $fieldDefinition; + } + } + + foreach ($field->multi as $multi) { + $name = (string) $multi['name']; + $fieldDefinition['multi'][$name] = $this->getSearchIndexFieldDefinition($multi); + } + + $allowedOptions = [ + 'type', + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/autocomplete-type/ + 'maxGrams', + 'minGrams', + 'tokenization', + 'foldDiacritics', + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/document-type/ + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/embedded-documents-type/ + 'dynamic', + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/geo-type/ + 'indexShapes', + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/knn-vector/ + 'dimensions', + 'similarity', + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/number-type/ + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/number-facet-type/ + 'representation', + 'indexIntegers', + 'indexDoubles', + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/string-type/ + 'analyzer', + 'searchAnalyzer', + 'indexOptions', + 'store', + 'ignoreAbove', + 'norms', + // https://www.mongodb.com/docs/atlas/atlas-search/field-types/token-type/ + 'normalizer', + ]; + + foreach ($allowedOptions as $key) { + if (isset($field[$key])) { + $fieldDefinition[$key] = $this->convertXMLElementValue((string) $field[$key]); + } + } + + return $fieldDefinition; + } + /** @return array|scalar|null> */ private function getPartialFilterExpression(SimpleXMLElement $fields): array { diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index d13554156f..46e3435558 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php @@ -424,6 +424,59 @@ public function testIndexes(ClassMetadata $class): ClassMetadata return $class; } + /** + * @param ClassMetadata $class + */ + #[Depends('testLoadMapping')] + public function testSearchIndexes(ClassMetadata $class): void + { + $expectedIndexes = [ + [ + 'definition' => [ + 'mappings' => ['dynamic' => true], + 'analyzer' => 'lucene.standard', + 'searchAnalyzer' => 'lucene.standard', + 'storedSource' => true, + ], + ], + [ + 'name' => 'usernameAndPhoneNumbers', + 'definition' => [ + 'mappings' => [ + 'fields' => [ + 'username' => [ + [ + 'type' => 'string', + 'multi' => [ + 'english' => ['type' => 'string', 'analyzer' => 'lucene.english'], + 'french' => ['type' => 'string', 'analyzer' => 'lucene.french'], + ], + ], + ['type' => 'autocomplete'], + ], + 'embedded_phone_number' => [ + 'type' => 'embeddedDocuments', + 'dynamic' => true, + ], + ], + ], + 'storedSource' => [ + 'include' => ['username'], + ], + 'synonyms' => [ + [ + 'name' => 'mySynonyms', + 'analyzer' => 'lucene.english', + 'source' => ['collection' => 'synonyms'], + ], + ], + ] + ], + ]; + + self::assertEquals($expectedIndexes, $class->getSearchIndexes()); + } + /** @param ClassMetadata $class */ #[Depends('testIndexes')] public function testShardKey(ClassMetadata $class): void @@ -634,6 +687,27 @@ public function testEnumType(): void * @ODM\DefaultDiscriminatorValue("default") * @ODM\HasLifecycleCallbacks * @ODM\Indexes(@ODM\Index(keys={"createdAt"="asc"},expireAfterSeconds=3600),@ODM\Index(keys={"lock"="asc"},partialFilterExpression={"version"={"$gt"=1},"discr"={"$eq"="default"}})) + * @ODM\SearchIndex(dynamic=true, analyzer="lucene.standard", searchAnalyzer="lucene.standard", storedSource=true) + * @ODM\SearchIndex( + * name="usernameAndPhoneNumbers", + * fields={ + * "username"={ + * { + * "type"="string", + * "multi"={ + * "english"={"type"="string", "analyzer"="lucene.english"}, + * "french"={"type"="string", "analyzer"="lucene.french"}, + * }, + * }, + * {"type"="autocomplete"}, + * }, + * "embedded_phone_number"={"type"="embeddedDocuments", "dynamic"=true}, + * }, + * storedSource={"include"={"username"}}, + * synonyms={ + * {"name"="mySynonyms", "analyzer"="lucene.english", "source"={"collection"="synonyms"}}, + * }, + * ) * @ODM\ShardKey(keys={"name"="asc"},unique=true,numInitialChunks=4096) * @ODM\ReadPreference("primaryPreferred", tags={ * { "dc"="east" }, @@ -648,6 +722,27 @@ public function testEnumType(): void #[ODM\HasLifecycleCallbacks] #[ODM\Index(keys: ['createdAt' => 'asc'], expireAfterSeconds: 3600)] #[ODM\Index(keys: ['lock' => 'asc'], partialFilterExpression: ['version' => ['$gt' => 1], 'discr' => ['$eq' => 'default']])] +#[ODM\SearchIndex(dynamic: true, analyzer: 'lucene.standard', searchAnalyzer: 'lucene.standard', storedSource: true)] +#[ODM\SearchIndex( + name: 'usernameAndPhoneNumbers', + fields: [ + 'username' => [ + [ + 'type' => 'string', + 'multi' => [ + 'english' => ['type' => 'string', 'analyzer' => 'lucene.english'], + 'french' => ['type' => 'string', 'analyzer' => 'lucene.french'], + ], + ], + ['type' => 'autocomplete'], + ], + 'embedded_phone_number' => ['type' => 'embeddedDocuments', 'dynamic' => true], + ], + storedSource: ['include' => ['username']], + synonyms: [ + ['name' => 'mySynonyms', 'analyzer' => 'lucene.english', 'source' => ['collection' => 'synonyms']], + ], +)] #[ODM\ShardKey(keys: ['name' => 'asc'], unique: true, numInitialChunks: 4096)] #[ODM\ReadPreference('primaryPreferred', tags: [['dc' => 'east'], ['dc' => 'west'], []])] class AbstractMappingDriverUser diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverUser.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverUser.dcm.xml index 91ee27d94b..96c4493b15 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverUser.dcm.xml +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/xml/Doctrine.ODM.MongoDB.Tests.Mapping.AbstractMappingDriverUser.dcm.xml @@ -44,6 +44,21 @@ + + + + + + + + + + + + + + + From 54161f21d098950df8552f7718e105792a1b5acd Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Fri, 26 Apr 2024 15:04:57 -0400 Subject: [PATCH 02/15] Store default search index name in ClassMetadata --- .../ODM/MongoDB/Mapping/ClassMetadata.php | 20 +++++++++++-------- .../Mapping/AbstractMappingDriverTestCase.php | 1 + 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index e0080ac2be..7fde766b80 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -230,7 +230,7 @@ * synonyms?: array, * } * @psalm-type SearchIndexMapping = array{ - * name?: string, + * name: string, * definition: SearchIndexDefinition * } * @psalm-type ShardKeys = array @@ -392,6 +392,13 @@ public const STORAGE_STRATEGY_ATOMIC_SET_ARRAY = 'atomicSetArray'; public const STORAGE_STRATEGY_SET_ARRAY = 'setArray'; + /** + * Default search index name. + * + * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/ + */ + public const DEFAULT_SEARCH_INDEX_NAME = 'default'; + private const ALLOWED_GRIDFS_FIELDS = ['_id', 'chunkSize', 'filename', 'length', 'metadata', 'uploadDate']; /** @@ -1186,13 +1193,10 @@ public function hasIndexes(): bool */ public function addSearchIndex(array $definition, ?string $name = null): void { - $searchIndex = ['definition' => $definition]; - - if ($name !== null) { - $searchIndex['name'] = $name; - } - - $this->searchIndexes[] = $searchIndex; + $this->searchIndexes[] = [ + 'definition' => $definition, + 'name' => $name ?? self::DEFAULT_SEARCH_INDEX_NAME, + ]; } /** diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index 46e3435558..937c4704fe 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php @@ -432,6 +432,7 @@ public function testSearchIndexes(ClassMetadata $class): void { $expectedIndexes = [ [ + 'name' => 'default', 'definition' => [ 'mappings' => ['dynamic' => true], 'analyzer' => 'lucene.standard', From 639efb42f70b187c5dfec4c5f2015ccdfc244c08 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Fri, 26 Apr 2024 15:06:02 -0400 Subject: [PATCH 03/15] Support search indexes in SchemaManager --- lib/Doctrine/ODM/MongoDB/SchemaManager.php | 137 +++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index f2f8e8dc18..669724d476 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -13,6 +13,8 @@ use MongoDB\Driver\WriteConcern; use MongoDB\Model\IndexInfo; +use function array_column; +use function array_diff; use function array_diff_key; use function array_filter; use function array_keys; @@ -22,6 +24,7 @@ use function array_values; use function assert; use function count; +use function implode; use function in_array; use function is_array; use function is_string; @@ -318,6 +321,140 @@ public function deleteDocumentIndexes(string $documentName, ?int $maxTimeMs = nu $this->dm->getDocumentCollection($documentName)->dropIndexes($this->getWriteOptions($maxTimeMs, $writeConcern)); } + /** + * Create search indexes for all mapped document classes. + */ + public function createSearchIndexes(): void + { + foreach ($this->metadataFactory->getAllMetadata() as $class) { + if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument || $class->isView()) { + continue; + } + + $this->createDocumentSearchIndexes($class->name); + } + } + + /** + * Create search indexes for the given document class. + * + * @psalm-param class-string $documentName + * + * @throws InvalidArgumentException + */ + public function createDocumentSearchIndexes(string $documentName): void + { + $class = $this->dm->getClassMetadata($documentName); + + if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument || $class->isView()) { + throw new InvalidArgumentException('Cannot create search indexes for mapped super classes, embedded documents, query result documents, or views.'); + } + + $searchIndexes = $class->getSearchIndexes(); + + if (empty($searchIndexes)) { + return; + } + + $collection = $this->dm->getDocumentCollection($class->name); + $createdNames = $collection->createSearchIndexes($searchIndexes); + $definedNames = array_column($searchIndexes, 'name'); + + /* createSearchIndexes builds indexes asynchronously but still reports + * the names of created indexes. Report an error if any defined names + * were not actually created. */ + $unprocessedNames = array_diff($definedNames, $createdNames); + + if (! empty($unprocessedNames)) { + throw new InvalidArgumentException(sprintf('The following search indexes for %s were not created: %s', $class->name, implode(', ', $unprocessedNames))); + } + } + + /** + * Update search indexes for all mapped document classes. + * + * Search indexes will be updated using the definitions in the document + * metadata. Search indexes not defined in the metadata will be deleted. + */ + public function updateSearchIndexes(): void + { + foreach ($this->metadataFactory->getAllMetadata() as $class) { + if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument || $class->isView()) { + continue; + } + + $this->updateDocumentSearchIndexes($class->name); + } + } + + /** + * Update search indexes for the given document class. + * + * Search indexes will be updated using the definitions in the document + * metadata. Search indexes not defined in the metadata will be deleted. + * + * @psalm-param class-string $documentName + * + * @throws InvalidArgumentException + */ + public function updateDocumentSearchIndexes(string $documentName): void + { + $class = $this->dm->getClassMetadata($documentName); + + if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument || $class->isView()) { + throw new InvalidArgumentException('Cannot update search indexes for mapped super classes, embedded documents, query result documents, or views.'); + } + + $searchIndexes = $class->getSearchIndexes(); + $collection = $this->dm->getDocumentCollection($class->name); + + $definedNames = array_column($searchIndexes, 'name'); + $existingNames = array_column(iterator_to_array($collection->listSearchIndexes()), 'name'); + + foreach (array_diff($existingNames, $definedNames) as $name) { + $collection->dropSearchIndex($name); + } + + foreach ($searchIndexes as $searchIndex) { + $collection->updateSearchIndex($searchIndex['name'], $searchIndex['definition']); + } + } + + /** + * Delete search indexes for all mapped document classes. + */ + public function deleteSearchIndexes(): void + { + foreach ($this->metadataFactory->getAllMetadata() as $class) { + if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument || $class->isView()) { + continue; + } + + $this->deleteDocumentSearchIndexes($class->name); + } + } + + /** + * Delete search indexes for the given document class. + * + * @psalm-param class-string $documentName + * + * @throws InvalidArgumentException + */ + public function deleteDocumentSearchIndexes(string $documentName): void + { + $class = $this->dm->getClassMetadata($documentName); + if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument || $class->isView()) { + throw new InvalidArgumentException('Cannot delete search indexes for mapped super classes, embedded documents, query result documents, or views.'); + } + + $collection = $this->dm->getDocumentCollection($class->name); + + foreach ($collection->listSearchIndexes() as $searchIndex) { + $collection->dropSearchIndex($searchIndex['name']); + } + } + /** * Ensure collection validators are up to date for all mapped document classes. */ From 2cb83acf4b375c110c1acab95e81ece59b82eafa Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Mon, 29 Apr 2024 13:19:07 -0400 Subject: [PATCH 04/15] Support search indexes in CLI commands Adds default implementations for "process" methods in AbstractCommand, which throw BadMethodCallException. Renames internal methods in ShardCommand to no longer override "index" base methods, since sharding methods in SchemaManager do much more than process indexes. --- .../Command/Schema/AbstractCommand.php | 90 +++++++++++++++---- .../Console/Command/Schema/CreateCommand.php | 43 +++++---- .../Console/Command/Schema/DropCommand.php | 38 ++++++-- .../Console/Command/Schema/ShardCommand.php | 34 ++----- .../Console/Command/Schema/UpdateCommand.php | 39 ++++---- phpstan-baseline.neon | 10 +++ 6 files changed, 166 insertions(+), 88 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/AbstractCommand.php b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/AbstractCommand.php index ff6b6f53a3..2bd59a14e0 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/AbstractCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/AbstractCommand.php @@ -4,6 +4,7 @@ namespace Doctrine\ODM\MongoDB\Tools\Console\Command\Schema; +use BadMethodCallException; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface; use Doctrine\ODM\MongoDB\SchemaManager; @@ -18,9 +19,10 @@ abstract class AbstractCommand extends Command { - public const DB = 'db'; - public const COLLECTION = 'collection'; - public const INDEX = 'index'; + public const DB = 'db'; + public const COLLECTION = 'collection'; + public const INDEX = 'index'; + public const SEARCH_INDEX = 'search-index'; /** @return void */ protected function configure() @@ -34,23 +36,81 @@ protected function configure() ->addOption('journal', null, InputOption::VALUE_REQUIRED, 'An optional journal option for the write concern that will be used for all schema operations. Using this option without a w option will cause an exception to be thrown.'); } - /** @return void */ - abstract protected function processDocumentCollection(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern); + /** + * @return void + * + * @throws BadMethodCallException + */ + protected function processDocumentCollection(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + throw new BadMethodCallException('This command does not support collections'); + } - /** @return void */ - abstract protected function processCollection(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern); + /** + * @return void + * + * @throws BadMethodCallException + */ + protected function processCollection(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + throw new BadMethodCallException('This command does not support collections'); + } - /** @return void */ - abstract protected function processDocumentDb(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern); + /** + * @return void + * + * @throws BadMethodCallException + */ + protected function processDocumentDb(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + throw new BadMethodCallException('This command does not support databases'); + } - /** @return void */ - abstract protected function processDb(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern); + /** + * @return void + * + * @throws BadMethodCallException + */ + protected function processDb(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + throw new BadMethodCallException('This command does not support databases'); + } - /** @return void */ - abstract protected function processDocumentIndex(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern); + /** + * @return void + * + * @throws BadMethodCallException + */ + protected function processDocumentIndex(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + throw new BadMethodCallException('This command does not support indexes'); + } - /** @return void */ - abstract protected function processIndex(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern); + /** + * @return void + * + * @throws BadMethodCallException + */ + protected function processIndex(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + throw new BadMethodCallException('This command does not support indexes'); + } + + /** @throws BadMethodCallException */ + protected function processSearchIndex(SchemaManager $sm): void + { + throw new BadMethodCallException('This command does not support search indexes'); + } + + /** + * @psalm-param class-string $document + * + * @throws BadMethodCallException + */ + protected function processDocumentSearchIndex(SchemaManager $sm, string $document): void + { + throw new BadMethodCallException('This command does not support search indexes'); + } /** @return SchemaManager */ protected function getSchemaManager() diff --git a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php index dfea793b95..8e395d9385 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php @@ -4,7 +4,6 @@ namespace Doctrine\ODM\MongoDB\Tools\Console\Command\Schema; -use BadMethodCallException; use Doctrine\ODM\MongoDB\SchemaManager; use Doctrine\ODM\MongoDB\Tools\Console\Command\CommandCompatibility; use MongoDB\Driver\WriteConcern; @@ -16,14 +15,20 @@ use function array_filter; use function is_string; use function sprintf; -use function ucfirst; class CreateCommand extends AbstractCommand { use CommandCompatibility; /** @var string[] */ - private array $createOrder = [self::COLLECTION, self::INDEX]; + private array $createOrder = [self::COLLECTION, self::INDEX, self::SEARCH_INDEX]; + + /* @var array> */ + private const INFLECTIONS = [ + self::COLLECTION => ['collection', 'collections'], + self::INDEX => ['index(es)', 'indexes'], + self::SEARCH_INDEX => ['search index(es)', 'search indexes'], + ]; /** @return void */ protected function configure() @@ -35,6 +40,7 @@ protected function configure() ->addOption('class', 'c', InputOption::VALUE_REQUIRED, 'Document class to process (default: all classes)') ->addOption(self::COLLECTION, null, InputOption::VALUE_NONE, 'Create collections') ->addOption(self::INDEX, null, InputOption::VALUE_NONE, 'Create indexes') + ->addOption(self::SEARCH_INDEX, null, InputOption::VALUE_NONE, 'Create search indexes') ->addOption('background', null, InputOption::VALUE_NONE, sprintf('Create indexes in background (requires "%s" option)', self::INDEX)) ->setDescription('Create databases, collections and indexes for your documents'); } @@ -53,17 +59,22 @@ private function doExecute(InputInterface $input, OutputInterface $output): int $isErrored = false; foreach ($create as $option) { + $method = match ($option) { + self::COLLECTION => 'Collection', + self::INDEX => 'Index', + self::SEARCH_INDEX => 'SearchIndex', + }; + try { if (isset($class)) { - $this->{'processDocument' . ucfirst($option)}($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input), $background); + $this->{'processDocument' . $method}($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input), $background); } else { - $this->{'process' . ucfirst($option)}($sm, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input), $background); + $this->{'process' . $method}($sm, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input), $background); } $output->writeln(sprintf( - 'Created %s%s for %s', - $option, - is_string($class) ? ($option === self::INDEX ? '(es)' : '') : ($option === self::INDEX ? 'es' : 's'), + 'Created %s for %s', + self::INFLECTIONS[$option][isset($class) ? 0 : 1], is_string($class) ? $class : 'all classes' )); } catch (Throwable $e) { @@ -85,24 +96,24 @@ protected function processCollection(SchemaManager $sm, ?int $maxTimeMs, ?WriteC $sm->createCollections($maxTimeMs, $writeConcern); } - protected function processDocumentDb(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) + protected function processDocumentIndex(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern, bool $background = false) { - throw new BadMethodCallException('A database is created automatically by MongoDB (>= 3.0).'); + $sm->ensureDocumentIndexes($document, $maxTimeMs, $writeConcern, $background); } - protected function processDb(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) + protected function processIndex(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern, bool $background = false) { - throw new BadMethodCallException('A database is created automatically by MongoDB (>= 3.0).'); + $sm->ensureIndexes($maxTimeMs, $writeConcern, $background); } - protected function processDocumentIndex(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern, bool $background = false) + protected function processDocumentSearchIndex(SchemaManager $sm, string $document): void { - $sm->ensureDocumentIndexes($document, $maxTimeMs, $writeConcern, $background); + $sm->createDocumentSearchIndexes($document); } - protected function processIndex(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern, bool $background = false) + protected function processSearchIndex(SchemaManager $sm): void { - $sm->ensureIndexes($maxTimeMs, $writeConcern, $background); + $sm->createSearchIndexes(); } /** @return void */ diff --git a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php index cb486e1aa4..7718a0373a 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php @@ -15,14 +15,21 @@ use function array_filter; use function is_string; use function sprintf; -use function ucfirst; class DropCommand extends AbstractCommand { use CommandCompatibility; /** @var string[] */ - private array $dropOrder = [self::INDEX, self::COLLECTION, self::DB]; + private array $dropOrder = [self::SEARCH_INDEX, self::INDEX, self::COLLECTION, self::DB]; + + /* @var array> */ + private const INFLECTIONS = [ + self::DB => ['database', 'databases'], + self::COLLECTION => ['collection', 'collections'], + self::INDEX => ['index(es)', 'indexes'], + self::SEARCH_INDEX => ['search index(es)', 'search indexes'], + ]; /** @return void */ protected function configure() @@ -35,6 +42,7 @@ protected function configure() ->addOption(self::DB, null, InputOption::VALUE_NONE, 'Drop databases') ->addOption(self::COLLECTION, null, InputOption::VALUE_NONE, 'Drop collections') ->addOption(self::INDEX, null, InputOption::VALUE_NONE, 'Drop indexes') + ->addOption(self::SEARCH_INDEX, null, InputOption::VALUE_NONE, 'Drop search indexes') ->setDescription('Drop databases, collections and indexes for your documents'); } @@ -50,17 +58,23 @@ private function doExecute(InputInterface $input, OutputInterface $output): int $isErrored = false; foreach ($drop as $option) { + $method = match ($option) { + self::DB => 'Db', + self::COLLECTION => 'Collection', + self::INDEX => 'Index', + self::SEARCH_INDEX => 'SearchIndex', + }; + try { if (is_string($class)) { - $this->{'processDocument' . ucfirst($option)}($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); + $this->{'processDocument' . $method}($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); } else { - $this->{'process' . ucfirst($option)}($sm, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); + $this->{'process' . $method}($sm, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); } $output->writeln(sprintf( - 'Dropped %s%s for %s', - $option, - is_string($class) ? ($option === self::INDEX ? '(es)' : '') : ($option === self::INDEX ? 'es' : 's'), + 'Dropped %s for %s', + self::INFLECTIONS[$option][isset($class) ? 0 : 1], is_string($class) ? $class : 'all classes' )); } catch (Throwable $e) { @@ -101,4 +115,14 @@ protected function processIndex(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcer { $sm->deleteIndexes($maxTimeMs, $writeConcern); } + + protected function processDocumentSearchIndex(SchemaManager $sm, string $document): void + { + $sm->deleteDocumentSearchIndexes($document); + } + + protected function processSearchIndex(SchemaManager $sm): void + { + $sm->deleteSearchIndexes(); + } } diff --git a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/ShardCommand.php b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/ShardCommand.php index 8915aa20a5..6394064d49 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/ShardCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/ShardCommand.php @@ -4,7 +4,6 @@ namespace Doctrine\ODM\MongoDB\Tools\Console\Command\Schema; -use BadMethodCallException; use Doctrine\ODM\MongoDB\SchemaManager; use Doctrine\ODM\MongoDB\Tools\Console\Command\CommandCompatibility; use MongoDB\Driver\WriteConcern; @@ -40,10 +39,10 @@ private function doExecute(InputInterface $input, OutputInterface $output): int try { if (is_string($class)) { - $this->processDocumentIndex($sm, $class, null, $this->getWriteConcernFromInput($input)); + $this->processDocumentSharding($sm, $class, $this->getWriteConcernFromInput($input)); $output->writeln(sprintf('Enabled sharding for %s', $class)); } else { - $this->processIndex($sm, null, $this->getWriteConcernFromInput($input)); + $this->processSharding($sm, $this->getWriteConcernFromInput($input)); $output->writeln('Enabled sharding for all classes'); } } catch (Throwable $e) { @@ -54,37 +53,14 @@ private function doExecute(InputInterface $input, OutputInterface $output): int return $isErrored ? 255 : 0; } - protected function processDocumentIndex(SchemaManager $sm, string $document, ?int $maxTimeMs = null, ?WriteConcern $writeConcern = null) + /** @psalm-param class-string $document */ + private function processDocumentSharding(SchemaManager $sm, string $document, ?WriteConcern $writeConcern = null): void { $sm->ensureDocumentSharding($document, $writeConcern); } - protected function processIndex(SchemaManager $sm, ?int $maxTimeMs = null, ?WriteConcern $writeConcern = null) + private function processSharding(SchemaManager $sm, ?WriteConcern $writeConcern = null): void { $sm->ensureSharding($writeConcern); } - - /** @throws BadMethodCallException */ - protected function processDocumentCollection(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) - { - throw new BadMethodCallException('Cannot update a document collection'); - } - - /** @throws BadMethodCallException */ - protected function processCollection(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) - { - throw new BadMethodCallException('Cannot update a collection'); - } - - /** @throws BadMethodCallException */ - protected function processDocumentDb(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) - { - throw new BadMethodCallException('Cannot update a document database'); - } - - /** @throws BadMethodCallException */ - protected function processDb(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) - { - throw new BadMethodCallException('Cannot update a database'); - } } diff --git a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php index c8ae5a445f..0f7cd98782 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php @@ -4,7 +4,6 @@ namespace Doctrine\ODM\MongoDB\Tools\Console\Command\Schema; -use BadMethodCallException; use Doctrine\ODM\MongoDB\SchemaManager; use Doctrine\ODM\MongoDB\Tools\Console\Command\CommandCompatibility; use MongoDB\Driver\WriteConcern; @@ -28,14 +27,16 @@ protected function configure() $this ->setName('odm:schema:update') ->addOption('class', 'c', InputOption::VALUE_OPTIONAL, 'Document class to process (default: all classes)') + ->addOption('disable-search-indexes', null, InputOption::VALUE_NONE, 'Do not update search indexes') ->addOption('disable-validators', null, InputOption::VALUE_NONE, 'Do not update database-level validation rules') ->setDescription('Update indexes and validation rules for your documents'); } private function doExecute(InputInterface $input, OutputInterface $output): int { - $class = $input->getOption('class'); - $updateValidators = ! $input->getOption('disable-validators'); + $class = $input->getOption('class'); + $updateValidators = ! $input->getOption('disable-validators'); + $updateSearchIndexes = ! $input->getOption('disable-search-indexes'); $sm = $this->getSchemaManager(); $isErrored = false; @@ -49,6 +50,11 @@ private function doExecute(InputInterface $input, OutputInterface $output): int $this->processDocumentValidator($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); $output->writeln(sprintf('Updated validation for %s', $class)); } + + if ($updateSearchIndexes) { + $this->processDocumentSearchIndex($sm, $class); + $output->writeln(sprintf('Updated search index(es) for %s', $class)); + } } else { $this->processIndex($sm, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); $output->writeln('Updated indexes for all classes'); @@ -57,6 +63,11 @@ private function doExecute(InputInterface $input, OutputInterface $output): int $this->processValidators($sm, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); $output->writeln('Updated validation for all classes'); } + + if ($updateSearchIndexes) { + $this->processSearchIndex($sm); + $output->writeln('Updated search indexes for all classes'); + } } } catch (Throwable $e) { $output->writeln('' . $e->getMessage() . ''); @@ -88,27 +99,13 @@ protected function processValidators(SchemaManager $sm, ?int $maxTimeMs, ?WriteC $sm->updateValidators($maxTimeMs, $writeConcern); } - /** @throws BadMethodCallException */ - protected function processDocumentCollection(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) - { - throw new BadMethodCallException('Cannot update a document collection'); - } - - /** @throws BadMethodCallException */ - protected function processCollection(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) - { - throw new BadMethodCallException('Cannot update a collection'); - } - - /** @throws BadMethodCallException */ - protected function processDocumentDb(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) + protected function processDocumentSearchIndex(SchemaManager $sm, string $document): void { - throw new BadMethodCallException('Cannot update a document database'); + $sm->updateDocumentSearchIndexes($document); } - /** @throws BadMethodCallException */ - protected function processDb(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) + protected function processSearchIndex(SchemaManager $sm): void { - throw new BadMethodCallException('Cannot update a database'); + $sm->updateSearchIndexes(); } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0aafde288d..c08e3e0caa 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -590,6 +590,16 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/AbstractCommand.php + - + message: "#^Match expression does not handle remaining value\\: string$#" + count: 1 + path: lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php + + - + message: "#^Match expression does not handle remaining value\\: string$#" + count: 1 + path: lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php + - message: "#^Call to an undefined method Symfony\\\\Component\\\\Console\\\\Helper\\\\HelperInterface\\:\\:getDocumentManager\\(\\)\\.$#" count: 1 From 53f85600f831da50177eb724a00106fab3e81b32 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Mon, 29 Apr 2024 13:23:23 -0400 Subject: [PATCH 05/15] Require at least one within --- doctrine-mongo-mapping.xsd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doctrine-mongo-mapping.xsd b/doctrine-mongo-mapping.xsd index a7ddecca34..e460afc955 100644 --- a/doctrine-mongo-mapping.xsd +++ b/doctrine-mongo-mapping.xsd @@ -468,7 +468,7 @@ - + From cb7c28b1ed715d9e8803d511f0deac77a6b5cd0b Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 1 May 2024 11:08:29 -0400 Subject: [PATCH 06/15] SchemaManager tests --- .../ODM/MongoDB/Tests/SchemaManagerTest.php | 84 +++++++++++++++++++ tests/Documents/CmsAddress.php | 4 +- tests/Documents/CmsArticle.php | 4 +- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php index d399db41c1..a8418d0093 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php @@ -63,6 +63,12 @@ class SchemaManagerTest extends BaseTestCase ShardedOneWithDifferentKey::class, ]; + /** @psalm-var list */ + private array $searchIndexedClasses = [ + CmsAddress::class, + CmsArticle::class, + ]; + /** @psalm-var list */ private array $views = [ UserName::class, @@ -375,6 +381,84 @@ public function testDeleteDocumentIndexes(array $expectedWriteOptions, ?int $max $this->schemaManager->deleteDocumentIndexes(CmsArticle::class, $maxTimeMs, $writeConcern); } + public function testCreateSearchIndexes(): void + { + $searchIndexedCollections = array_map( + fn (string $fqcn) => $this->dm->getClassMetadata($fqcn)->getCollection(), + $this->searchIndexedClasses, + ); + foreach ($this->documentCollections as $collectionName => $collection) { + if (in_array($collectionName, $searchIndexedCollections)) { + $collection + ->expects($this->once()) + ->method('createSearchIndexes') + ->with($this->anything()) + ->willReturn(['default']); + } else { + $collection->expects($this->never())->method('createSearchIndexes'); + } + } + + $this->schemaManager->createSearchIndexes(); + } + + public function testCreateDocumentSearchIndexes(): void + { + $cmsArticleCollectionName = $this->dm->getClassMetadata(CmsArticle::class)->getCollection(); + foreach ($this->documentCollections as $collectionName => $collection) { + if ($collectionName === $cmsArticleCollectionName) { + $collection + ->expects($this->once()) + ->method('createSearchIndexes') + ->with($this->anything()) + ->willReturn(['default']); + } else { + $collection->expects($this->never())->method('createSearchIndexes'); + } + } + + $this->schemaManager->createDocumentSearchIndexes(CmsArticle::class); + } + + public function testUpdateDocumentSearchIndexes(): void + { + $collectionName = $this->dm->getClassMetadata(CmsArticle::class)->getCollection(); + $collection = $this->documentCollections[$collectionName]; + $collection + ->expects($this->once()) + ->method('listSearchIndexes') + ->willReturn(new ArrayIterator([ + ['name' => 'default'], + ['name' => 'foo'], + ])); + $collection + ->expects($this->once()) + ->method('dropSearchIndex') + ->with('foo'); + $collection + ->expects($this->once()) + ->method('updateSearchIndex') + ->with('default', $this->anything()); + + $this->schemaManager->updateDocumentSearchIndexes(CmsArticle::class); + } + + public function testDeleteDocumentSearchIndexes(): void + { + $collectionName = $this->dm->getClassMetadata(CmsArticle::class)->getCollection(); + $collection = $this->documentCollections[$collectionName]; + $collection + ->expects($this->once()) + ->method('listSearchIndexes') + ->willReturn(new ArrayIterator([['name' => 'default']])); + $collection + ->expects($this->once()) + ->method('dropSearchIndex') + ->with('default'); + + $this->schemaManager->deleteDocumentSearchIndexes(CmsArticle::class); + } + public function testUpdateValidators(): void { $dbCommands = []; diff --git a/tests/Documents/CmsAddress.php b/tests/Documents/CmsAddress.php index 1cfb936ca8..f695d4d6bc 100644 --- a/tests/Documents/CmsAddress.php +++ b/tests/Documents/CmsAddress.php @@ -5,9 +5,9 @@ namespace Documents; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -use Doctrine\ODM\MongoDB\Mapping\Annotations\Index; -#[Index(keys: ['country' => 'asc', 'zip' => 'asc', 'city' => 'asc'])] +#[ODM\Index(keys: ['country' => 'asc', 'zip' => 'asc', 'city' => 'asc'])] +#[ODM\SearchIndex(dynamic: true)] #[ODM\Document] class CmsAddress { diff --git a/tests/Documents/CmsArticle.php b/tests/Documents/CmsArticle.php index d78f60bbe9..15accb4aa8 100644 --- a/tests/Documents/CmsArticle.php +++ b/tests/Documents/CmsArticle.php @@ -6,9 +6,9 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -use Doctrine\ODM\MongoDB\Mapping\Annotations\Index; -#[Index(keys: ['topic' => 'asc'])] +#[ODM\Index(keys: ['topic' => 'asc'])] +#[ODM\SearchIndex(dynamic: true)] #[ODM\Document] class CmsArticle { From 1db8ad2ec3a16ffc92e404fdf22b439edfe20105 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 1 May 2024 12:25:09 -0400 Subject: [PATCH 07/15] Type information for storedSource and synonym structs --- .../MongoDB/Mapping/Annotations/SearchIndex.php | 12 ++++++++---- lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php | 14 ++++++++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php index e985fa6b60..39b0fb038f 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php @@ -6,21 +6,25 @@ use Attribute; use Doctrine\Common\Annotations\Annotation\NamedArgumentConstructor; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; /** * Defines a search index on a class. * * @Annotation * @NamedArgumentConstructor + * + * @psalm-import-type SearchIndexStoredSource from ClassMetadata + * @psalm-import-type SearchIndexSynonym from ClassMetadata */ #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class SearchIndex implements Annotation { /** - * @param array|null $fields - * @param list|null $analyzers - * @param bool|array|null $storedSource - * @param list|null $synonyms + * @param array|null $fields + * @param list|null $analyzers + * @param SearchIndexStoredSource|null $storedSource + * @param list|null $synonyms */ public function __construct( public ?string $name = null, diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 7fde766b80..2a4a35f5dc 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -218,6 +218,16 @@ * keys: IndexKeys, * options: IndexOptions * } + * @psalm-type SearchIndexStoredSourceInclude = array{include: list} + * @psalm-type SearchIndexStoredSourceExclude = array{exclude: list} + * @psalm-type SearchIndexStoredSource = bool|SearchIndexStoredSourceInclude|SearchIndexStoredSourceExclude + * @psalm-type SearchIndexSynonym = array{ + * analyzer: string, + * name: string, + * source: array{ + * collection: string, + * }, + * } * @psalm-type SearchIndexDefinition = array{ * mappings: array{ * dynamic?: bool, @@ -226,8 +236,8 @@ * analyzer?: string, * searchAnalyzer?: string, * analyzers?: array, - * storedSource?: array|bool, - * synonyms?: array, + * storedSource?: SearchIndexStoredSource, + * synonyms?: list, * } * @psalm-type SearchIndexMapping = array{ * name: string, From 7654bab2f933cdebe41dcc1022f4614fc7121784 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 1 May 2024 12:28:35 -0400 Subject: [PATCH 08/15] phpcbf fixes --- .../Mapping/Annotations/SearchIndex.php | 1 - .../ODM/MongoDB/Mapping/Driver/XmlDriver.php | 9 +++-- .../Mapping/AbstractMappingDriverTestCase.php | 38 +++++++++---------- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php index 39b0fb038f..cbf3ab52a7 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php @@ -13,7 +13,6 @@ * * @Annotation * @NamedArgumentConstructor - * * @psalm-import-type SearchIndexStoredSource from ClassMetadata * @psalm-import-type SearchIndexSynonym from ClassMetadata */ diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index e6dcbb1641..665e6b4bd9 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -14,6 +14,7 @@ use MongoDB\Driver\Exception\UnexpectedValueException; use SimpleXMLElement; +use function array_is_list; use function array_keys; use function array_map; use function assert; @@ -587,7 +588,7 @@ private function addSearchIndex(ClassMetadata $class, SimpleXMLElement $searchIn } foreach ($searchIndex->field as $field) { - $name = (string) $field['name']; + $name = (string) $field['name']; $fieldDefinition = $this->getSearchIndexFieldDefinition($field); // If the field is indexed with multiple data types, collect the definitions in a list. @@ -610,7 +611,7 @@ private function addSearchIndex(ClassMetadata $class, SimpleXMLElement $searchIn } foreach ($searchIndex->{'stored-source'} as $storedSource) { - $type = (string) $storedSource['type']; + $type = (string) $storedSource['type']; $fields = []; foreach ($storedSource->field as $field) { @@ -646,7 +647,7 @@ private function getSearchIndexFieldDefinition(SimpleXMLElement $field): array $fieldDefinition = []; foreach ($field->field as $nestedField) { - $name = (string) $nestedField['name']; + $name = (string) $nestedField['name']; $nestedFieldDefinition = $this->getSearchIndexFieldDefinition($field); // If the field is indexed with multiple data types, collect the definitions in a list. @@ -663,7 +664,7 @@ private function getSearchIndexFieldDefinition(SimpleXMLElement $field): array } foreach ($field->multi as $multi) { - $name = (string) $multi['name']; + $name = (string) $multi['name']; $fieldDefinition['multi'][$name] = $this->getSearchIndexFieldDefinition($multi); } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index 937c4704fe..1a20c17458 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php @@ -424,9 +424,7 @@ public function testIndexes(ClassMetadata $class): ClassMetadata return $class; } - /** - * @param ClassMetadata $class - */ + /** @param ClassMetadata $class */ #[Depends('testLoadMapping')] public function testSearchIndexes(ClassMetadata $class): void { @@ -471,7 +469,7 @@ public function testSearchIndexes(ClassMetadata $class): void 'source' => ['collection' => 'synonyms'], ], ], - ] + ], ], ]; @@ -725,24 +723,24 @@ public function testEnumType(): void #[ODM\Index(keys: ['lock' => 'asc'], partialFilterExpression: ['version' => ['$gt' => 1], 'discr' => ['$eq' => 'default']])] #[ODM\SearchIndex(dynamic: true, analyzer: 'lucene.standard', searchAnalyzer: 'lucene.standard', storedSource: true)] #[ODM\SearchIndex( - name: 'usernameAndPhoneNumbers', - fields: [ - 'username' => [ - [ - 'type' => 'string', - 'multi' => [ - 'english' => ['type' => 'string', 'analyzer' => 'lucene.english'], - 'french' => ['type' => 'string', 'analyzer' => 'lucene.french'], + name: 'usernameAndPhoneNumbers', + fields: [ + 'username' => [ + [ + 'type' => 'string', + 'multi' => [ + 'english' => ['type' => 'string', 'analyzer' => 'lucene.english'], + 'french' => ['type' => 'string', 'analyzer' => 'lucene.french'], + ], + ], + ['type' => 'autocomplete'], ], - ], - ['type' => 'autocomplete'], + 'embedded_phone_number' => ['type' => 'embeddedDocuments', 'dynamic' => true], + ], + storedSource: ['include' => ['username']], + synonyms: [ + ['name' => 'mySynonyms', 'analyzer' => 'lucene.english', 'source' => ['collection' => 'synonyms']], ], - 'embedded_phone_number' => ['type' => 'embeddedDocuments', 'dynamic' => true], - ], - storedSource: ['include' => ['username']], - synonyms: [ - ['name' => 'mySynonyms', 'analyzer' => 'lucene.english', 'source' => ['collection' => 'synonyms']], - ], )] #[ODM\ShardKey(keys: ['name' => 'asc'], unique: true, numInitialChunks: 4096)] #[ODM\ReadPreference('primaryPreferred', tags: [['dc' => 'east'], ['dc' => 'west'], []])] From 7957b10fcb9f7e384cbc2185a197faa5e7a1c79f Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 1 May 2024 13:25:43 -0400 Subject: [PATCH 09/15] Update phpstan baseline for unmodeled search index structs The fields struct is recursive, which is not supported by phpstan. The analyzers struct may be technically possible to model, but the complexity isn't worth the effort. --- phpstan-baseline.neon | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c08e3e0caa..736b03a30c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -446,8 +446,23 @@ parameters: path: lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php - - message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\:\\:validateAndCompleteTypedFieldMapping\\(\\) should return array\\{type\\?\\: string, fieldName\\?\\: string, name\\?\\: string, strategy\\?\\: string, association\\?\\: int, id\\?\\: bool, isOwningSide\\?\\: bool, collectionClass\\?\\: class\\-string, \\.\\.\\.\\} but returns array\\{type\\?\\: string, fieldName\\?\\: string, name\\?\\: string, strategy\\?\\: string, association\\?\\: int, id\\?\\: bool, isOwningSide\\?\\: bool, collectionClass\\?\\: class\\-string, \\.\\.\\.\\}\\.$#" + message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\Annotations\\\\SearchIndex\\:\\:__construct\\(\\) has parameter \\$analyzers with no value type specified in iterable type array\\.$#" count: 1 + path: lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php + + - + message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\Annotations\\\\SearchIndex\\:\\:__construct\\(\\) has parameter \\$fields with no value type specified in iterable type array\\.$#" + count: 1 + path: lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php + + - + message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\:\\:addSearchIndex\\(\\) has parameter \\$definition with no value type specified in iterable type array\\.$#" + count: 2 + path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php + + - + message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\:\\:getSearchIndexes\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 2 path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - @@ -455,6 +470,16 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php + - + message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\:\\:\\$searchIndexes type has no value type specified in iterable type array\\.$#" + count: 2 + path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php + + - + message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\Driver\\\\XmlDriver\\:\\:getSearchIndexFieldDefinition\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php + - message: "#^Parameter \\#2 \\$mapping of method Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\Driver\\\\XmlDriver\\:\\:addFieldMapping\\(\\) expects array\\{type\\?\\: string, fieldName\\?\\: string, name\\?\\: string, strategy\\?\\: string, association\\?\\: int, id\\?\\: bool, isOwningSide\\?\\: bool, collectionClass\\?\\: class\\-string, \\.\\.\\.\\}, array\\\\|bool\\|string\\> given\\.$#" count: 1 From c45a25327492a7dc4b02d74c66a8922baff4c7f2 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 1 May 2024 14:32:28 -0400 Subject: [PATCH 10/15] Update psalm baseline for XmlDriver --- psalm-baseline.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index eda95bae28..4ff55a448b 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -100,6 +100,7 @@ {'reference-many'}]]> {'reference-one'}]]> {'schema-validation'}]]> + {'search-indexes'}]]> {'shard-key'}]]> @@ -117,6 +118,7 @@ {'reference-many'}]]> {'reference-one'}]]> {'schema-validation'}]]> + {'search-indexes'}]]> {'shard-key'}]]> @@ -155,6 +157,7 @@ {'reference-many'})]]> {'reference-one'})]]> {'schema-validation'})]]> + {'search-indexes'})]]> {'shard-key'})]]> option)]]> From 92bac8047ee0233e393c19df3bf5cbeb6029aedc Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Wed, 1 May 2024 14:37:24 -0400 Subject: [PATCH 11/15] Require driver 1.17+ for search index APIs --- .github/workflows/continuous-integration.yml | 2 +- composer.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index fe10b42ecb..d2e179e36e 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -38,7 +38,7 @@ jobs: - dependencies: "lowest" php-version: "8.1" mongodb-version: "5.0" - driver-version: "1.11.0" + driver-version: "1.17.0" topology: "server" symfony-version: "stable" # Test with highest dependencies diff --git a/composer.json b/composer.json index 5952379a8b..70a3605cff 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ ], "require": { "php": "^8.1", - "ext-mongodb": "^1.11", + "ext-mongodb": "^1.17", "doctrine/cache": "^1.11 || ^2.0", "doctrine/collections": "^1.5 || ^2.0", "doctrine/event-manager": "^1.0 || ^2.0", @@ -30,7 +30,7 @@ "doctrine/persistence": "^3.2", "friendsofphp/proxy-manager-lts": "^1.0", "jean85/pretty-package-versions": "^1.3.0 || ^2.0.1", - "mongodb/mongodb": "^1.10.0", + "mongodb/mongodb": "^1.17.0", "psr/cache": "^1.0 || ^2.0 || ^3.0", "symfony/console": "^5.4 || ^6.0 || ^7.0", "symfony/deprecation-contracts": "^2.2 || ^3.0", From da37dc51a836792866764d77bfc617ab18425b80 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Mon, 6 May 2024 11:59:07 -0400 Subject: [PATCH 12/15] Add skip-search-indexes option to schema CLI commands Currently, commands can either process all definitions (default behavior) or specify individual definitions. This allows the commands to rely on default behavior (e.g. $createOrder) but omit processing of search indexes, which may be more stringent requirements. Note: this is similar to the disable-validators option that already existed in UpdateCommand; however, #2634 suggests renaming that if additional "skip" options are introduced. --- .../MongoDB/Tools/Console/Command/Schema/CreateCommand.php | 5 +++++ .../ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php | 5 +++++ .../MongoDB/Tools/Console/Command/Schema/UpdateCommand.php | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php index 8e395d9385..4a237a1a1b 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/CreateCommand.php @@ -41,6 +41,7 @@ protected function configure() ->addOption(self::COLLECTION, null, InputOption::VALUE_NONE, 'Create collections') ->addOption(self::INDEX, null, InputOption::VALUE_NONE, 'Create indexes') ->addOption(self::SEARCH_INDEX, null, InputOption::VALUE_NONE, 'Create search indexes') + ->addOption('skip-search-indexes', null, InputOption::VALUE_NONE, 'Skip processing of search indexes') ->addOption('background', null, InputOption::VALUE_NONE, sprintf('Create indexes in background (requires "%s" option)', self::INDEX)) ->setDescription('Create databases, collections and indexes for your documents'); } @@ -65,6 +66,10 @@ private function doExecute(InputInterface $input, OutputInterface $output): int self::SEARCH_INDEX => 'SearchIndex', }; + if ($option === self::SEARCH_INDEX && $input->getOption('skip-search-indexes')) { + continue; + } + try { if (isset($class)) { $this->{'processDocument' . $method}($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input), $background); diff --git a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php index 7718a0373a..4fdc74a895 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/DropCommand.php @@ -43,6 +43,7 @@ protected function configure() ->addOption(self::COLLECTION, null, InputOption::VALUE_NONE, 'Drop collections') ->addOption(self::INDEX, null, InputOption::VALUE_NONE, 'Drop indexes') ->addOption(self::SEARCH_INDEX, null, InputOption::VALUE_NONE, 'Drop search indexes') + ->addOption('skip-search-indexes', null, InputOption::VALUE_NONE, 'Skip processing of search indexes') ->setDescription('Drop databases, collections and indexes for your documents'); } @@ -65,6 +66,10 @@ private function doExecute(InputInterface $input, OutputInterface $output): int self::SEARCH_INDEX => 'SearchIndex', }; + if ($option === self::SEARCH_INDEX && $input->getOption('skip-search-indexes')) { + continue; + } + try { if (is_string($class)) { $this->{'processDocument' . $method}($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); diff --git a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php index 0f7cd98782..a4d0fc313b 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php @@ -27,7 +27,7 @@ protected function configure() $this ->setName('odm:schema:update') ->addOption('class', 'c', InputOption::VALUE_OPTIONAL, 'Document class to process (default: all classes)') - ->addOption('disable-search-indexes', null, InputOption::VALUE_NONE, 'Do not update search indexes') + ->addOption('skip-search-indexes', null, InputOption::VALUE_NONE, 'Skip processing of search indexes') ->addOption('disable-validators', null, InputOption::VALUE_NONE, 'Do not update database-level validation rules') ->setDescription('Update indexes and validation rules for your documents'); } @@ -36,7 +36,7 @@ private function doExecute(InputInterface $input, OutputInterface $output): int { $class = $input->getOption('class'); $updateValidators = ! $input->getOption('disable-validators'); - $updateSearchIndexes = ! $input->getOption('disable-search-indexes'); + $updateSearchIndexes = ! $input->getOption('skip-search-indexes'); $sm = $this->getSchemaManager(); $isErrored = false; From 28beaca54f31c61db57797179c5a826a0d5bdb1d Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Tue, 7 May 2024 14:20:40 -0400 Subject: [PATCH 13/15] Update baseline for Psalm 5.24.0 --- psalm-baseline.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4ff55a448b..a0a40fd84f 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + From f916fdff719105f84b77a4775fe1bee31fefef35 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Fri, 10 May 2024 11:35:38 -0400 Subject: [PATCH 14/15] SearchIndex annotation docs --- docs/en/reference/annotations-reference.rst | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/en/reference/annotations-reference.rst b/docs/en/reference/annotations-reference.rst index bad58c7312..b53fd7bd11 100644 --- a/docs/en/reference/annotations-reference.rst +++ b/docs/en/reference/annotations-reference.rst @@ -1073,6 +1073,60 @@ Optional attributes: */ private $cart; +@SearchIndex +------------ + +This annotation is used to specify :ref:`search indexes ` for +`MongoDB Atlas Search `__. + +The attributes correspond to arguments for +`MongoDB\Collection::createSearchIndex() `__. +Excluding ``name``, attributes are used to create the +`search index definition `__. + +Optional attributes: + +- + ``name`` - Name of the search index to create, which must be unique to the + collection. Defaults to ``"default"``. +- + ``dynamic`` - Enables or disables dynamic field mapping for this index. + If ``true``, the index will include all fields with + `supported data types `__. + If ``false``, the ``fields`` attribute must be specified. Defaults to ``false``. +- + ``fields`` - Associative array of `field mappings `__ + that specify the fields to index (keys). Required only if dynamic mapping is disabled. +- + ``analyzer`` - Specifies the `analyzer `__ + to apply to string fields when indexing. Defaults to the + `standard analyzer `__. +- + ``searchAnalyzer`` - Specifies the `analyzer `__ + to apply to query text before the text is searched. Defaults to the + ``analyzer`` attribute, or the `standard analyzer `__. + if both are unspecified. +- + ``analyzers`` - Array of `custom analyzers `__ + to use in this index. +- + ``storedSource`` - Specifies document fields to store for queries performed + using the `returnedStoredSource `__ + option. Specify ``true`` to store all fields, ``false`` to store no fields, + or a `document `__ + to specify individual fields to include or exclude from storage. Defaults to ``false``. +- + ``synonyms`` - Array of `synonym mapping definitions `__ + to use in this index. + +.. note:: + + Search indexes have some notable differences from `@Index`_. They may only + be defined on document classes. Definitions will not be incorporated from + embedded documents. Additionally, ODM will **NOT** translate field names in + search index definitions. Database field names must be used instead of + mapped field names (i.e. PHP property names). + @ShardKey --------- From 1d669b945e518c24f9390b44413b53f470cbda63 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Fri, 10 May 2024 16:25:56 -0400 Subject: [PATCH 15/15] Search indexes chapter --- docs/en/reference/search-indexes.rst | 188 +++++++++++++++++++++++++++ docs/en/sidebar.rst | 1 + 2 files changed, 189 insertions(+) create mode 100644 docs/en/reference/search-indexes.rst diff --git a/docs/en/reference/search-indexes.rst b/docs/en/reference/search-indexes.rst new file mode 100644 index 0000000000..aad845e170 --- /dev/null +++ b/docs/en/reference/search-indexes.rst @@ -0,0 +1,188 @@ +.. _search_indexes: + +Search Indexes +============== + +In addition to standard :ref:`indexes `, ODM allows you to define +search indexes for use with `MongoDB Atlas Search `__. +Search indexes may be queried using the `$search `__ +and `$searchMeta `__ +aggregation pipeline stages. + +Search indexes have some notable differences from regular +:ref:`indexes ` in ODM. They may only be defined on document classes. +Definitions will not be incorporated from embedded documents. Additionally, ODM +will **NOT** translate field names in search index definitions. Database field +names must be used instead of mapped field names (i.e. PHP property names). + +Search Index Options +-------------------- + +Search indexes are defined using a more complex syntax than regular +:ref:`indexes `. + +ODM supports the following search index options: + +- + ``name`` - Name of the search index to create, which must be unique to the + collection. Defaults to ``"default"``. +- + ``dynamic`` - Enables or disables dynamic field mapping for this index. + If ``true``, the index will include all fields with + `supported data types `__. + If ``false``, the ``fields`` attribute must be specified. Defaults to ``false``. +- + ``fields`` - Associative array of `field mappings `__ + that specify the fields to index (keys). Required only if dynamic mapping is disabled. +- + ``analyzer`` - Specifies the `analyzer `__ + to apply to string fields when indexing. Defaults to the + `standard analyzer `__. +- + ``searchAnalyzer`` - Specifies the `analyzer `__ + to apply to query text before the text is searched. Defaults to the + ``analyzer`` attribute, or the `standard analyzer `__. + if both are unspecified. +- + ``analyzers`` - Array of `custom analyzers `__ + to use in this index. +- + ``storedSource`` - Specifies document fields to store for queries performed + using the `returnedStoredSource `__ + option. Specify ``true`` to store all fields, ``false`` to store no fields, + or a `document `__ + to specify individual fields to include or exclude from storage. Defaults to ``false``. +- + ``synonyms`` - Array of `synonym mapping definitions `__ + to use in this index. + +Additional documentation for defining search indexes may be found in +`search index definition `__ +within the MongoDB manual. + +Static Mapping +-------------- + +`Static mapping `__ +can be used to configure indexing of specific fields within a document. + +The following example demonstrates how to define a search index using static +mapping. + +.. configuration-block:: + + .. code-block:: php + + + + + + + + + + + + + + + + +The ``username`` field will indexed both as a string and for autocompletion. +Since the ``addresses`` field uses an :ref:`embed-many ` +relationship, it must be indexed using the ``embeddedDocuments`` type; however, +embedded documents within the array are permitted to use dynamic mapping. + +Dynamic Mapping +--------------- + +`Dynamic mapping `__ +can be used to automatically index fields with +`supported data types `__ +within a document. Dynamically mapped indexes occupy more disk space than +statically mapped indexes and may be less performant; however, they may be +useful if your schema changes or for when experimenting with Atlas Search + +.. note:: + + Atlas Search does **NOT** dynamically index embedded documents contained + within arrays (e.g. :ref:`embed-many ` relationships). You must + use static mappings with the `embeddedDocument `__ + field type. + +The following example demonstrates how to define a search index using dynamic +mapping: + +.. configuration-block:: + + .. code-block:: php + + + + + + + + + + + diff --git a/docs/en/sidebar.rst b/docs/en/sidebar.rst index 067b3a176e..c89e4178a9 100644 --- a/docs/en/sidebar.rst +++ b/docs/en/sidebar.rst @@ -24,6 +24,7 @@ reference/bidirectional-references reference/complex-references reference/indexes + reference/search-indexes reference/inheritance-mapping reference/embedded-mapping reference/trees