diff --git a/docs/en/cookbook/validation-of-documents.rst b/docs/en/cookbook/validation-of-documents.rst index d26220998..28bfbe513 100644 --- a/docs/en/cookbook/validation-of-documents.rst +++ b/docs/en/cookbook/validation-of-documents.rst @@ -34,6 +34,7 @@ is allowed to: - - + -Now validation is performed whenever you call -``DocumentManager#persist($order)`` or when you call -``DocumentManager#flush()`` and an order is about to be updated. Any -Exception that happens in the lifecycle callbacks will be cached by -the DocumentManager. +Now validation is performed when you call ``DocumentManager#flush()`` and an +order is about to be inserted or updated. Any Exception that happens in the +lifecycle callbacks will stop the flush operation and the exception will be +propagated. + +You might want to use the ``PrePersist`` instead of ``PreFlush`` to validate +the document sooner, when you call ``DocumentManager#persist()``. This way you +can catch validation errors earlier in your application flow. But be aware that +if the document is modified after the ``PrePersist`` event, the validation +might not be triggered again and an invalid document can be persisted. Of course you can do any type of primitive checks, not null, email-validation, string size, integer and date ranges in your @@ -102,8 +106,7 @@ validation callbacks. #[HasLifecycleCallbacks] class Order { - #[PrePersist] - #[PreUpdate] + #[PreFlush] public function validate(): void { if (!($this->plannedShipDate instanceof DateTime)) { @@ -128,11 +131,8 @@ can register multiple methods for validation in "PrePersist" or "PreUpdate" or mix and share them in any combinations between those two events. -There is no limit to what you can and can't validate in -"PrePersist" and "PreUpdate" as long as you don't create new document -instances. This was already discussed in the previous blog post on -the Versionable extension, which requires another type of event -called "onFlush". +There is no limit to what you can validate in ``PreFlush``, ``PrePersist`` and +``PreUpdate`` as long as you don't create new document instances. Further readings: :doc:`Lifecycle Events <../reference/events>` @@ -181,44 +181,44 @@ the ``odm:schema:create`` or ``odm:schema:update`` command. #[ODM\Document] #[ODM\Validation( validator: self::VALIDATOR, - action: ClassMetadata::SCHEMA_VALIDATION_ACTION_WARN, - level: ClassMetadata::SCHEMA_VALIDATION_LEVEL_MODERATE, + action: ClassMetadata::SCHEMA_VALIDATION_ACTION_ERROR, + level: ClassMetadata::SCHEMA_VALIDATION_LEVEL_STRICT, )] class SchemaValidated { - public const VALIDATOR = <<<'EOT' - { - "$jsonSchema": { - "required": ["name"], - "properties": { - "name": { - "bsonType": "string", - "description": "must be a string and is required" - } + private const VALIDATOR = <<<'EOT' + { + "$jsonSchema": { + "required": ["name"], + "properties": { + "name": { + "bsonType": "string", + "description": "must be a string and is required" + } + } + }, + "$or": [ + { "phone": { "$type": "string" } }, + { "email": { "$regularExpression" : { "pattern": "@mongodb\\.com$", "options": "" } } }, + { "status": { "$in": [ "Unknown", "Incomplete" ] } } + ] } - }, - "$or": [ - { "phone": { "$type": "string" } }, - { "email": { "$regularExpression" : { "pattern": "@mongodb\\.com$", "options": "" } } }, - { "status": { "$in": [ "Unknown", "Incomplete" ] } } - ] - } - EOT; + EOT; #[ODM\Id] - private $id; + public string $id; #[ODM\Field(type: 'string')] - private $name; + public string $name; #[ODM\Field(type: 'string')] - private $phone; + public string $phone; #[ODM\Field(type: 'string')] - private $email; + public string $email; #[ODM\Field(type: 'string')] - private $status; + public string $status; } .. code-block:: xml diff --git a/docs/en/reference/attributes-reference.rst b/docs/en/reference/attributes-reference.rst index d9ce78fa7..b6d8a616e 100644 --- a/docs/en/reference/attributes-reference.rst +++ b/docs/en/reference/attributes-reference.rst @@ -1229,7 +1229,7 @@ for the related collection. )] class SchemaValidated { - public const VALIDATOR = <<<'EOT' + private const VALIDATOR = <<<'EOT' { "$jsonSchema": { "required": ["name"], diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 2a4a35f5d..d47bf6dca 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -325,20 +325,51 @@ public const REFERENCE_STORE_AS_REF = 'ref'; /** - * The collection schema validationAction values + * Rejects any insert or update that violates the validation criteria. * - * @see https://docs.mongodb.com/manual/core/schema-validation/#accept-or-reject-invalid-documents + * Value for collection schema validationAction. + * + * @see https://www.mongodb.com/docs/manual/core/schema-validation/handle-invalid-documents/#option-1--reject-invalid-documents */ public const SCHEMA_VALIDATION_ACTION_ERROR = 'error'; - public const SCHEMA_VALIDATION_ACTION_WARN = 'warn'; /** - * The collection schema validationLevel values + * MongoDB allows the operation to proceed, but records the violation in the MongoDB log. + * + * Value for collection schema validationAction. + * + * @see https://www.mongodb.com/docs/manual/core/schema-validation/handle-invalid-documents/#option-2--allow-invalid-documents--but-record-them-in-the-log + */ + public const SCHEMA_VALIDATION_ACTION_WARN = 'warn'; + + /** + * Disable schema validation for the collection. + * + * Value of validationLevel. + * + * @see https://www.mongodb.com/docs/manual/core/schema-validation/specify-validation-level/ + */ + public const SCHEMA_VALIDATION_LEVEL_OFF = 'off'; + + /** + * MongoDB applies the same validation rules to all document inserts and updates. + * + * Value of validationLevel. + * + * @see https://www.mongodb.com/docs/manual/core/schema-validation/specify-validation-level/#steps--use-strict-validation + */ + public const SCHEMA_VALIDATION_LEVEL_STRICT = 'strict'; + + /** + * MongoDB applies the same validation rules to document inserts and updates + * to existing valid documents that match the validation rules. Updates to + * existing documents in the collection that don't match the validation rules + * aren't checked for validity. + * + * Value of validationLevel. * - * @see https://docs.mongodb.com/manual/core/schema-validation/#existing-documents + * @see https://www.mongodb.com/docs/manual/core/schema-validation/specify-validation-level/#steps--use-moderate-validation */ - public const SCHEMA_VALIDATION_LEVEL_OFF = 'off'; - public const SCHEMA_VALIDATION_LEVEL_STRICT = 'strict'; public const SCHEMA_VALIDATION_LEVEL_MODERATE = 'moderate'; /* The inheritance mapping types */ diff --git a/tests/Documentation/Validation/Customer.php b/tests/Documentation/Validation/Customer.php new file mode 100644 index 000000000..a3f8c607b --- /dev/null +++ b/tests/Documentation/Validation/Customer.php @@ -0,0 +1,22 @@ + */ + #[EmbedMany(targetDocument: OrderLine::class)] + public Collection $orderLines = new ArrayCollection(), + ) { + } + + /** @throw CustomerOrderLimitExceededException */ + #[PreFlush] + public function assertCustomerAllowedBuying(): void + { + $orderLimit = $this->customer->orderLimit; + + $amount = 0; + foreach ($this->orderLines as $line) { + $amount += $line->amount; + } + + if ($amount > $orderLimit) { + throw new CustomerOrderLimitExceededException(); + } + } +} diff --git a/tests/Documentation/Validation/OrderLine.php b/tests/Documentation/Validation/OrderLine.php new file mode 100644 index 000000000..8ecafe209 --- /dev/null +++ b/tests/Documentation/Validation/OrderLine.php @@ -0,0 +1,22 @@ +dm->persist($customer); + $this->dm->flush(); + + // Invalid order + $order1 = new Order(customer: $customer); + $order1->orderLines->add(new OrderLine(50)); + $order1->orderLines->add(new OrderLine(60)); + + try { + $this->dm->persist($order1); + $this->dm->flush(); + $this->fail('Expected CustomerOrderLimitExceededException'); + } catch (CustomerOrderLimitExceededException) { + } + + $this->dm->clear(); + $order1 = $this->dm->find(Order::class, $order1->id); + $this->assertNull($order1); + + // Valid order + $customer = new Customer(orderLimit: 100); + $order2 = new Order(customer: $customer); + $order2->orderLines->add(new OrderLine(50)); + $order2->orderLines->add(new OrderLine(40)); + $this->dm->persist($customer); + $this->dm->persist($order2); + $this->dm->flush(); + $this->dm->clear(); + + // Update order to exceed limit + $order2 = $this->dm->find(Order::class, $order2->id); + $order2->orderLines->add(new OrderLine(20)); + + try { + $this->dm->flush(); + $this->fail('Expected CustomerOrderLimitExceededException'); + } catch (CustomerOrderLimitExceededException) { + } + + $this->dm->clear(); + $order2 = $this->dm->find(Order::class, $order2->id); + $this->assertCount(2, $order2->orderLines, 'Order should not have been updated'); + } + + public function testSchemaValidation(): void + { + $this->dm->getSchemaManager()->createDocumentCollection(SchemaValidated::class); + + // Valid document + $document = new SchemaValidated(); + $document->name = 'Jone Doe'; + $document->email = 'jone.doe@example.com'; + $document->phone = '123-456-7890'; + $document->status = 'Unknown'; + + $this->dm->persist($document); + $this->dm->flush(); + $this->dm->clear(); + + // Invalid document + $document = new SchemaValidated(); + $document->email = 'foo'; + $document->status = 'Invalid'; + + $this->dm->persist($document); + + $this->expectException(ServerException::class); + $this->dm->flush(); + } +}