diff --git a/docs/en/cookbook/validation-of-documents.rst b/docs/en/cookbook/validation-of-documents.rst index 789c9d0994..0d43ed8060 100644 --- a/docs/en/cookbook/validation-of-documents.rst +++ b/docs/en/cookbook/validation-of-documents.rst @@ -1,6 +1,9 @@ Validation of Documents ======================= +Validation of Documents - Application Side +------------------------------------------ + .. sectionauthor:: Benjamin Eberlei Doctrine does not ship with any internal validators, the reason @@ -127,4 +130,124 @@ instances. This was already discussed in the previous blog post on the Versionable extension, which requires another type of event called "onFlush". -Further readings: :doc:`Lifecycle Events <../reference/events>` \ No newline at end of file +Further readings: :doc:`Lifecycle Events <../reference/events>` + +Validation of Documents - Database Side +--------------------------------------- + +.. sectionauthor:: Alexandre Abrioux + +.. note:: + + This feature has been introduced in version 2.3.0 + +MongoDB ≥ 3.6 offers the capability to validate documents during +insertions and updates through a schema associated to the collection +(cf. `MongoDB documentation `_). + +Doctrine MongoDB ODM now provides a way to take advantage of this functionality +thanks to the new :doc:`@Validation <../reference/annotations-reference#validation>` +annotation and its properties (also available with XML mapping): + +- + ``validator`` - The schema that will be used to validate documents. + It is a string representing a BSON document under the + `Extended JSON specification `_. +- + ``action`` - The behavior followed by MongoDB to handle documents that + violate the validation rules. +- + ``level`` - The threshold used by MongoDB to filter operations that + will get validated. + +Once defined, those options will be added to the collection after running +the ``odm:schema:create`` or ``odm:schema:update`` command. + +.. configuration-block:: + + .. code-block:: php + + + + + + + { + "$jsonSchema": { + "required": ["name"], + "properties": { + "name": { + "bsonType": "string", + "description": "must be a string and is required" + } + } + }, + "$or": [ + { "phone": { "$type": "string" } }, + { "email": { "$regex": { "$regularExpression" : { "pattern": "@mongodb\\.com$", "options": "" } } } }, + { "status": { "$in": [ "Unknown", "Incomplete" ] } } + ] + } + + + + +Please refer to the :doc:`@Validation <../reference/annotations-reference#document>` annotation reference +for more details on how to use this feature. diff --git a/docs/en/reference/annotations-reference.rst b/docs/en/reference/annotations-reference.rst index 97cabf06a1..650792e762 100644 --- a/docs/en/reference/annotations-reference.rst +++ b/docs/en/reference/annotations-reference.rst @@ -139,7 +139,7 @@ and it does not contain the class name of the persisted document, a @Document --------- -Required annotation to mark a PHP class as a document, whose peristence will be +Required annotation to mark a PHP class as a document, whose persistence will be managed by ODM. Optional attributes: @@ -1092,6 +1092,100 @@ Alias of `@Index`_, with the ``unique`` option set by default. .. _annotations_reference_version: +@Validation +----------- + +This annotation may be used at the class level to specify the validation schema +for the related collection. + +- + ``validator`` - Specifies a schema that will be used by + MongoDB to validate data inserted or updated in the collection. + Please refer to the following + `MongoDB documentation (Schema Validation ¶) `_ + for more details. The value should be a string representing a BSON document under the + `Extended JSON specification `_. + The recommended way to fill up this property is to create a class constant + (eg. ``::VALIDATOR``) using the + `HEREDOC/NOWDOC syntax `_ + for clarity and to reference it as the annotation value. + Please note that if you decide to insert the schema directly in the annotation without + using a class constant then double quotes ``"`` have to be escaped by doubling them ``""``. + This method also requires that you don't prefix multiline strings by the Docblock asterisk symbol ``*``. +- + ``action`` - Determines how MongoDB handles documents that violate + the validation rules. Please refer to the related + `MongoDB documentation (Accept or Reject Invalid Documents ¶) `_ + for more details. The allowed values are the following: + + - ``error`` + - ``warn`` + + If it is not defined then the default behavior (``error``) will be used. + Those values are also declared as constants for convenience: + + - ``\Doctrine\ODM\MongoDB\Mapping\ClassMetadata::SCHEMA_VALIDATION_ACTION_ERROR`` + - ``\Doctrine\ODM\MongoDB\Mapping\ClassMetadata::SCHEMA_VALIDATION_ACTION_WARN`` + + Import the ``ClassMetadata`` namespace to use those constants in your annotation. +- + ``level`` - Determines which operations MongoDB applies the + validation rules. Please refer to the related + `MongoDB documentation (Existing Documents ¶) `_ + for more details. The allowed values are the following: + + - ``off`` + - ``strict`` + - ``moderate`` + + If it is not defined then the default behavior (``strict``) will be used. + Those values are also declared as constants for convenience: + + - ``\Doctrine\ODM\MongoDB\Mapping\ClassMetadata::SCHEMA_VALIDATION_LEVEL_OFF`` + - ``\Doctrine\ODM\MongoDB\Mapping\ClassMetadata::SCHEMA_VALIDATION_LEVEL_STRICT`` + - ``\Doctrine\ODM\MongoDB\Mapping\ClassMetadata::SCHEMA_VALIDATION_LEVEL_MODERATE`` + + Import the ``ClassMetadata`` namespace to use those constants in your annotation. + +.. code-block:: php + + + @@ -514,4 +515,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Validation.php b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Validation.php new file mode 100644 index 0000000000..40820ad5bd --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Annotations/Validation.php @@ -0,0 +1,34 @@ +shardKey !== []; } + /** + * @return array|object|null + */ + public function getValidator() + { + return $this->validator; + } + + /** + * @param array|object|null $validator + */ + public function setValidator($validator): void + { + $this->validator = $validator; + } + + public function getValidationAction(): string + { + return $this->validationAction; + } + + public function setValidationAction(string $validationAction): void + { + $this->validationAction = $validationAction; + } + + public function getValidationLevel(): string + { + return $this->validationLevel; + } + + public function setValidationLevel(string $validationLevel): void + { + $this->validationLevel = $validationLevel; + } + /** * Sets the read preference used by this class. */ @@ -2161,6 +2235,12 @@ public function __sleep() $serialized[] = 'isReadOnly'; } + if ($this->validator !== null) { + $serialized[] = 'validator'; + $serialized[] = 'validationAction'; + $serialized[] = 'validationLevel'; + } + return $serialized; } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AnnotationDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AnnotationDriver.php index 9070c82893..92e5d8c897 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AnnotationDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/AnnotationDriver.php @@ -13,6 +13,7 @@ use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; use Doctrine\Persistence\Mapping\Driver\AnnotationDriver as AbstractAnnotationDriver; +use MongoDB\Driver\Exception\UnexpectedValueException; use ReflectionClass; use ReflectionMethod; @@ -25,6 +26,8 @@ use function get_class; use function interface_exists; use function is_array; +use function MongoDB\BSON\fromJSON; +use function MongoDB\BSON\toPHP; use function trigger_deprecation; /** @@ -99,6 +102,25 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C $metadata->setDefaultDiscriminatorValue($annot->value); } elseif ($annot instanceof ODM\ReadPreference) { $metadata->setReadPreference($annot->value, $annot->tags ?? []); + } elseif ($annot instanceof ODM\Validation) { + if (isset($annot->validator)) { + try { + $validatorBson = fromJSON($annot->validator); + } catch (UnexpectedValueException $e) { + throw MappingException::schemaValidationError($e->getCode(), $e->getMessage(), $className, 'validator'); + } + + $validator = toPHP($validatorBson, []); + $metadata->setValidator($validator); + } + + if (isset($annot->action)) { + $metadata->setValidationAction($annot->action); + } + + if (isset($annot->level)) { + $metadata->setValidationLevel($annot->level); + } } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php index 52fd4b9a87..43bf61a8fe 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php @@ -11,6 +11,7 @@ use DOMDocument; use InvalidArgumentException; use LibXMLError; +use MongoDB\Driver\Exception\UnexpectedValueException; use SimpleXMLElement; use function array_keys; @@ -29,6 +30,8 @@ use function libxml_clear_errors; use function libxml_get_errors; use function libxml_use_internal_errors; +use function MongoDB\BSON\fromJSON; +use function MongoDB\BSON\toPHP; use function next; use function preg_match; use function simplexml_load_file; @@ -195,6 +198,28 @@ public function loadMetadataForClass($className, \Doctrine\Persistence\Mapping\C $this->setShardKey($metadata, $xmlRoot->{'shard-key'}[0]); } + if (isset($xmlRoot->{'schema-validation'})) { + $xmlSchemaValidation = $xmlRoot->{'schema-validation'}; + + if (isset($xmlSchemaValidation['action'])) { + $metadata->setValidationAction((string) $xmlSchemaValidation['action']); + } + + if (isset($xmlSchemaValidation['level'])) { + $metadata->setValidationLevel((string) $xmlSchemaValidation['level']); + } + + $validatorJson = (string) $xmlSchemaValidation; + try { + $validatorBson = fromJSON($validatorJson); + } catch (UnexpectedValueException $e) { + throw MappingException::schemaValidationError($e->getCode(), $e->getMessage(), $className, 'schema-validation'); + } + + $validator = toPHP($validatorBson, []); + $metadata->setValidator($validator); + } + if (isset($xmlRoot['read-only']) && (string) $xmlRoot['read-only'] === 'true') { $metadata->markReadOnly(); } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php index f2b7e375a6..e4b8b08805 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php @@ -271,4 +271,9 @@ public static function viewRootClassNotFound(string $className, string $rootClas { return new self(sprintf('Root class "%s" for view "%s" could not be found.', $rootClass, $className)); } + + public static function schemaValidationError(int $errorCode, string $errorMessage, string $className, string $property): self + { + return new self(sprintf('The following schema validation error occurred while parsing the "%s" property of the "%s" class: "%s" (code %s).', $property, $className, $errorMessage, $errorCode)); + } } diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php index eaea829c83..613f909435 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php @@ -300,6 +300,64 @@ public function deleteDocumentIndexes(string $documentName, ?int $maxTimeMs = nu $this->dm->getDocumentCollection($documentName)->dropIndexes($this->getWriteOptions($maxTimeMs, $writeConcern)); } + /** + * Ensure collection validators are up to date for all mapped document classes. + */ + public function updateValidators(?int $maxTimeMs = null, ?WriteConcern $writeConcern = null): void + { + foreach ($this->metadataFactory->getAllMetadata() as $class) { + assert($class instanceof ClassMetadata); + if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument || $class->isView() || $class->isFile) { + continue; + } + + $this->updateDocumentValidator($class->name, $maxTimeMs, $writeConcern); + } + } + + /** + * Ensure collection validators are up to date for the mapped document class. + */ + public function updateDocumentValidator(string $documentName, ?int $maxTimeMs = null, ?WriteConcern $writeConcern = null): void + { + $class = $this->dm->getClassMetadata($documentName); + if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument || $class->isView() || $class->isFile) { + throw new InvalidArgumentException('Cannot update validators for files, views, mapped super classes, embedded documents or aggregation result documents.'); + } + + $validator = []; + if ($class->getValidator() !== null) { + $validator = $class->getValidator(); + } + + $collection = $this->dm->getDocumentCollection($class->name); + $database = $this->dm->getDocumentDatabase($class->name); + $collections = $database->listCollections(); + $collectionExists = false; + foreach ($collections as $existingCollection) { + if ($collection->getCollectionName() === $existingCollection->getName()) { + $collectionExists = true; + break; + } + } + + if (! $collectionExists) { + $this->createDocumentCollection($documentName, $maxTimeMs, $writeConcern); + + return; + } + + $database->command( + [ + 'collMod' => $class->collection, + 'validator' => $validator, + 'validationAction' => $class->getValidationAction(), + 'validationLevel' => $class->getValidationLevel(), + ], + $this->getWriteOptions($maxTimeMs, $writeConcern) + ); + } + /** * Create all the mapped document collections in the metadata factory. */ @@ -365,6 +423,12 @@ public function createDocumentCollection(string $documentName, ?int $maxTimeMs = 'max' => $class->getCollectionMax(), ]; + if ($class->getValidator() !== null) { + $options['validator'] = $class->getValidator(); + $options['validationAction'] = $class->getValidationAction(); + $options['validationLevel'] = $class->getValidationLevel(); + } + $this->dm->getDocumentDatabase($documentName)->createCollection( $class->getCollection(), $this->getWriteOptions($maxTimeMs, $writeConcern, $options) 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 f4fe992550..418d17c655 100644 --- a/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php +++ b/lib/Doctrine/ODM/MongoDB/Tools/Console/Command/Schema/UpdateCommand.php @@ -41,9 +41,13 @@ protected function execute(InputInterface $input, OutputInterface $output) if (is_string($class)) { $this->processDocumentIndex($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); $output->writeln(sprintf('Updated index(es) for %s', $class)); + $this->processDocumentValidator($sm, $class, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); + $output->writeln(sprintf('Updated validation for %s', $class)); } else { $this->processIndex($sm, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); $output->writeln('Updated indexes for all classes'); + $this->processValidators($sm, $this->getMaxTimeMsFromInput($input), $this->getWriteConcernFromInput($input)); + $output->writeln('Updated validation for all classes'); } } catch (Throwable $e) { $output->writeln('' . $e->getMessage() . ''); @@ -63,6 +67,16 @@ protected function processIndex(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcer $sm->updateIndexes($maxTimeMs, $writeConcern); } + protected function processDocumentValidator(SchemaManager $sm, string $document, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + $sm->updateDocumentValidator($document, $maxTimeMs, $writeConcern); + } + + protected function processValidators(SchemaManager $sm, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + $sm->updateValidators($maxTimeMs, $writeConcern); + } + /** * @throws BadMethodCallException */ diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/ValidationTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ValidationTest.php new file mode 100644 index 0000000000..1f1fdd5d48 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ValidationTest.php @@ -0,0 +1,97 @@ +requireVersion($this->getServerVersion(), '3.6.0', '<', 'MongoDB cannot perform JSON schema validation before version 3.6'); + + // Test creation of SchemaValidated collection + $cm = $this->dm->getClassMetadata(SchemaValidated::class); + $this->dm->getSchemaManager()->createDocumentCollection($cm->name); + $expectedValidatorJson = <<<'EOT' +{ + "$jsonSchema": { + "required": ["name"], + "properties": { + "name": { + "bsonType": "string", + "description": "must be a string and is required" + } + } + }, + "$or": [ + { "phone": { "$type": "string" } }, + { "email": { "$regex": { "$regularExpression" : { "pattern": "@mongodb\\.com$", "options": "" } } } }, + { "status": { "$in": [ "Unknown", "Incomplete" ] } } + ] +} +EOT; + $expectedValidatorBson = fromJSON($expectedValidatorJson); + $expectedValidator = toPHP($expectedValidatorBson, []); + $expectedOptions = [ + 'validator' => $expectedValidator, + 'validationLevel' => ClassMetadata::SCHEMA_VALIDATION_LEVEL_MODERATE, + 'validationAction' => ClassMetadata::SCHEMA_VALIDATION_ACTION_WARN, + ]; + $expectedOptionsBson = fromPHP($expectedOptions); + $expectedOptionsJson = toCanonicalExtendedJSON($expectedOptionsBson); + $collections = $this->dm->getDocumentDatabase($cm->name)->listCollections(); + $assertNb = 0; + foreach ($collections as $collection) { + if ($collection->getName() !== $cm->getCollection()) { + continue; + } + + $assertNb++; + $collectionOptionsBson = fromPHP($collection->getOptions()); + $collectionOptionsJson = toCanonicalExtendedJSON($collectionOptionsBson); + $this->assertJsonStringEqualsJsonString($expectedOptionsJson, $collectionOptionsJson); + } + + $this->assertEquals(1, $assertNb); + + // Test updating the same collection, this time removing the validators and resetting to default options + $cmUpdated = $this->dm->getClassMetadata(SchemaValidatedUpdate::class); + $this->dm->getSchemaManager()->updateDocumentValidator($cmUpdated->name); + // We expect the default values set by MongoDB + // See: https://docs.mongodb.com/manual/reference/command/collMod/#document-validation + $expectedOptions = [ + 'validationLevel' => ClassMetadata::SCHEMA_VALIDATION_LEVEL_STRICT, + 'validationAction' => ClassMetadata::SCHEMA_VALIDATION_ACTION_ERROR, + ]; + $collections = $this->dm->getDocumentDatabase($cmUpdated->name)->listCollections(); + $assertNb = 0; + foreach ($collections as $collection) { + if ($collection->getName() !== $cm->getCollection()) { + continue; + } + + $assertNb++; + $this->assertEquals($expectedOptions, $collection->getOptions()); + } + + $this->assertEquals(1, $assertNb); + } +} + +/** + * @ODM\Document(collection="SchemaValidated") + */ +class SchemaValidatedUpdate extends SchemaValidated +{ +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AnnotationDriverTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AnnotationDriverTest.php index a95f2652fa..8be9e43805 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AnnotationDriverTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AnnotationDriverTest.php @@ -242,6 +242,15 @@ public function provideClassCanBeMappedByOneAbstractDocument() ]; } + public function testWrongValueForValidationValidatorShouldThrowException() + { + $annotationDriver = $this->loadDriver(); + $classMetadata = new ClassMetadata(WrongValueForValidationValidator::class); + $this->expectException(MappingException::class); + $this->expectExceptionMessage('The following schema validation error occurred while parsing the "validator" property of the "Doctrine\ODM\MongoDB\Tests\Mapping\WrongValueForValidationValidator" class: "Got parse error at "w", position 0: "SPECIAL_EXPECTED"" (code 0).'); + $annotationDriver->loadMetadataForClass($classMetadata->name, $classMetadata); + } + protected function loadDriverForCMSDocuments() { $annotationDriver = $this->loadDriver(); @@ -319,3 +328,10 @@ class AnnotationDriverTestWriteConcernUnacknowledged /** @ODM\Id */ public $id; } + +/** @ODM\Validation(validator="wrong") */ +class WrongValueForValidationValidator +{ + /** @ODM\Id */ + public $id; +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Annotations/ValidationTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Annotations/ValidationTest.php new file mode 100644 index 0000000000..b6e3959cad --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Annotations/ValidationTest.php @@ -0,0 +1,72 @@ +expectException(AnnotationException::class); + $this->expectExceptionMessage('[Type Error] Attribute "validator" of @ODM\Validation declared on class Doctrine\ODM\MongoDB\Tests\Mapping\Annotations\WrongTypeForValidationValidator expects a(n) string, but got array.'); + $this->dm->getClassMetadata(WrongTypeForValidationValidator::class); + } + + public function testWrongTypeForValidationActionShouldThrowException() + { + $this->expectException(AnnotationException::class); + $this->expectExceptionMessage('[Type Error] Attribute "action" of @ODM\Validation declared on class Doctrine\ODM\MongoDB\Tests\Mapping\Annotations\WrongTypeForValidationAction expects a(n) string, but got boolean.'); + $this->dm->getClassMetadata(WrongTypeForValidationAction::class); + } + + public function testWrongValueForValidationActionShouldThrowException() + { + $this->expectException(AnnotationException::class); + $this->expectExceptionMessageMatches('#^\[Enum Error\] Attribute "action" of @Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\Annotations\\\\Validation declared on class Doctrine\\\\ODM\\\\MongoDB\\\\Tests\\\\Mapping\\\\Annotations\\\\WrongValueForValidationAction accepts? only \[error, warn\], but got wrong\.$#'); + $this->dm->getClassMetadata(WrongValueForValidationAction::class); + } + + public function testWrongTypeForValidationLevelShouldThrowException() + { + $this->expectException(AnnotationException::class); + $this->expectExceptionMessage('[Type Error] Attribute "level" of @ODM\Validation declared on class Doctrine\ODM\MongoDB\Tests\Mapping\Annotations\WrongTypeForValidationLevel expects a(n) string, but got boolean.'); + $this->dm->getClassMetadata(WrongTypeForValidationLevel::class); + } + + public function testWrongValueForValidationLevelShouldThrowException() + { + $this->expectException(AnnotationException::class); + $this->expectExceptionMessageMatches('#^\[Enum Error\] Attribute "level" of @Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\Annotations\\\\Validation declared on class Doctrine\\\\ODM\\\\MongoDB\\\\Tests\\\\Mapping\\\\Annotations\\\\WrongValueForValidationLevel accepts? only \[off, strict, moderate\], but got wrong\.$#'); + $this->dm->getClassMetadata(WrongValueForValidationLevel::class); + } +} + +/** @ODM\Validation(validator={"wrong"}) */ +class WrongTypeForValidationValidator +{ +} + +/** @ODM\Validation(action=true) */ +class WrongTypeForValidationAction +{ +} + +/** @ODM\Validation(action="wrong") */ +class WrongValueForValidationAction +{ +} + +/** @ODM\Validation(level=true) */ +class WrongTypeForValidationLevel +{ +} + +/** @ODM\Validation(level="wrong") */ +class WrongValueForValidationLevel +{ +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php index 37630ed48e..0969614fbe 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php @@ -28,6 +28,8 @@ use function array_merge; use function get_class; +use function MongoDB\BSON\fromJSON; +use function MongoDB\BSON\toPHP; use function serialize; use function unserialize; @@ -61,6 +63,10 @@ public function testClassMetadataInstanceSerialization() $cm->setLockField('lock'); $cm->setVersioned(true); $cm->setVersionField('version'); + $validatorJson = '{ "$and": [ { "email": { "$regex": { "$regularExpression" : { "pattern": "@mongodb\\\\.com$", "options": "" } } } } ] }'; + $cm->setValidator(toPHP(fromJSON($validatorJson))); + $cm->setValidationAction(ClassMetadata::SCHEMA_VALIDATION_ACTION_WARN); + $cm->setValidationLevel(ClassMetadata::SCHEMA_VALIDATION_LEVEL_OFF); $this->assertIsArray($cm->getFieldMapping('phonenumbers')); $this->assertCount(1, $cm->fieldMappings); $this->assertCount(1, $cm->associationMappings); @@ -90,6 +96,9 @@ public function testClassMetadataInstanceSerialization() $this->assertEquals('lock', $cm->lockField); $this->assertEquals(true, $cm->isVersioned); $this->assertEquals('version', $cm->versionField); + $this->assertEquals(toPHP(fromJSON($validatorJson)), $cm->getValidator()); + $this->assertEquals(ClassMetadata::SCHEMA_VALIDATION_ACTION_WARN, $cm->getValidationAction()); + $this->assertEquals(ClassMetadata::SCHEMA_VALIDATION_LEVEL_OFF, $cm->getValidationLevel()); } public function testOwningSideAndInverseSide() @@ -805,6 +814,24 @@ public function testArbitraryFieldInGridFSFileThrowsException(): void $cm->mapField(['type' => 'string', 'fieldName' => 'contentType']); } + + public function testDefaultValueForValidator() + { + $cm = new ClassMetadata('stdClass'); + $this->assertNull($cm->getValidator()); + } + + public function testDefaultValueForValidationAction() + { + $cm = new ClassMetadata('stdClass'); + $this->assertEquals(ClassMetadata::SCHEMA_VALIDATION_ACTION_ERROR, $cm->getValidationAction()); + } + + public function testDefaultValueForValidationLevel() + { + $cm = new ClassMetadata('stdClass'); + $this->assertEquals(ClassMetadata::SCHEMA_VALIDATION_LEVEL_STRICT, $cm->getValidationLevel()); + } } class TestCustomRepositoryClass extends DocumentRepository diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/XmlDriverTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/XmlDriverTest.php index 4e8cf1611a..60af52b046 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/XmlDriverTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/XmlDriverTest.php @@ -10,9 +10,14 @@ use TestDocuments\AlsoLoadDocument; use TestDocuments\CustomIdGenerator; use TestDocuments\InvalidPartialFilterDocument; +use TestDocuments\SchemaInvalidDocument; +use TestDocuments\SchemaValidatedDocument; use TestDocuments\UserCustomIdGenerator; use TestDocuments\UserNonStringOptions; +use function MongoDB\BSON\fromJSON; +use function MongoDB\BSON\toPHP; + class XmlDriverTest extends AbstractDriverTest { public function setUp(): void @@ -93,6 +98,43 @@ public function testAlsoLoadFieldMapping() 'alsoLoadFields' => ['createdOn', 'creation_date'], ], $classMetadata->fieldMappings['createdAt']); } + + public function testValidationMapping() + { + $classMetadata = new ClassMetadata(SchemaValidatedDocument::class); + $this->driver->loadMetadataForClass($classMetadata->name, $classMetadata); + $this->assertEquals(ClassMetadata::SCHEMA_VALIDATION_ACTION_WARN, $classMetadata->getValidationAction()); + $this->assertEquals(ClassMetadata::SCHEMA_VALIDATION_LEVEL_MODERATE, $classMetadata->getValidationLevel()); + $expectedValidatorJson = <<<'EOT' +{ + "$jsonSchema": { + "required": ["name"], + "properties": { + "name": { + "bsonType": "string", + "description": "must be a string and is required" + } + } + }, + "$or": [ + { "phone": { "$type": "string" } }, + { "email": { "$regex": { "$regularExpression" : { "pattern": "@mongodb\\.com$", "options": "" } } } }, + { "status": { "$in": [ "Unknown", "Incomplete" ] } } + ] +} +EOT; + $expectedValidatorBson = fromJSON($expectedValidatorJson); + $expectedValidator = toPHP($expectedValidatorBson, []); + $this->assertEquals($expectedValidator, $classMetadata->getValidator()); + } + + public function testWrongValueForValidationSchemaShouldThrowException() + { + $classMetadata = new ClassMetadata(SchemaInvalidDocument::class); + $this->expectException(MappingException::class); + $this->expectExceptionMessage('The following schema validation error occurred while parsing the "schema-validation" property of the "TestDocuments\SchemaInvalidDocument" class: "Got parse error at "w", position 13: "SPECIAL_EXPECTED"" (code 0).'); + $this->driver->loadMetadataForClass($classMetadata->name, $classMetadata); + } } namespace TestDocuments; diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/SchemaInvalidDocument.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/SchemaInvalidDocument.php new file mode 100644 index 0000000000..4ea3ebfda9 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/SchemaInvalidDocument.php @@ -0,0 +1,9 @@ + + + + + + + wrong + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/TestDocuments.SchemaValidatedDocument.dcm.xml b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/TestDocuments.SchemaValidatedDocument.dcm.xml new file mode 100644 index 0000000000..a29e53043a --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures/xml/TestDocuments.SchemaValidatedDocument.dcm.xml @@ -0,0 +1,27 @@ + + + + + + { + "$jsonSchema": { + "required": ["name"], + "properties": { + "name": { + "bsonType": "string", + "description": "must be a string and is required" + } + } + }, + "$or": [ + { "phone": { "$type": "string" } }, + { "email": { "$regex": { "$regularExpression" : { "pattern": "@mongodb\\.com$", "options": "" } } } }, + { "status": { "$in": [ "Unknown", "Incomplete" ] } } + ] + } + + + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php index 9c9874524a..0cde7af069 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php @@ -9,21 +9,25 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\SchemaManager; +use Documents\BaseDocument; use Documents\CmsAddress; use Documents\CmsArticle; use Documents\CmsComment; use Documents\CmsProduct; use Documents\Comment; use Documents\File; +use Documents\SchemaValidated; use Documents\Sharded\ShardedOne; use Documents\Sharded\ShardedOneWithDifferentKey; use Documents\SimpleReferenceUser; use Documents\UserName; +use InvalidArgumentException; use MongoDB\Client; use MongoDB\Collection; use MongoDB\Database; use MongoDB\Driver\WriteConcern; use MongoDB\GridFS\Bucket; +use MongoDB\Model\CollectionInfo; use MongoDB\Model\IndexInfo; use MongoDB\Model\IndexInfoIteratorIterator; use PHPUnit\Framework\Constraint\ArrayHasKey; @@ -37,6 +41,8 @@ use function assert; use function class_exists; use function in_array; +use function MongoDB\BSON\fromJSON; +use function MongoDB\BSON\toPHP; class SchemaManagerTest extends BaseTest { @@ -83,7 +89,7 @@ public function setUp(): void if ($cm->isFile) { $this->documentBuckets[$cm->getBucketName()] = $this->getMockBucket(); } else { - $this->documentCollections[$cm->getCollection()] = $this->getMockCollection(); + $this->documentCollections[$cm->getCollection()] = $this->getMockCollection($cm->getCollection()); } $db = $this->getDatabaseName($cm); @@ -370,6 +376,99 @@ public function testDeleteDocumentIndexes(array $expectedWriteOptions, ?int $max $this->schemaManager->deleteDocumentIndexes(CmsArticle::class, $maxTimeMs, $writeConcern); } + public function testUpdateValidators() + { + $dbCommands = []; + foreach ($this->dm->getMetadataFactory()->getAllMetadata() as $cm) { + assert($cm instanceof ClassMetadata); + if ($cm->isMappedSuperclass || $cm->isEmbeddedDocument || $cm->isQueryResultDocument || $cm->isView() || $cm->isFile) { + continue; + } + + $databaseName = $this->getDatabaseName($cm); + $dbCommands[$databaseName] = empty($dbCommands[$databaseName]) ? 1 : $dbCommands[$databaseName] + 1; + } + + foreach ($dbCommands as $databaseName => $nbCommands) { + $this->documentDatabases[$databaseName] + ->expects($this->exactly($nbCommands)) + ->method('command'); + } + + $this->schemaManager->updateValidators(); + } + + /** + * @dataProvider getWriteOptions + */ + public function testUpdateDocumentValidator(array $expectedWriteOptions, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + $class = $this->dm->getClassMetadata(SchemaValidated::class); + $database = $this->documentDatabases[$this->getDatabaseName($class)]; + $expectedValidatorJson = <<<'EOT' +{ + "$jsonSchema": { + "required": ["name"], + "properties": { + "name": { + "bsonType": "string", + "description": "must be a string and is required" + } + } + }, + "$or": [ + { "phone": { "$type": "string" } }, + { "email": { "$regex": { "$regularExpression" : { "pattern": "@mongodb\\.com$", "options": "" } } } }, + { "status": { "$in": [ "Unknown", "Incomplete" ] } } + ] +} +EOT; + $expectedValidatorBson = fromJSON($expectedValidatorJson); + $expectedValidator = toPHP($expectedValidatorBson, []); + $database + ->expects($this->exactly(1)) + ->method('command') + ->with( + [ + 'collMod' => $class->collection, + 'validator' => $expectedValidator, + 'validationAction' => ClassMetadata::SCHEMA_VALIDATION_ACTION_WARN, + 'validationLevel' => ClassMetadata::SCHEMA_VALIDATION_LEVEL_MODERATE, + ], + $expectedWriteOptions + ); + $this->schemaManager->updateDocumentValidator($class->name, $maxTimeMs, $writeConcern); + } + + public function testUpdateDocumentValidatorShouldThrowExceptionForMappedSuperclass() + { + $class = $this->dm->getClassMetadata(BaseDocument::class); + $this->expectException(InvalidArgumentException::class); + $this->schemaManager->updateDocumentValidator($class->name); + } + + /** + * @dataProvider getWriteOptions + */ + public function testUpdateDocumentValidatorReset(array $expectedWriteOptions, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + $class = $this->dm->getClassMetadata(CmsArticle::class); + $database = $this->documentDatabases[$this->getDatabaseName($class)]; + $database + ->expects($this->exactly(1)) + ->method('command') + ->with( + [ + 'collMod' => $class->collection, + 'validator' => [], + 'validationAction' => ClassMetadata::SCHEMA_VALIDATION_ACTION_ERROR, + 'validationLevel' => ClassMetadata::SCHEMA_VALIDATION_LEVEL_STRICT, + ], + $expectedWriteOptions + ); + $this->schemaManager->updateDocumentValidator($class->name, $maxTimeMs, $writeConcern); + } + /** * @dataProvider getWriteOptions */ @@ -414,6 +513,49 @@ public function testCreateDocumentCollectionForFile(array $expectedWriteOptions, $this->schemaManager->createDocumentCollection(File::class, $maxTimeMs, $writeConcern); } + /** + * @dataProvider getWriteOptions + */ + public function testCreateDocumentCollectionWithValidator(array $expectedWriteOptions, ?int $maxTimeMs, ?WriteConcern $writeConcern) + { + $expectedValidatorJson = <<<'EOT' +{ + "$jsonSchema": { + "required": ["name"], + "properties": { + "name": { + "bsonType": "string", + "description": "must be a string and is required" + } + } + }, + "$or": [ + { "phone": { "$type": "string" } }, + { "email": { "$regex": { "$regularExpression" : { "pattern": "@mongodb\\.com$", "options": "" } } } }, + { "status": { "$in": [ "Unknown", "Incomplete" ] } } + ] +} +EOT; + $expectedValidatorBson = fromJSON($expectedValidatorJson); + $expectedValidator = toPHP($expectedValidatorBson, []); + $options = [ + 'capped' => false, + 'size' => null, + 'max' => null, + 'validator' => $expectedValidator, + 'validationAction' => ClassMetadata::SCHEMA_VALIDATION_ACTION_WARN, + 'validationLevel' => ClassMetadata::SCHEMA_VALIDATION_LEVEL_MODERATE, + ]; + $cm = $this->dm->getClassMetadata(SchemaValidated::class); + $database = $this->documentDatabases[$this->getDatabaseName($cm)]; + $database + ->expects($this->once()) + ->method('createCollection') + ->with('SchemaValidated', $options + $expectedWriteOptions); + + $this->schemaManager->createDocumentCollection($cm->name, $maxTimeMs, $writeConcern); + } + /** * @dataProvider getWriteOptions */ @@ -963,9 +1105,14 @@ private function getMockBucket() } /** @return Collection|MockObject */ - private function getMockCollection() + private function getMockCollection(?string $name = null) { - return $this->createMock(Collection::class); + $collection = $this->createMock(Collection::class); + $collection->method('getCollectionName')->willReturnCallback(static function () use ($name) { + return $name; + }); + + return $collection; } /** @return Database|MockObject */ @@ -978,6 +1125,14 @@ private function getMockDatabase() $db->method('selectGridFSBucket')->willReturnCallback(function (array $options) { return $this->documentBuckets[$options['bucketName']]; }); + $db->method('listCollections')->willReturnCallback(function () { + $collections = []; + foreach ($this->documentCollections as $collectionName => $collection) { + $collections[] = new CollectionInfo(['name' => $collectionName]); + } + + return $collections; + }); return $db; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Tools/Console/Command/AbstractCommandTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Tools/Console/Command/AbstractCommandTest.php new file mode 100644 index 0000000000..83ecf29b91 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Tools/Console/Command/AbstractCommandTest.php @@ -0,0 +1,36 @@ + new DocumentManagerHelper($this->dm), + ] + ); + $application = new Application('Doctrine MongoDB ODM'); + $application->setHelperSet($helperSet); + $this->application = $application; + } + + public function tearDown(): void + { + parent::tearDown(); + unset($this->application); + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Tools/Console/Command/Schema/UpdateCommandTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Tools/Console/Command/Schema/UpdateCommandTest.php new file mode 100644 index 0000000000..19d0456cc4 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Tools/Console/Command/Schema/UpdateCommandTest.php @@ -0,0 +1,65 @@ +application->addCommands( + [ + new UpdateCommand(), + ] + ); + $command = $this->application->find('odm:schema:update'); + $commandTester = new CommandTester($command); + + $this->command = $command; + $this->commandTester = $commandTester; + } + + public function tearDown(): void + { + parent::tearDown(); + unset($this->command); + unset($this->commandTester); + } + + public function testProcessValidator() + { + $this->commandTester->execute( + [ + '--class' => SchemaValidated::class, + ] + ); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Updated validation for Documents\SchemaValidated', $output); + } + + public function testProcessValidators() + { + // Only load a subset of documents with legit annotations + $annotationDriver = AnnotationDriver::create(__DIR__ . '/../../../../../../../../Documents/Ecommerce'); + $this->dm->getConfiguration()->setMetadataDriverImpl($annotationDriver); + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Updated validation for all classes', $output); + } +} diff --git a/tests/Documents/SchemaValidated.php b/tests/Documents/SchemaValidated.php new file mode 100644 index 0000000000..ae5b809185 --- /dev/null +++ b/tests/Documents/SchemaValidated.php @@ -0,0 +1,53 @@ +