From 8b542f4c5b9239a3083a375894c4249e922b315d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Sat, 29 Jun 2024 01:13:56 +0200
Subject: [PATCH 01/17] Update custom mapping example (#2654)

The previous example was a simplified copy of the date type. In order to present something more useful, the new example is inspired by MongoDB's codec tutorial.
---
 docs/en/reference/custom-mapping-types.rst    | 68 +++++++++++--------
 .../CustomMapping/CustomMappingTest.php       | 40 +++++++++++
 .../DateTimeWithTimezoneType.php              | 47 +++++++++++++
 tests/Documentation/CustomMapping/Thing.php   | 20 ++++++
 4 files changed, 146 insertions(+), 29 deletions(-)
 create mode 100644 tests/Documentation/CustomMapping/CustomMappingTest.php
 create mode 100644 tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php
 create mode 100644 tests/Documentation/CustomMapping/Thing.php

diff --git a/docs/en/reference/custom-mapping-types.rst b/docs/en/reference/custom-mapping-types.rst
index 339c3bf37..abee5d13b 100644
--- a/docs/en/reference/custom-mapping-types.rst
+++ b/docs/en/reference/custom-mapping-types.rst
@@ -7,8 +7,12 @@ to replace the existing implementation of a mapping type.
 
 In order to create a new mapping type you need to subclass
 ``Doctrine\ODM\MongoDB\Types\Type`` and implement/override
-the methods. Here is an example skeleton of such a custom type
-class:
+the methods.
+
+The following example defines a custom type that stores ``DateTimeInterface``
+instances as an embedded document containing a BSON date and accompanying
+timezone string. Those same embedded documents are then be translated back into
+a ``DateTimeImmutable`` when the data is read from the database.
 
 .. code-block:: php
 
@@ -16,36 +20,45 @@ class:
 
     namespace My\Project\Types;
 
+    use DateTimeImmutable;
+    use DateTimeZone;
     use Doctrine\ODM\MongoDB\Types\ClosureToPHP;
     use Doctrine\ODM\MongoDB\Types\Type;
     use MongoDB\BSON\UTCDateTime;
 
-    /**
-     * My custom datatype.
-     */
-    class MyType extends Type
+    class DateTimeWithTimezoneType extends Type
     {
         // This trait provides default closureToPHP used during data hydration
         use ClosureToPHP;
 
-        public function convertToPHPValue($value): \DateTime
+        public function convertToPHPValue($value): DateTimeImmutable
         {
-            // This is called to convert a Mongo value to a PHP representation
-            return $value->toDateTime();
+            $timeZone = new DateTimeZone($value['tz']);
+            $dateTime = $value['utc']
+                ->toDateTime()
+                ->setTimeZone($timeZone);
+
+            return DateTimeImmutable::createFromMutable($dateTime);
         }
 
-        public function convertToDatabaseValue($value): UTCDateTime
+        public function convertToDatabaseValue($value): array
         {
-            // This is called to convert a PHP value to its Mongo equivalent
-            return new UTCDateTime($value);
+            if (! isset($value['utc'], $value['tz'])) {
+                throw new RuntimeException('Database value cannot be converted to date with timezone. Expected array with "utc" and "tz" keys.');
+            }
+
+            return [
+                'utc' => new UTCDateTime($value),
+                'tz' => $value->getTimezone()->getName(),
+            ];
         }
     }
 
 Restrictions to keep in mind:
 
 -
-   If the value of the field is *NULL* the method
-   ``convertToDatabaseValue()`` is not called.
+   If the value of the field is *NULL* the method ``convertToDatabaseValue()``
+   is not called. You don't need to check for *NULL* values.
 -
    The ``UnitOfWork`` never passes values to the database convert
    method that did not change in the request.
@@ -59,25 +72,20 @@ know about it:
 
     // in bootstrapping code
 
-    // ...
-
     use Doctrine\ODM\MongoDB\Types\Type;
 
-    // ...
-
     // Adds a type. This results in an exception if type with given name is already registered
-    Type::addType('mytype', \My\Project\Types\MyType::class);
+    Type::addType('date_with_timezone', \My\Project\Types\DateTimeWithTimezoneType::class);
 
     // Overrides a type. This results in an exception if type with given name is not registered
-    Type::overrideType('mytype', \My\Project\Types\MyType::class);
+    Type::overrideType('date_immutable', \My\Project\Types\DateTimeWithTimezoneType::class);
 
     // Registers a type without checking whether it was already registered
-    Type::registerType('mytype', \My\Project\Types\MyType::class);
+    Type::registerType('date_immutable', \My\Project\Types\DateTimeWithTimezoneType::class);
 
-As can be seen above, when registering the custom types in the
-configuration you specify a unique name for the mapping type and
-map that to the corresponding |FQCN|. Now you can use your new
-type in your mapping like this:
+As can be seen above, when registering the custom types in the configuration you
+specify a unique name for the mapping type and map that to the corresponding
+|FQCN|. Now you can use your new type in your mapping like this:
 
 .. configuration-block::
 
@@ -85,15 +93,17 @@ type in your mapping like this:
 
         <?php
 
-        class MyPersistentClass
+        use DateTimeImmutable;
+
+        class Thing
         {
-            #[Field(type: 'mytype')]
-            private \DateTime $field;
+            #[Field(type: 'date_with_timezone')]
+            public DateTimeImmutable $date;
         }
 
     .. code-block:: xml
 
-        <field field-name="field" type="mytype" />
+        <field field-name="field" type="date_with_timezone" />
 
 .. |FQCN| raw:: html
   <abbr title="Fully-Qualified Class Name">FQCN</abbr>
diff --git a/tests/Documentation/CustomMapping/CustomMappingTest.php b/tests/Documentation/CustomMapping/CustomMappingTest.php
new file mode 100644
index 000000000..956f9701c
--- /dev/null
+++ b/tests/Documentation/CustomMapping/CustomMappingTest.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\CustomMapping;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
+use Doctrine\ODM\MongoDB\Types\Type;
+
+class CustomMappingTest extends BaseTestCase
+{
+    public function testTest(): void
+    {
+        Type::addType('date_with_timezone', DateTimeWithTimezoneType::class);
+        Type::overrideType('date_immutable', DateTimeWithTimezoneType::class);
+
+        $thing       = new Thing();
+        $thing->date = new DateTimeImmutable('2021-01-01 00:00:00', new DateTimeZone('Africa/Tripoli'));
+
+        $this->dm->persist($thing);
+        $this->dm->flush();
+        $this->dm->clear();
+
+        $result = $this->dm->find(Thing::class, $thing->id);
+        $this->assertEquals($thing->date, $result->date);
+        $this->assertEquals('Africa/Tripoli', $result->date->getTimezone()->getName());
+
+        // Ensure we don't need to handle null values
+        $nothing = new Thing();
+
+        $this->dm->persist($nothing);
+        $this->dm->flush();
+        $this->dm->clear();
+
+        $result = $this->dm->find(Thing::class, $nothing->id);
+        $this->assertNull($result->date);
+    }
+}
diff --git a/tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php b/tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php
new file mode 100644
index 000000000..39e2395a0
--- /dev/null
+++ b/tests/Documentation/CustomMapping/DateTimeWithTimezoneType.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\CustomMapping;
+
+use DateTimeImmutable;
+use DateTimeInterface;
+use DateTimeZone;
+use Doctrine\ODM\MongoDB\Types\ClosureToPHP;
+use Doctrine\ODM\MongoDB\Types\Type;
+use MongoDB\BSON\UTCDateTime;
+use RuntimeException;
+
+class DateTimeWithTimezoneType extends Type
+{
+    // This trait provides default closureToPHP used during data hydration
+    use ClosureToPHP;
+
+    /** @param array{utc: UTCDateTime, tz: string} $value */
+    public function convertToPHPValue($value): DateTimeImmutable
+    {
+        if (! isset($value['utc'], $value['tz'])) {
+            throw new RuntimeException('Database value cannot be converted to date with timezone. Expected array with "utc" and "tz" keys.');
+        }
+
+        $timeZone = new DateTimeZone($value['tz']);
+        $dateTime = $value['utc']
+            ->toDateTime()
+            ->setTimeZone($timeZone);
+
+        return DateTimeImmutable::createFromMutable($dateTime);
+    }
+
+    /**
+     * @param DateTimeInterface $value
+     *
+     * @return array{utc: UTCDateTime, tz: string}
+     */
+    public function convertToDatabaseValue($value): array
+    {
+        return [
+            'utc' => new UTCDateTime($value),
+            'tz' => $value->getTimezone()->getName(),
+        ];
+    }
+}
diff --git a/tests/Documentation/CustomMapping/Thing.php b/tests/Documentation/CustomMapping/Thing.php
new file mode 100644
index 000000000..982ac0396
--- /dev/null
+++ b/tests/Documentation/CustomMapping/Thing.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\CustomMapping;
+
+use DateTimeImmutable;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+
+#[Document]
+class Thing
+{
+    #[Id]
+    public string $id;
+
+    #[Field(type: 'date_with_timezone')]
+    public ?DateTimeImmutable $date = null;
+}

From af0f6e3367c5756b5fc21f94543f8c9891db30de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Mon, 1 Jul 2024 10:41:35 +0200
Subject: [PATCH 02/17] doc: Review and test validation cookbook (#2662)

* Review and test validation cookbook
* Fix @throws annotation for DM flush
* Field type is read from property's type
---
 docs/en/cookbook/validation-of-documents.rst  | 88 +++++++++----------
 docs/en/reference/attributes-reference.rst    |  2 +-
 lib/Doctrine/ODM/MongoDB/DocumentManager.php  |  1 +
 .../ODM/MongoDB/Mapping/ClassMetadata.php     | 45 ++++++++--
 tests/Documentation/Validation/Customer.php   | 22 +++++
 .../CustomerOrderLimitExceededException.php   | 11 +++
 tests/Documentation/Validation/Order.php      | 47 ++++++++++
 tests/Documentation/Validation/OrderLine.php  | 22 +++++
 .../Validation/SchemaValidated.php            | 51 +++++++++++
 .../Validation/ValidationTest.php             | 87 ++++++++++++++++++
 10 files changed, 324 insertions(+), 52 deletions(-)
 create mode 100644 tests/Documentation/Validation/Customer.php
 create mode 100644 tests/Documentation/Validation/CustomerOrderLimitExceededException.php
 create mode 100644 tests/Documentation/Validation/Order.php
 create mode 100644 tests/Documentation/Validation/OrderLine.php
 create mode 100644 tests/Documentation/Validation/SchemaValidated.php
 create mode 100644 tests/Documentation/Validation/ValidationTest.php

diff --git a/docs/en/cookbook/validation-of-documents.rst b/docs/en/cookbook/validation-of-documents.rst
index d26220998..397788893 100644
--- a/docs/en/cookbook/validation-of-documents.rst
+++ b/docs/en/cookbook/validation-of-documents.rst
@@ -34,6 +34,7 @@ is allowed to:
 
     <?php
 
+    #[Document]
     class Order
     {
         public function assertCustomerAllowedBuying(): void
@@ -68,8 +69,7 @@ First Attributes:
         #[HasLifecycleCallbacks]
         class Order
         {
-            #[PrePersist]
-            #[PreUpdate]
+            #[PreFlush]
             public function assertCustomerAllowedBuying(): void {}
         }
 
@@ -78,17 +78,21 @@ First Attributes:
         <doctrine-mapping>
             <document name="Order">
                 <lifecycle-callbacks>
-                    <lifecycle-callback type="prePersist" method="assertCustomerallowedBuying" />
-                    <lifecycle-callback type="preUpdate" method="assertCustomerallowedBuying" />
+                    <lifecycle-callback type="preFlush" method="assertCustomerAllowedBuying" />
                 </lifecycle-callbacks>
             </document>
         </doctrine-mapping>
 
-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 ``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. 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;
+            #[ODM\Field]
+            public string $name;
 
-            #[ODM\Field(type: 'string')]
-            private $phone;
+            #[ODM\Field]
+            public string $phone;
 
-            #[ODM\Field(type: 'string')]
-            private $email;
+            #[ODM\Field]
+            public string $email;
 
-            #[ODM\Field(type: 'string')]
-            private $status;
+            #[ODM\Field]
+            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/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php
index b98f0dc3e..706b23ed8 100644
--- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php
+++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php
@@ -575,6 +575,7 @@ public function getRepository($className)
      * @psalm-param CommitOptions $options
      *
      * @throws MongoDBException
+     * @throws Throwable From event listeners.
      */
     public function flush(array $options = [])
     {
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..83a233583
--- /dev/null
+++ b/tests/Documentation/Validation/Customer.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Validation;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+
+#[Document]
+class Customer
+{
+    #[Id]
+    public string $id;
+
+    public function __construct(
+        #[Field]
+        public float $orderLimit,
+    ) {
+    }
+}
diff --git a/tests/Documentation/Validation/CustomerOrderLimitExceededException.php b/tests/Documentation/Validation/CustomerOrderLimitExceededException.php
new file mode 100644
index 000000000..fef9401bd
--- /dev/null
+++ b/tests/Documentation/Validation/CustomerOrderLimitExceededException.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Validation;
+
+use RuntimeException;
+
+class CustomerOrderLimitExceededException extends RuntimeException
+{
+}
diff --git a/tests/Documentation/Validation/Order.php b/tests/Documentation/Validation/Order.php
new file mode 100644
index 000000000..aff4c0eb9
--- /dev/null
+++ b/tests/Documentation/Validation/Order.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Validation;
+
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\EmbedMany;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\HasLifecycleCallbacks;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\PreFlush;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\ReferenceOne;
+
+#[Document]
+#[HasLifecycleCallbacks]
+class Order
+{
+    #[Id]
+    public string $id;
+
+    public function __construct(
+        #[ReferenceOne(targetDocument: Customer::class)]
+        public Customer $customer,
+        /** @var Collection<OrderLine> */
+        #[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..c554d8166
--- /dev/null
+++ b/tests/Documentation/Validation/OrderLine.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Validation;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations\EmbeddedDocument;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+
+#[EmbeddedDocument]
+class OrderLine
+{
+    #[Id]
+    public string $id;
+
+    public function __construct(
+        #[Field]
+        public float $amount,
+    ) {
+    }
+}
diff --git a/tests/Documentation/Validation/SchemaValidated.php b/tests/Documentation/Validation/SchemaValidated.php
new file mode 100644
index 000000000..0640f91da
--- /dev/null
+++ b/tests/Documentation/Validation/SchemaValidated.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Validation;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
+
+#[ODM\Document]
+#[ODM\Validation(
+    validator: self::VALIDATOR,
+    action: ClassMetadata::SCHEMA_VALIDATION_ACTION_ERROR,
+    level: ClassMetadata::SCHEMA_VALIDATION_LEVEL_STRICT,
+)]
+class SchemaValidated
+{
+    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" ] } }
+            ]
+        }
+        EOT;
+
+    #[ODM\Id]
+    public string $id;
+
+    #[ODM\Field]
+    public string $name;
+
+    #[ODM\Field]
+    public string $phone;
+
+    #[ODM\Field]
+    public string $email;
+
+    #[ODM\Field]
+    public string $status;
+}
diff --git a/tests/Documentation/Validation/ValidationTest.php b/tests/Documentation/Validation/ValidationTest.php
new file mode 100644
index 000000000..5812bbc71
--- /dev/null
+++ b/tests/Documentation/Validation/ValidationTest.php
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Validation;
+
+use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
+use MongoDB\Driver\Exception\ServerException;
+
+class ValidationTest extends BaseTestCase
+{
+    public function testLifecycleValidation(): void
+    {
+        $customer = new Customer(orderLimit: 100);
+        $this->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) {
+            // Expected
+            $this->dm->clear();
+        }
+
+        // Order should not have been saved
+        $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) {
+            // Expected
+            $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();
+    }
+}

From 571957330191d590c77dea82ec5a971b73e887e9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Mon, 1 Jul 2024 15:41:50 +0200
Subject: [PATCH 03/17] doc: Review cookbook on blending ORM and ODM (#2656)

* Review cookbook on blending ORM and ODM
* ORM test requires pdo_sqlite
* Rewrite introduction
---
 composer.json                                 |  1 +
 .../cookbook/blending-orm-and-mongodb-odm.rst | 86 +++++++++----------
 .../BlendingOrmEventSubscriber.php            | 34 ++++++++
 .../BlendingOrm/BlendingOrmTest.php           | 63 ++++++++++++++
 tests/Documentation/BlendingOrm/Order.php     | 42 +++++++++
 tests/Documentation/BlendingOrm/Product.php   | 19 ++++
 6 files changed, 198 insertions(+), 47 deletions(-)
 create mode 100644 tests/Documentation/BlendingOrm/BlendingOrmEventSubscriber.php
 create mode 100644 tests/Documentation/BlendingOrm/BlendingOrmTest.php
 create mode 100644 tests/Documentation/BlendingOrm/Order.php
 create mode 100644 tests/Documentation/BlendingOrm/Product.php

diff --git a/composer.json b/composer.json
index c937390cf..fdcf7c9a2 100644
--- a/composer.json
+++ b/composer.json
@@ -40,6 +40,7 @@
         "ext-bcmath": "*",
         "doctrine/annotations": "^1.12 || ^2.0",
         "doctrine/coding-standard": "^12.0",
+        "doctrine/orm": "^3.2",
         "jmikola/geojson": "^1.0",
         "phpbench/phpbench": "^1.0.0",
         "phpstan/phpstan": "~1.10.67",
diff --git a/docs/en/cookbook/blending-orm-and-mongodb-odm.rst b/docs/en/cookbook/blending-orm-and-mongodb-odm.rst
index b748f44c2..8eb0d3fcd 100644
--- a/docs/en/cookbook/blending-orm-and-mongodb-odm.rst
+++ b/docs/en/cookbook/blending-orm-and-mongodb-odm.rst
@@ -1,9 +1,12 @@
 Blending the ORM and MongoDB ODM
 ================================
 
-Since the start of the `Doctrine MongoDB Object Document Mapper`_ project people have asked how it can be integrated with the `ORM`_. This article will demonstrates how you can integrate the two transparently, maintaining a clean domain model.
+This article demonstrates how you can integrate the `Doctrine MongoDB ODM`_
+and the `ORM`_ transparently, maintaining a clean domain model. This is
+something that is supported indirectly by the libraries by using the events.
 
-This example will have a ``Product`` that is stored in MongoDB and the ``Order`` stored in a MySQL database.
+This example will have a ``Product`` that is stored in MongoDB and the ``Order``
+stored in a SQL database like MySQL, PostgreSQL or SQLite.
 
 Define Product
 --------------
@@ -20,31 +23,17 @@ First lets define our ``Product`` document:
     class Product
     {
         #[Id]
-        private $id;
+        public string $id;
 
         #[Field(type: 'string')]
-        private $title;
-
-        public function getId(): ?string
-        {
-            return $this->id;
-        }
-
-        public function getTitle(): ?string
-        {
-            return $this->title;
-        }
-
-        public function setTitle(string $title): void
-        {
-            $this->title = $title;
-        }
+        public string $title;
     }
 
 Define Entity
 -------------
 
-Next create the ``Order`` entity that has a ``$product`` and ``$productId`` property linking it to the ``Product`` that is stored with MongoDB:
+Next create the ``Order`` entity that has a ``$product`` and ``$productId``
+property linking it to the ``Product`` that is stored with MongoDB:
 
 .. code-block:: php
 
@@ -61,15 +50,12 @@ Next create the ``Order`` entity that has a ``$product`` and ``$productId`` prop
         #[Id]
         #[Column(type: 'int')]
         #[GeneratedValue(strategy: 'AUTO')]
-        private $id;
+        public int $id;
 
         #[Column(type: 'string')]
-        private $productId;
+        private string $productId;
 
-        /**
-         * @var Documents\Product
-         */
-        private $product;
+        private Product $product;
 
         public function getId(): ?int
         {
@@ -96,7 +82,10 @@ Next create the ``Order`` entity that has a ``$product`` and ``$productId`` prop
 Event Subscriber
 ----------------
 
-Now we need to setup an event subscriber that will set the ``$product`` property of all ``Order`` instances to a reference to the document product so it can be lazily loaded when it is accessed the first time. So first register a new event subscriber:
+Now we need to setup an event subscriber that will set the ``$product`` property
+of all ``Order`` instances to a reference to the document product so it can be
+lazily loaded when it is accessed the first time. So first register a new event
+subscriber:
 
 .. code-block:: php
 
@@ -107,15 +96,17 @@ Now we need to setup an event subscriber that will set the ``$product`` property
         [\Doctrine\ORM\Events::postLoad], new MyEventSubscriber($dm)
     );
 
-or in .yaml
+or in YAML configuration of the Symfony container:
 
 .. code-block:: yaml    
     
     App\Listeners\MyEventSubscriber:
         tags:
-            - { name: doctrine.event_listener, connection: default, event: postLoad}
+            - { name: doctrine.event_listener, connection: default, event: postLoad }
 
-So now we need to define a class named ``MyEventSubscriber`` and pass ``DocumentManager`` as a dependency. It will have a ``postLoad()`` method that sets the product document reference:
+So now we need to define a class named ``MyEventSubscriber`` and pass
+``DocumentManager`` as a dependency. It will have a ``postLoad()`` method that
+sets the product document reference:
 
 .. code-block:: php
 
@@ -126,10 +117,9 @@ So now we need to define a class named ``MyEventSubscriber`` and pass ``Document
 
     class MyEventSubscriber
     {
-        public function __construct(DocumentManager $dm)
-        {
-            $this->dm = $dm;
-        }
+        public function __construct(
+            private readonly DocumentManager $dm,
+        ) {}
 
         public function postLoad(LifecycleEventArgs $eventArgs): void
         {
@@ -139,13 +129,13 @@ So now we need to define a class named ``MyEventSubscriber`` and pass ``Document
                 return;
             }
 
-            $em = $eventArgs->getEntityManager();
-            $productReflProp = $em->getClassMetadata(Order::class)
-                ->reflClass->getProperty('product');
-            $productReflProp->setAccessible(true);
-            $productReflProp->setValue(
-                $order, $this->dm->getReference(Product::class, $order->getProductId())
-            );
+            $product = $this->dm->getReference(Product::class, $order->getProductId());
+
+            $eventArgs->getObjectManager()
+                ->getClassMetadata(Order::class)
+                ->reflClass
+                ->getProperty('product')
+                ->setValue($order, $product);
         }
     }
 
@@ -165,7 +155,7 @@ First create a new ``Product``:
     <?php
 
     $product = new \Documents\Product();
-    $product->setTitle('Test Product');
+    $product->title = 'Test Product';
     $dm->persist($product);
     $dm->flush();
 
@@ -180,19 +170,21 @@ Now create a new ``Order`` and link it to a ``Product`` in MySQL:
     $em->persist($order);
     $em->flush();
 
-Later we can retrieve the entity and lazily load the reference to the document in MongoDB:
+Later we can retrieve the entity and lazily load the reference to the document
+in MongoDB:
 
 .. code-block:: php
 
     <?php
 
-    $order = $em->find(Order::class, $order->getId());
+    $order = $em->find(Order::class, $order->id);
 
     $product = $order->getProduct();
 
-    echo "Order Title: " . $product->getTitle();
+    echo "Order Title: " . $product->title;
 
-If you were to print the ``$order`` you would see that we got back regular PHP objects:
+If you were to print the ``$order`` you would see that we got back regular PHP
+objects:
 
 .. code-block:: php
 
@@ -216,5 +208,5 @@ The above would output the following:
             )
     )
 
-.. _Doctrine MongoDB Object Document Mapper: http://www.doctrine-project.org/projects/mongodb_odm
+.. _Doctrine MongoDB ODM: http://www.doctrine-project.org/projects/mongodb_odm
 .. _ORM: http://www.doctrine-project.org/projects/orm
diff --git a/tests/Documentation/BlendingOrm/BlendingOrmEventSubscriber.php b/tests/Documentation/BlendingOrm/BlendingOrmEventSubscriber.php
new file mode 100644
index 000000000..f4ac3d821
--- /dev/null
+++ b/tests/Documentation/BlendingOrm/BlendingOrmEventSubscriber.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\BlendingOrm;
+
+use Doctrine\ODM\MongoDB\DocumentManager;
+use Doctrine\ORM\Event\PostLoadEventArgs;
+
+class BlendingOrmEventSubscriber
+{
+    public function __construct(
+        private readonly DocumentManager $dm,
+    ) {
+    }
+
+    public function postLoad(PostLoadEventArgs $eventArgs): void
+    {
+        $order = $eventArgs->getObject();
+
+        if (! $order instanceof Order) {
+            return;
+        }
+
+        // Reference to the Product document, without loading it
+        $product = $this->dm->getReference(Product::class, $order->getProductId());
+
+        $eventArgs->getObjectManager()
+            ->getClassMetadata(Order::class)
+            ->reflClass
+            ->getProperty('product')
+            ->setValue($order, $product);
+    }
+}
diff --git a/tests/Documentation/BlendingOrm/BlendingOrmTest.php b/tests/Documentation/BlendingOrm/BlendingOrmTest.php
new file mode 100644
index 000000000..61e50e4b6
--- /dev/null
+++ b/tests/Documentation/BlendingOrm/BlendingOrmTest.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\BlendingOrm;
+
+use Doctrine\DBAL\DriverManager;
+use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
+use Doctrine\ORM\EntityManager;
+use Doctrine\ORM\Events;
+use Doctrine\ORM\ORMSetup;
+use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+
+#[RequiresPhpExtension('pdo_sqlite')]
+class BlendingOrmTest extends BaseTestCase
+{
+    public function testTest(): void
+    {
+        $dm = $this->dm;
+
+        // Init ORM
+        $config     = ORMSetup::createAttributeMetadataConfiguration(
+            paths: [__DIR__],
+            isDevMode: true,
+        );
+        $connection = DriverManager::getConnection([
+            'driver' => 'pdo_sqlite',
+            'path' => ':memory:',
+        ], $config);
+        $connection->executeQuery('CREATE TABLE orders (id INTEGER PRIMARY KEY, productId TEXT)');
+        $em = new EntityManager($connection, $config);
+        $em->getEventManager()
+            ->addEventListener(
+                [Events::postLoad],
+                new BlendingOrmEventSubscriber($dm),
+            );
+
+        // Init Product document and Order entity
+        $product        = new Product();
+        $product->title = 'Test Product';
+        $dm->persist($product);
+        $dm->flush();
+
+        $order = new Order();
+        $order->setProduct($product);
+        $em->persist($order);
+        $em->flush();
+        $em->clear();
+
+        $order = $em->find(Order::class, $order->id);
+        // The Product document is loaded from the DocumentManager
+        $this->assertSame($product, $order->getProduct());
+
+        $em->clear();
+        $dm->clear();
+
+        $order = $em->find(Order::class, $order->id);
+        // New Product instance, not the same as the one in the DocumentManager
+        $this->assertNotSame($product, $order->getProduct());
+        $this->assertSame($product->id, $order->getProduct()->id);
+        $this->assertSame($product->title, $order->getProduct()->title);
+    }
+}
diff --git a/tests/Documentation/BlendingOrm/Order.php b/tests/Documentation/BlendingOrm/Order.php
new file mode 100644
index 000000000..5df5e6d0c
--- /dev/null
+++ b/tests/Documentation/BlendingOrm/Order.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\BlendingOrm;
+
+use Doctrine\ORM\Mapping\Column;
+use Doctrine\ORM\Mapping\Entity;
+use Doctrine\ORM\Mapping\GeneratedValue;
+use Doctrine\ORM\Mapping\Id;
+use Doctrine\ORM\Mapping\Table;
+
+#[Entity]
+#[Table(name: 'orders')]
+class Order
+{
+    #[Id]
+    #[Column(type: 'integer')]
+    #[GeneratedValue(strategy: 'AUTO')]
+    public int $id;
+
+    #[Column(type: 'string')]
+    private string $productId;
+
+    private Product $product;
+
+    public function getProductId(): ?string
+    {
+        return $this->productId;
+    }
+
+    public function setProduct(Product $product): void
+    {
+        $this->productId = $product->id;
+        $this->product   = $product;
+    }
+
+    public function getProduct(): ?Product
+    {
+        return $this->product;
+    }
+}
diff --git a/tests/Documentation/BlendingOrm/Product.php b/tests/Documentation/BlendingOrm/Product.php
new file mode 100644
index 000000000..1e82fe70f
--- /dev/null
+++ b/tests/Documentation/BlendingOrm/Product.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\BlendingOrm;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+
+#[Document]
+class Product
+{
+    #[Id]
+    public string $id;
+
+    #[Field]
+    public string $title;
+}

From 43a70e86b2a472be1cd19bf91d206af2bab2d300 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Mon, 1 Jul 2024 15:44:50 +0200
Subject: [PATCH 04/17] doc: Review mapping ORM and ODM cookbook (#2658)

* Review mapping ORM and ODM cookbook
* Add tests on mapping ORM and ODM
* ORM test requires pdo_sqlite
---
 .../mapping-classes-to-orm-and-odm.rst        | 85 +++++++++++--------
 .../MappingOrmAndOdm/BlogPost.php             | 28 ++++++
 .../BlogPostRepositoryInterface.php           | 10 +++
 .../MappingOrmAndOdm/MappingOrmAndOdmTest.php | 75 ++++++++++++++++
 .../OdmBlogPostRepository.php                 | 15 ++++
 .../OrmBlogPostRepository.php                 | 15 ++++
 6 files changed, 192 insertions(+), 36 deletions(-)
 create mode 100644 tests/Documentation/MappingOrmAndOdm/BlogPost.php
 create mode 100644 tests/Documentation/MappingOrmAndOdm/BlogPostRepositoryInterface.php
 create mode 100644 tests/Documentation/MappingOrmAndOdm/MappingOrmAndOdmTest.php
 create mode 100644 tests/Documentation/MappingOrmAndOdm/OdmBlogPostRepository.php
 create mode 100644 tests/Documentation/MappingOrmAndOdm/OrmBlogPostRepository.php

diff --git a/docs/en/cookbook/mapping-classes-to-orm-and-odm.rst b/docs/en/cookbook/mapping-classes-to-orm-and-odm.rst
index fbf540c73..4d6fd9088 100644
--- a/docs/en/cookbook/mapping-classes-to-orm-and-odm.rst
+++ b/docs/en/cookbook/mapping-classes-to-orm-and-odm.rst
@@ -1,9 +1,9 @@
 Mapping Classes to the ORM and ODM
 ==================================
 
-Because of the non-intrusive design of Doctrine, it is possible for you to have plain PHP classes
-that are mapped to both a relational database (with the Doctrine2 Object Relational Mapper) and
-MongoDB (with the Doctrine MongoDB Object Document Mapper), or any other persistence layer that
+Because of the non-intrusive design of Doctrine, it is possible to map PHP
+classes to both a relational database (with the Doctrine ORM) and
+MongoDB (with the Doctrine MongoDB ODM), or any other persistence layer that
 implements the Doctrine Persistence `persistence`_ interfaces.
 
 Test Subject
@@ -21,18 +21,16 @@ for multiple Doctrine persistence layers:
 
     class BlogPost
     {
-        private $id;
-        private $title;
-        private $body;
-
-        // ...
+        public int $id;
+        public string $title;
+        public string $body;
     }
 
 Mapping Information
 -------------------
 
-Now we just need to provide the mapping information for the Doctrine persistence layers so they know
-how to consume the objects and persist them to the database.
+Now we just need to provide the mapping information for the Doctrine persistence
+layers so they know how to consume the objects and persist them to the database.
 
 ORM
 ~~~
@@ -48,21 +46,21 @@ First define the mapping for the ORM:
         namespace Documents\Blog;
 
         use Documents\Blog\Repository\ORM\BlogPostRepository;
+        use Doctrine\ORM\Mapping as ORM;
 
-        #[Entity(repositoryClass: BlogPostRepository::class)]
+        #[ORM\Entity(repositoryClass: BlogPostRepository::class)]
         class BlogPost
         {
-            #[Id]
-            #[Column(type: 'int')]
-            private $id;
-
-            #[Column(type: 'string')]
-            private $title;
+            #[ORM\Id]
+            #[ORM\Column(type: 'integer')]
+            #[ORM\GeneratedValue(strategy: 'AUTO')]
+            public int $id;
 
-            #[Column(type: 'text')]
-            private $body;
+            #[ORM\Column(type: 'string')]
+            public string $title;
 
-            // ...
+            #[ORM\Column(type: 'text')]
+            public string $body;
         }
 
     .. code-block:: xml
@@ -80,14 +78,15 @@ First define the mapping for the ORM:
             </entity>
         </doctrine-mapping>
 
-Now you are able to persist the ``Documents\Blog\BlogPost`` with an instance of ``EntityManager``:
+Now you are able to persist the ``Documents\Blog\BlogPost`` with an instance of
+``EntityManager``:
 
 .. code-block:: php
 
     <?php
 
     $blogPost = new BlogPost();
-    $blogPost->setTitle('test');
+    $blogPost->title = 'Hello World!';
 
     $em->persist($blogPost);
     $em->flush();
@@ -98,7 +97,7 @@ You can find the blog post:
 
     <?php
 
-    $blogPost = $em->getRepository(BlogPost::class)->findOneBy(array('title' => 'test'));
+    $blogPost = $em->getRepository(BlogPost::class)->findOneBy(['title' => 'Hello World!']);
 
 MongoDB ODM
 ~~~~~~~~~~~
@@ -114,20 +113,19 @@ Now map the same class to the Doctrine MongoDB ODM:
         namespace Documents\Blog;
 
         use Documents\Blog\Repository\ODM\BlogPostRepository;
+        use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
 
-        #[Document(repositoryClass: BlogPostRepository::class)]
+        #[ODM\Document(repositoryClass: BlogPostRepository::class)]
         class BlogPost
         {
-            #[Id]
-            private $id;
+            #[ODM\Id(type: 'int', strategy: 'INCREMENT')]
+            public int $id;
 
-            #[Field(type: 'string')]
-            private $title;
+            #[ODM\Field]
+            public string $title;
 
-            #[Field(type: 'string')]
-            private $body;
-
-            // ...
+            #[ODM\Field]
+            public string $body;
         }
 
     .. code-block:: xml
@@ -145,14 +143,21 @@ Now map the same class to the Doctrine MongoDB ODM:
             </document>
         </doctrine-mongo-mapping>
 
-Now the same class is able to be persisted in the same way using an instance of ``DocumentManager``:
+.. note::
+
+    We use the ``INCREMENT`` strategy for the MongoDB ODM for compatibility with
+    the ORM mapping. But you can also use the default ``AUTO`` strategy
+    and store a generated MongoDB ObjectId as a string in the SQL database.
+
+Now the same class is able to be persisted in the same way using an instance of
+``DocumentManager``:
 
 .. code-block:: php
 
     <?php
 
     $blogPost = new BlogPost();
-    $blogPost->setTitle('test');
+    $blogPost->title = 'Hello World!';
 
     $dm->persist($blogPost);
     $dm->flush();
@@ -163,12 +168,13 @@ You can find the blog post:
 
     <?php
 
-    $blogPost = $dm->getRepository(BlogPost::class)->findOneBy(array('title' => 'test'));
+    $blogPost = $dm->getRepository(BlogPost::class)->findOneBy(['title' => 'Hello World!']);
 
 Repository Classes
 ------------------
 
-You can implement the same repository interface for the ORM and MongoDB ODM easily, e.g. by creating ``BlogPostRepositoryInterface``:
+You can implement the same repository interface for the ORM and MongoDB ODM
+easily, e.g. by creating ``BlogPostRepositoryInterface``:
 
 .. code-block:: php
 
@@ -227,4 +233,11 @@ PHP objects. The data is transparently injected to the objects for you automatic
 are not forced to extend some base class or shape your domain in any certain way for it to work
 with the Doctrine persistence layers.
 
+.. note::
+
+    If the same class is mapped to both the ORM and ODM, and you persist the
+    instance in both, you will have two separate instances in memory. This is
+    because the ORM and ODM are separate libraries and do not share the same
+    object manager.
+
 .. _persistence: https://github.com/doctrine/persistence
diff --git a/tests/Documentation/MappingOrmAndOdm/BlogPost.php b/tests/Documentation/MappingOrmAndOdm/BlogPost.php
new file mode 100644
index 000000000..67d00ecff
--- /dev/null
+++ b/tests/Documentation/MappingOrmAndOdm/BlogPost.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\MappingOrmAndOdm;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+use Doctrine\ORM\Mapping as ORM;
+
+#[ORM\Entity(repositoryClass: OrmBlogPostRepository::class)]
+#[ORM\Table(name: 'blog_posts')]
+#[ODM\Document(repositoryClass: OdmBlogPostRepository::class)]
+class BlogPost
+{
+    #[ORM\Id]
+    #[ORM\Column(type: 'integer')]
+    #[ORM\GeneratedValue(strategy: 'AUTO')]
+    #[ODM\Id(type: 'int', strategy: 'INCREMENT')]
+    public int $id;
+
+    #[ORM\Column(type: 'string')]
+    #[ODM\Field]
+    public string $title;
+
+    #[ORM\Column(type: 'text')]
+    #[ODM\Field]
+    public string $body;
+}
diff --git a/tests/Documentation/MappingOrmAndOdm/BlogPostRepositoryInterface.php b/tests/Documentation/MappingOrmAndOdm/BlogPostRepositoryInterface.php
new file mode 100644
index 000000000..ec656a502
--- /dev/null
+++ b/tests/Documentation/MappingOrmAndOdm/BlogPostRepositoryInterface.php
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\MappingOrmAndOdm;
+
+interface BlogPostRepositoryInterface
+{
+    public function findPostById(int $id): ?BlogPost;
+}
diff --git a/tests/Documentation/MappingOrmAndOdm/MappingOrmAndOdmTest.php b/tests/Documentation/MappingOrmAndOdm/MappingOrmAndOdmTest.php
new file mode 100644
index 000000000..41f89df39
--- /dev/null
+++ b/tests/Documentation/MappingOrmAndOdm/MappingOrmAndOdmTest.php
@@ -0,0 +1,75 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\MappingOrmAndOdm;
+
+use Doctrine\DBAL\DriverManager;
+use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
+use Doctrine\ORM\EntityManager;
+use Doctrine\ORM\ORMSetup;
+use PHPUnit\Framework\Attributes\RequiresPhpExtension;
+
+#[RequiresPhpExtension('pdo_sqlite')]
+class MappingOrmAndOdmTest extends BaseTestCase
+{
+    public function testTest(): void
+    {
+        // Init ORM
+        $config     = ORMSetup::createAttributeMetadataConfiguration(
+            paths: [__DIR__],
+            isDevMode: true,
+        );
+        $connection = DriverManager::getConnection([
+            'driver' => 'pdo_sqlite',
+            'path' => ':memory:',
+        ], $config);
+        $connection->executeQuery('CREATE TABLE blog_posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, body TEXT)');
+        $em = new EntityManager($connection, $config);
+
+        // Init ODM
+        $dm = $this->dm;
+
+        // Create and persist a BlogPost in both ORM and ODM
+        $blogPost        = new BlogPost();
+        $blogPost->title = 'Hello World!';
+
+        $em->persist($blogPost);
+        $em->flush();
+        $em->clear();
+
+        $dm->persist($blogPost);
+        $dm->flush();
+        $dm->clear();
+
+        // Load the BlogPost from both ORM and ODM
+        $ormBlogPost = $em->find(BlogPost::class, $blogPost->id);
+        $odmBlogPost = $dm->find(BlogPost::class, $blogPost->id);
+
+        $this->assertSame($blogPost->id, $ormBlogPost->id);
+        $this->assertSame($blogPost->id, $odmBlogPost->id);
+        $this->assertSame($blogPost->title, $ormBlogPost->title);
+        $this->assertSame($blogPost->title, $odmBlogPost->title);
+
+        // Different Object Managers are used, so the instances are different
+        $this->assertNotSame($odmBlogPost, $ormBlogPost);
+
+        $dm->clear();
+        $em->clear();
+
+        // Remove the BlogPost from both ORM and ODM using the repository
+        $ormBlogPostRepository = $em->getRepository(BlogPost::class);
+        $this->assertInstanceOf(OrmBlogPostRepository::class, $ormBlogPostRepository);
+        $ormBlogPost = $ormBlogPostRepository->findPostById($blogPost->id);
+
+        $odmBlogPostRepository = $dm->getRepository(BlogPost::class);
+        $this->assertInstanceOf(OdmBlogPostRepository::class, $odmBlogPostRepository);
+        $odmBlogPost = $odmBlogPostRepository->findPostById($blogPost->id);
+
+        $this->assertSame($blogPost->title, $ormBlogPost->title);
+        $this->assertSame($blogPost->title, $odmBlogPost->title);
+
+        // Different Object Managers are used, so the instances are different
+        $this->assertNotSame($odmBlogPost, $ormBlogPost);
+    }
+}
diff --git a/tests/Documentation/MappingOrmAndOdm/OdmBlogPostRepository.php b/tests/Documentation/MappingOrmAndOdm/OdmBlogPostRepository.php
new file mode 100644
index 000000000..5002d17eb
--- /dev/null
+++ b/tests/Documentation/MappingOrmAndOdm/OdmBlogPostRepository.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\MappingOrmAndOdm;
+
+use Doctrine\ODM\MongoDB\Repository\DocumentRepository;
+
+final class OdmBlogPostRepository extends DocumentRepository implements BlogPostRepositoryInterface
+{
+    public function findPostById(int $id): ?BlogPost
+    {
+        return $this->findOneBy(['id' => $id]);
+    }
+}
diff --git a/tests/Documentation/MappingOrmAndOdm/OrmBlogPostRepository.php b/tests/Documentation/MappingOrmAndOdm/OrmBlogPostRepository.php
new file mode 100644
index 000000000..58238b682
--- /dev/null
+++ b/tests/Documentation/MappingOrmAndOdm/OrmBlogPostRepository.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\MappingOrmAndOdm;
+
+use Doctrine\ORM\EntityRepository;
+
+final class OrmBlogPostRepository extends EntityRepository implements BlogPostRepositoryInterface
+{
+    public function findPostById(int $id): ?BlogPost
+    {
+        return $this->findOneBy(['id' => $id]);
+    }
+}

From 50827515eb1fe1650bb4f9076c3c20a90ca3682c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Mon, 1 Jul 2024 15:45:40 +0200
Subject: [PATCH 05/17] Add tests for introduction (#2664)

---
 docs/en/reference/introduction.rst            | 29 +++++----
 docs/en/tutorials/getting-started.rst         | 10 +--
 tests/Documentation/Introduction/Address.php  | 23 +++++++
 .../Introduction/BaseEmployee.php             | 34 ++++++++++
 tests/Documentation/Introduction/Employee.php | 17 +++++
 .../Introduction/IntroductionTest.php         | 62 +++++++++++++++++++
 tests/Documentation/Introduction/Manager.php  | 25 ++++++++
 tests/Documentation/Introduction/Project.php  | 20 ++++++
 8 files changed, 202 insertions(+), 18 deletions(-)
 create mode 100644 tests/Documentation/Introduction/Address.php
 create mode 100644 tests/Documentation/Introduction/BaseEmployee.php
 create mode 100644 tests/Documentation/Introduction/Employee.php
 create mode 100644 tests/Documentation/Introduction/IntroductionTest.php
 create mode 100644 tests/Documentation/Introduction/Manager.php
 create mode 100644 tests/Documentation/Introduction/Project.php

diff --git a/docs/en/reference/introduction.rst b/docs/en/reference/introduction.rst
index b71626d0a..42b44f2be 100644
--- a/docs/en/reference/introduction.rst
+++ b/docs/en/reference/introduction.rst
@@ -54,23 +54,23 @@ the features.
         #[ODM\Id]
         public string $id;
 
-        #[ODM\Field(type: 'int', strategy: 'increment')]
+        #[ODM\Field(strategy: 'increment')]
         public int $changes = 0;
 
         /** @var string[] */
-        #[ODM\Field(type: 'collection')]
+        #[ODM\Field]
         public array $notes = [];
 
-        #[ODM\Field(type: 'string')]
+        #[ODM\Field]
         public string $name;
 
-        #[ODM\Field(type: 'int')]
+        #[ODM\Field]
         public int $salary;
 
-        #[ODM\Field(type: 'date_immutable')]
+        #[ODM\Field]
         public DateTimeImmutable $started;
 
-        #[ODM\Field(type: 'date')]
+        #[ODM\Field]
         public DateTime $left;
 
         #[ODM\EmbedOne(targetDocument: Address::class)]
@@ -101,16 +101,16 @@ the features.
     class Address
     {
         public function __construct(
-            #[ODM\Field(type: 'string')]
+            #[ODM\Field]
             public string $address,
 
-            #[ODM\Field(type: 'string')]
+            #[ODM\Field]
             public string $city,
 
-            #[ODM\Field(type: 'string')]
+            #[ODM\Field]
             public string $state,
 
-            #[ODM\Field(type: 'string')]
+            #[ODM\Field]
             public string $zipcode,
         ) {
         }
@@ -123,7 +123,7 @@ the features.
         public string $id;
 
         public function __construct(
-            #[ODM\Field(type: 'string')]
+            #[ODM\Field]
             public string $name,
         ) {
         }
@@ -160,9 +160,12 @@ Doctrine:
     $manager->started = new DateTimeImmutable();
     $manager->projects->add($project);
 
-    /** @var Doctrine\ODM\MongoDB\DocumentManager $dm */
+    /**
+     * Learn how to setup the DocumentManager in the next chapter.
+     *
+     * @var Doctrine\ODM\MongoDB\DocumentManager $dm
+     */
     $dm->persist($employee);
-    $dm->persist($address);
     $dm->persist($project);
     $dm->persist($manager);
     $dm->flush();
diff --git a/docs/en/tutorials/getting-started.rst b/docs/en/tutorials/getting-started.rst
index b346e4497..11ef0ccc8 100755
--- a/docs/en/tutorials/getting-started.rst
+++ b/docs/en/tutorials/getting-started.rst
@@ -95,10 +95,10 @@ You can provide your mapping information in Annotations or XML:
                 #[ODM\Id]
                 public ?string $id = null,
 
-                #[ODM\Field(type: 'string')]
+                #[ODM\Field]
                 public string $name = '',
 
-                #[ODM\Field(type: 'string')]
+                #[ODM\Field]
                 public string $email = '',
 
                 #[ODM\ReferenceMany(targetDocument: BlogPost::class, cascade: 'all')]
@@ -116,13 +116,13 @@ You can provide your mapping information in Annotations or XML:
                 #[ODM\Id]
                 public ?string $id = null,
 
-                #[ODM\Field(type: 'string')]
+                #[ODM\Field]
                 public string $title = '',
 
-                #[ODM\Field(type: 'string')]
+                #[ODM\Field]
                 public string $body = '',
 
-                #[ODM\Field(type: 'date_immutable')]
+                #[ODM\Field]
                 public DateTimeImmutable $createdAt = new DateTimeImmutable(),
             ) {
             }
diff --git a/tests/Documentation/Introduction/Address.php b/tests/Documentation/Introduction/Address.php
new file mode 100644
index 000000000..99573d3b0
--- /dev/null
+++ b/tests/Documentation/Introduction/Address.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Introduction;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+
+#[ODM\EmbeddedDocument]
+class Address
+{
+    public function __construct(
+        #[ODM\Field]
+        public string $address,
+        #[ODM\Field]
+        public string $city,
+        #[ODM\Field]
+        public string $state,
+        #[ODM\Field]
+        public string $zipcode,
+    ) {
+    }
+}
diff --git a/tests/Documentation/Introduction/BaseEmployee.php b/tests/Documentation/Introduction/BaseEmployee.php
new file mode 100644
index 000000000..e9b5b3a35
--- /dev/null
+++ b/tests/Documentation/Introduction/BaseEmployee.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Introduction;
+
+use DateTimeImmutable;
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+
+#[ODM\MappedSuperclass]
+abstract class BaseEmployee
+{
+    #[ODM\Field(strategy: 'increment')]
+    public int $changes = 0;
+
+    /** @var string[] */
+    #[ODM\Field]
+    public array $notes = [];
+
+    #[ODM\Field]
+    public string $name;
+
+    #[ODM\Field]
+    public int $salary;
+
+    #[ODM\Field]
+    public DateTimeImmutable $started;
+
+    #[ODM\Field]
+    public DateTimeImmutable $left;
+
+    #[ODM\EmbedOne]
+    public Address $address;
+}
diff --git a/tests/Documentation/Introduction/Employee.php b/tests/Documentation/Introduction/Employee.php
new file mode 100644
index 000000000..23ed65b98
--- /dev/null
+++ b/tests/Documentation/Introduction/Employee.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Introduction;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+
+#[ODM\Document]
+class Employee extends BaseEmployee
+{
+    #[ODM\Id]
+    public string $id;
+
+    #[ODM\ReferenceOne(targetDocument: Manager::class)]
+    public ?Manager $manager = null;
+}
diff --git a/tests/Documentation/Introduction/IntroductionTest.php b/tests/Documentation/Introduction/IntroductionTest.php
new file mode 100644
index 000000000..67d1cc9aa
--- /dev/null
+++ b/tests/Documentation/Introduction/IntroductionTest.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Introduction;
+
+use DateTimeImmutable;
+use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
+
+class IntroductionTest extends BaseTestCase
+{
+    public function testIntroduction(): void
+    {
+        // Insert
+        $employee          = new Employee();
+        $employee->name    = 'Employee';
+        $employee->salary  = 50000;
+        $employee->started = new DateTimeImmutable();
+        $employee->address = new Address(
+            address: '555 Doctrine Rd.',
+            city: 'Nashville',
+            state: 'TN',
+            zipcode: '37209',
+        );
+
+        $project          = new Project('New Project');
+        $manager          = new Manager();
+        $manager->name    = 'Manager';
+        $manager->salary  = 100_000;
+        $manager->started = new DateTimeImmutable();
+        $manager->projects->add($project);
+
+        $this->dm->persist($employee);
+        $this->dm->persist($project);
+        $this->dm->persist($manager);
+        $this->dm->flush();
+        $this->dm->clear();
+
+        $employee = $this->dm->find(Employee::class, $employee->id);
+        $this->assertInstanceOf(Address::class, $employee->address);
+
+        $manager = $this->dm->find(Manager::class, $manager->id);
+        $this->assertInstanceOf(Project::class, $manager->projects[0]);
+
+        // Update
+        $newProject       = new Project('Another Project');
+        $manager->salary  = 200_000;
+        $manager->notes[] = 'Gave user 100k a year raise';
+        $manager->changes++;
+        $manager->projects->add($newProject);
+
+        $this->dm->persist($newProject);
+        $this->dm->flush();
+        $this->dm->clear();
+
+        $manager = $this->dm->find(Manager::class, $manager->id);
+        $this->assertSame(200_000, $manager->salary);
+        $this->assertCount(1, $manager->notes);
+        $this->assertSame(1, $manager->changes);
+        $this->assertCount(2, $manager->projects);
+    }
+}
diff --git a/tests/Documentation/Introduction/Manager.php b/tests/Documentation/Introduction/Manager.php
new file mode 100644
index 000000000..451799858
--- /dev/null
+++ b/tests/Documentation/Introduction/Manager.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Introduction;
+
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+
+#[ODM\Document]
+class Manager extends BaseEmployee
+{
+    #[ODM\Id]
+    public string $id;
+
+    /** @var Collection<Project> */
+    #[ODM\ReferenceMany(targetDocument: Project::class)]
+    public Collection $projects;
+
+    public function __construct()
+    {
+        $this->projects = new ArrayCollection();
+    }
+}
diff --git a/tests/Documentation/Introduction/Project.php b/tests/Documentation/Introduction/Project.php
new file mode 100644
index 000000000..c8f587c64
--- /dev/null
+++ b/tests/Documentation/Introduction/Project.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\Introduction;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
+
+#[ODM\Document]
+class Project
+{
+    #[ODM\Id]
+    public string $id;
+
+    public function __construct(
+        #[ODM\Field]
+        public string $name,
+    ) {
+    }
+}

From f57fcc3f722506f2cf9f2e543e57ccc53fcfc3a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Mon, 1 Jul 2024 21:54:44 +0200
Subject: [PATCH 06/17] Modernize generated code for Hydrators (#2665)

---
 .../ODM/MongoDB/Hydrator/HydratorFactory.php  | 41 +++++++++----------
 1 file changed, 19 insertions(+), 22 deletions(-)

diff --git a/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php b/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php
index 8a076cca5..52c1f3943 100644
--- a/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php
+++ b/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php
@@ -167,8 +167,8 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
                     $code .= sprintf(
                         <<<EOF
 
-        /** @AlsoLoad("$name") */
-        if (!array_key_exists('%1\$s', \$data) && array_key_exists('$name', \$data)) {
+        // AlsoLoad("$name")
+        if (! array_key_exists('%1\$s', \$data) && array_key_exists('$name', \$data)) {
             \$data['%1\$s'] = \$data['$name'];
         }
 
@@ -183,7 +183,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
                 $code .= sprintf(
                     <<<'EOF'
 
-        /** @Field(type="date") */
+        // Field(type: "date")
         if (isset($data['%1$s'])) {
             $value = $data['%1$s'];
             %3$s
@@ -201,7 +201,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
                 $code .= sprintf(
                     <<<EOF
 
-        /** @Field(type="{$mapping['type']}") */
+        // Field(type: "{$mapping['type']}")
         if (isset(\$data['%1\$s']) || (! empty(\$this->class->fieldMappings['%2\$s']['nullable']) && array_key_exists('%1\$s', \$data))) {
             \$value = \$data['%1\$s'];
             if (\$value !== null) {
@@ -224,7 +224,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
                 $code .= sprintf(
                     <<<'EOF'
 
-        /** @ReferenceOne */
+        // ReferenceOne
         if (isset($data['%1$s']) || (! empty($this->class->fieldMappings['%2$s']['nullable']) && array_key_exists('%1$s', $data))) {
             $return = $data['%1$s'];
             if ($return !== null) {
@@ -275,11 +275,11 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
         $mappedByMapping = $targetClass->fieldMappings[$mapping['mappedBy']];
         $mappedByFieldName = ClassMetadata::getReferenceFieldName($mappedByMapping['storeAs'], $mapping['mappedBy']);
         $criteria = array_merge(
-            array($mappedByFieldName => $data['_id']),
-            isset($this->class->fieldMappings['%2$s']['criteria']) ? $this->class->fieldMappings['%2$s']['criteria'] : array()
+            [$mappedByFieldName => $data['_id']],
+            $this->class->fieldMappings['%2$s']['criteria'] ?? []
         );
-        $sort = isset($this->class->fieldMappings['%2$s']['sort']) ? $this->class->fieldMappings['%2$s']['sort'] : array();
-        $return = $this->dm->getUnitOfWork()->getDocumentPersister($className)->load($criteria, null, array(), 0, $sort);
+        $sort = $this->class->fieldMappings['%2$s']['sort'] ?? [];
+        $return = $this->dm->getUnitOfWork()->getDocumentPersister($className)->load($criteria, null, [], 0, $sort);
         $this->class->reflFields['%2$s']->setValue($document, $return);
         $hydratedData['%2$s'] = $return;
 
@@ -293,8 +293,8 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
                 $code .= sprintf(
                     <<<'EOF'
 
-        /** @Many */
-        $mongoData = isset($data['%1$s']) ? $data['%1$s'] : null;
+        // ReferenceMany & EmbedMany
+        $mongoData = $data['%1$s'] ?? null;
 
         if ($mongoData !== null && ! is_array($mongoData)) {
             throw HydratorException::associationTypeMismatch('%3$s', '%1$s', 'array', gettype($mongoData));
@@ -320,7 +320,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
                 $code .= sprintf(
                     <<<'EOF'
 
-        /** @EmbedOne */
+        // EmbedOne
         if (isset($data['%1$s']) || (! empty($this->class->fieldMappings['%2$s']['nullable']) && array_key_exists('%1$s', $data))) {
             $return = $data['%1$s'];
             if ($return !== null) {
@@ -370,23 +370,20 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla
 use Doctrine\ODM\MongoDB\Query\Query;
 use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
 
+use function array_key_exists;
+use function gettype;
+use function is_array;
+
 /**
  * THIS CLASS WAS GENERATED BY THE DOCTRINE ODM. DO NOT EDIT THIS FILE.
  */
 class $hydratorClassName implements HydratorInterface
 {
-    private \$dm;
-    private \$class;
-
-    public function __construct(DocumentManager \$dm, ClassMetadata \$class)
-    {
-        \$this->dm = \$dm;
-        \$this->class = \$class;
-    }
+    public function __construct(private DocumentManager \$dm, private ClassMetadata \$class) {}
 
-    public function hydrate(object \$document, array \$data, array \$hints = array()): array
+    public function hydrate(object \$document, array \$data, array \$hints = []): array
     {
-        \$hydratedData = array();
+        \$hydratedData = [];
 %s        return \$hydratedData;
     }
 }

From ffd77f95f5f65772dcee94212dc60fcc685e3253 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Mon, 1 Jul 2024 23:01:35 +0200
Subject: [PATCH 07/17] doc: Remove wakeup and clone cookbook (#2663)

* Remove wakeup and clone cookbook
* Implemeting __clone and __wakeup is not an issue
---
 .../cookbook/implementing-wakeup-or-clone.rst | 77 -------------------
 docs/en/reference/architecture.rst            |  7 --
 2 files changed, 84 deletions(-)
 delete mode 100644 docs/en/cookbook/implementing-wakeup-or-clone.rst

diff --git a/docs/en/cookbook/implementing-wakeup-or-clone.rst b/docs/en/cookbook/implementing-wakeup-or-clone.rst
deleted file mode 100644
index 0908dd09c..000000000
--- a/docs/en/cookbook/implementing-wakeup-or-clone.rst
+++ /dev/null
@@ -1,77 +0,0 @@
-Implementing Wakeup or Clone
-============================
-
-.. sectionauthor:: Roman Borschel <roman@code-factory.org>
-
-As explained in the
-:doc:`restrictions for document classes in the manual <../reference/architecture>`.
-it is usually not allowed for a document to implement ``__wakeup``
-or ``__clone``, because Doctrine makes special use of them.
-However, it is quite easy to make use of these methods in a safe
-way by guarding the custom wakeup or clone code with a document
-identity check, as demonstrated in the following sections.
-
-Safely implementing \_\_wakeup
-------------------------------
-
-To safely implement ``__wakeup``, simply enclose your
-implementation code in an identity check as follows:
-
-.. code-block:: php
-
-    <?php
-
-    class MyDocument
-    {
-        private $id; // This is the identifier of the document.
-        //...
-
-        public function __wakeup()
-        {
-            // If the document has an identity, proceed as normal.
-            if ($this->id !== null) {
-                // ... Your code here as normal ...
-            }
-            // otherwise do nothing, do NOT throw an exception!
-        }
-
-        //...
-    }
-
-Safely implementing \_\_clone
------------------------------
-
-Safely implementing ``__clone`` is pretty much the same:
-
-.. code-block:: php
-
-    <?php
-    class MyDocument
-    {
-        private $id; // This is the identifier of the document.
-        //...
-
-        public function __clone()
-        {
-            // If the document has an identity, proceed as normal.
-            if ($this->id !== null) {
-                // ... Your code here as normal ...
-            }
-            // otherwise do nothing, do NOT throw an exception!
-        }
-
-        //...
-    }
-
-Summary
--------
-
-As you have seen, it is quite easy to safely make use of
-``__wakeup`` and ``__clone`` in your documents without adding any
-really Doctrine-specific or Doctrine-dependant code.
-
-These implementations are possible and safe because when Doctrine
-invokes these methods, the documents never have an identity (yet).
-Furthermore, it is possibly a good idea to check for the identity
-in your code anyway, since it's rarely the case that you want to
-unserialize or clone a document with no identity.
\ No newline at end of file
diff --git a/docs/en/reference/architecture.rst b/docs/en/reference/architecture.rst
index 9ff13338c..aff153331 100644
--- a/docs/en/reference/architecture.rst
+++ b/docs/en/reference/architecture.rst
@@ -15,13 +15,6 @@ be any regular PHP class observing the following restrictions:
 -  All persistent properties/field of any document class should
    always be private or protected, otherwise lazy-loading might not
    work as expected.
--  A document class must not implement ``__clone`` or
-   :doc:`do so safely <../cookbook/implementing-wakeup-or-clone>`.
--  A document class must not implement ``__wakeup`` or
-   :doc:`do so safely <../cookbook/implementing-wakeup-or-clone>`.
-   Also consider implementing
-   `Serializable <https://www.php.net/manual/en/class.serializable.php>`_
-   instead.
 -  Any two document classes in a class hierarchy that inherit
    directly or indirectly from one another must not have a mapped
    property with the same name. That is, if B inherits from A then B

From 99b4183889414e593029557c356a829eaba34f8d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Mon, 1 Jul 2024 23:02:27 +0200
Subject: [PATCH 08/17] Remove soft-delete-cookbook (#2657)

---
 docs/en/cookbook/soft-delete-extension.rst | 226 ---------------------
 1 file changed, 226 deletions(-)
 delete mode 100644 docs/en/cookbook/soft-delete-extension.rst

diff --git a/docs/en/cookbook/soft-delete-extension.rst b/docs/en/cookbook/soft-delete-extension.rst
deleted file mode 100644
index 8c1cd3993..000000000
--- a/docs/en/cookbook/soft-delete-extension.rst
+++ /dev/null
@@ -1,226 +0,0 @@
-Soft Delete Extension
-=====================
-
-Sometimes you may not want to delete data from your database completely, but you want to
-disable or temporarily delete some records so they do not appear anymore in your frontend.
-Then, later you might want to restore that deleted data like it was never deleted.
-
-This is possible with the ``SoftDelete`` extension which can be found on `github`_.
-
-Installation
-------------
-
-First you just need to get the code by cloning the `github`_ repository:
-
-.. code-block:: console
-
-    $ git clone git://github.com/doctrine/mongodb-odm-softdelete.git
-
-Now once you have the code you can setup the autoloader for it:
-
-.. code-block:: php
-
-    <?php
-
-    $classLoader = new ClassLoader('Doctrine\ODM\MongoDB\SoftDelete', 'mongodb-odm-softdelete/lib');
-    $classLoader->register();
-
-Setup
------
-
-Now you can autoload the classes you need to setup the ``SoftDeleteManager`` instance you need to manage
-the soft delete state of your documents:
-
-.. code-block:: php
-
-    <?php
-
-    use Doctrine\ODM\MongoDB\SoftDelete\Configuration;
-    use Doctrine\ODM\MongoDB\SoftDelete\UnitOfWork;
-    use Doctrine\ODM\MongoDB\SoftDelete\SoftDeleteManager;
-    use Doctrine\Common\EventManager;
-
-    // $dm is a DocumentManager instance we should already have
-
-    $config = new Configuration();
-    $evm = new EventManager();
-    $sdm = new SoftDeleteManager($dm, $config, $evm);
-
-SoftDeleteable Interface
-------------------------
-
-In order for your documents to work with the SoftDelete functionality they must implement
-the ``SoftDeleteable`` interface:
-
-.. code-block:: php
-
-    <?php
-
-    interface SoftDeleteable
-    {
-        function getDeletedAt();
-    }
-
-Example Implementation
-----------------------
-
-An implementation might look like this in a ``User`` document:
-
-.. code-block:: php
-
-    <?php
-
-    use Doctrine\ODM\MongoDB\SoftDelete\SoftDeleteable;
-    use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
-
-    #[ODM\Document]
-    class User implements SoftDeleteable
-    {
-        // ...
-
-        #[Field(type: 'date')]
-        #[Index]
-        private $deletedAt;
-
-        public function getDeletedAt(): ?\DateTime
-        {
-            return $this->deletedAt;
-        }
-
-        // ...
-    }
-
-Usage
------
-
-Once you have the ``$sdm`` you can start managing the soft delete state of your documents:
-
-.. code-block:: php
-
-    <?php
-
-    $jwage = $dm->getRepository(User::class)->findOneBy(['username' => 'jwage']);
-    $fabpot = $dm->getRepository(User::class)->findOneBy(['username' => 'fabpot']);
-    $sdm->delete($jwage);
-    $sdm->delete($fabpot);
-    $sdm->flush();
-
-The call to ``SoftDeleteManager#flush()`` would persist the deleted state to the database
-for all the documents it knows about and run a query like the following:
-
-.. code-block:: javascript
-
-    db.users.update({ _id : { $in : userIds }}, { $set : { deletedAt : new Date() } })
-
-Now if we were to restore the documents:
-
-.. code-block:: php
-
-    <?php
-
-    $sdm->restore($jwage);
-    $sdm->flush();
-
-It would execute a query like the following:
-
-.. code-block:: javascript
-
-    db.users.update({ _id : { $in : userIds }}, { $unset : { deletedAt : true } })
-
-Events
-------
-
-We trigger some additional lifecycle events when documents are soft deleted and restored:
-
-- Events::preSoftDelete
-- Events::postSoftDelete
-- Events::preRestore
-- Events::postRestore
-
-Using the events is easy, just define a class like the following:
-
-.. code-block:: php
-
-    <?php
-
-    class TestEventSubscriber implements \Doctrine\Common\EventSubscriber
-    {
-        public function preSoftDelete(LifecycleEventArgs $args): void
-        {
-            $document = $args->getDocument();
-            $sdm = $args->getSoftDeleteManager();
-        }
-
-        public function getSubscribedEvents(): array
-        {
-            return [Events::preSoftDelete];
-        }
-    }
-
-Now we just need to add the event subscriber to the EventManager:
-
-.. code-block:: php
-
-    <?php
-
-    $eventSubscriber = new TestEventSubscriber();
-    $evm->addEventSubscriber($eventSubscriber);
-
-When we soft delete something the preSoftDelete() method will be invoked before any queries are sent
-to the database:
-
-.. code-block:: php
-
-    <?php
-
-    $sdm->delete($fabpot);
-    $sdm->flush();
-
-Cascading Soft Deletes
-----------------------
-
-You can easily implement cascading soft deletes by using events in a certain way. Imagine you have
-a User and Post document and you want to soft delete a users posts when you delete him.
-
-You just need to setup an event listener like the following:
-
-.. code-block:: php
-
-    <?php
-
-    use Doctrine\Common\EventSubscriber;
-    use Doctrine\ODM\MongoDB\SoftDelete\Event\LifecycleEventArgs;
-
-    class CascadingSoftDeleteListener implements EventSubscriber
-    {
-        public function preSoftDelete(LifecycleEventArgs $args): void
-        {
-            $sdm = $args->getSoftDeleteManager();
-            $document = $args->getDocument();
-            if ($document instanceof User) {
-                $sdm->deleteBy(Post::class, ['user.id' => $document->getId()]);
-            }
-        }
-
-        public function preRestore(LifecycleEventArgs $args): void
-        {
-            $sdm = $args->getSoftDeleteManager();
-            $document = $args->getDocument();
-            if ($document instanceof User) {
-                $sdm->restoreBy(Post::class, ['user.id' => $document->getId()]);
-            }
-        }
-
-        public function getSubscribedEvents(): array
-        {
-            return [
-                Events::preSoftDelete,
-                Events::preRestore
-            ];
-        }
-    }
-
-Now when you delete an instance of User it will also delete any Post documents where they
-reference the User being deleted. If you restore the User, his Post documents will also be restored.
-
-.. _github: https://github.com/doctrine/mongodb-odm-softdelete

From e716dbe23eaba9b6b9595a2e1ec8301bd3ef2824 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Tue, 2 Jul 2024 13:04:06 +0200
Subject: [PATCH 09/17] Review and add tests on `ResolveTargetDocumentListener`
 (#2660)

* Review and add tests on ResolveTargetDocumentListener doc
* Get field type from prop type
---
 .../resolve-target-document-listener.rst      | 153 +++++++++++-------
 .../ResolveTargetDocument/Customer.php        |  18 +++
 .../CustomerModule/Customer.php               |  19 +++
 .../InvoiceModule/Invoice.php                 |  19 +++
 .../InvoiceModule/InvoiceSubjectInterface.php |  10 ++
 .../ResolveTargetDocumentTest.php             |  42 +++++
 6 files changed, 204 insertions(+), 57 deletions(-)
 create mode 100644 tests/Documentation/ResolveTargetDocument/Customer.php
 create mode 100644 tests/Documentation/ResolveTargetDocument/CustomerModule/Customer.php
 create mode 100644 tests/Documentation/ResolveTargetDocument/InvoiceModule/Invoice.php
 create mode 100644 tests/Documentation/ResolveTargetDocument/InvoiceModule/InvoiceSubjectInterface.php
 create mode 100644 tests/Documentation/ResolveTargetDocument/ResolveTargetDocumentTest.php

diff --git a/docs/en/cookbook/resolve-target-document-listener.rst b/docs/en/cookbook/resolve-target-document-listener.rst
index 1a304d39b..9bff06d13 100644
--- a/docs/en/cookbook/resolve-target-document-listener.rst
+++ b/docs/en/cookbook/resolve-target-document-listener.rst
@@ -1,26 +1,20 @@
 Keeping Your Modules Independent
 ================================
 
-One of the goals of using modules is to create discrete units of functionality
-that do not have many (if any) dependencies, allowing you to use that
-functionality in other applications without including unnecessary items.
-
-Doctrine MongoDB ODM includes a utility called
-``ResolveTargetDocumentListener``, that functions by intercepting certain calls
-inside Doctrine and rewriting ``targetDocument`` parameters in your metadata
-mapping at runtime. This allows your bundle to use an interface or abstract
-class in its mappings while still allowing the mapping to resolve to a concrete
-document class at runtime. It will also rewrite class names when no mapping
-metadata has been found for the original class name.
-
-This functionality allows you to define relationships between different
-documents without creating hard dependencies.
+If you work with independent modules, you may encounter the problem of creating
+relationships between objects in different modules. This is problematic because
+it creates a dependency between the modules. This can be resolved by using
+interfaces or abstract classes to define the relationships between the objects
+and then using the ``ResolveTargetDocumentListener``. This event listener will
+intercept certain calls inside Doctrine and rewrite ``targetDocument``
+parameters in your metadata mapping at runtime. It will also rewrite class names
+when no mapping metadata has been found for the original class name.
 
 Background
 ----------
 
-In the following example, we have an `InvoiceModule` that provides invoicing
-functionality, and a `CustomerModule` that contains customer management tools.
+In the following example, we have an ``InvoiceModule`` that provides invoicing
+functionality, and a ``CustomerModule`` that contains customer management tools.
 We want to keep these separated, because they can be used in other systems
 without each other; however, we'd like to use them together in our application.
 
@@ -32,79 +26,94 @@ with a real class that implements that interface.
 Configuration
 -------------
 
-We're going to use the following basic documents (which are incomplete
-for brevity) to explain how to set up and use the
-``ResolveTargetDocumentListener``.
+We're going to use the following basic documents to explain how to set up and
+use the ``ResolveTargetDocumentListener``.
 
-A Customer document:
+A ``Customer`` class in the ``CustomerModule``. This class will be extended in
+the application:
 
 .. code-block:: php
 
     <?php
-    // src/Acme/AppModule/Document/Customer.php
-
-    namespace Acme\AppModule\Document;
 
-    use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
-    use Acme\CustomerModule\Document\Customer as BaseCustomer;
-    use Acme\InvoiceModule\Model\InvoiceSubjectInterface;
+    namespace Acme\CustomerModule\Document;
 
-    #[ODM\Document]
-    class Customer extends BaseCustomer implements InvoiceSubjectInterface
+    #[Document]
+    class Customer
     {
-        // In our example, any methods defined in the InvoiceSubjectInterface
-        // are already implemented in the BaseCustomer
+        #[Id]
+        public string $id;
+
+        #[Field]
+        public string $name;
     }
 
-An Invoice document:
+An ``Invoice`` document in the ``InvoiceModule``:
 
 .. code-block:: php
 
     <?php
-    // src/Acme/InvoiceModule/Document/Invoice.php
 
     namespace Acme\InvoiceModule\Document;
 
-    use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
     use Acme\InvoiceModule\Model\InvoiceSubjectInterface;
 
-    #[ODM\Document]
+    #[Document]
     class Invoice
     {
-        #[ReferenceOne(targetDocument: \Acme\InvoiceModule\Model\InvoiceSubjectInterface::class)]
-        protected InvoiceSubjectInterface $subject;
+        #[Id]
+        public string $id;
+
+        #[ReferenceOne]
+        public InvoiceSubjectInterface $subject;
     }
 
-An InvoiceSubjectInterface:
+This class has de reference to the ``InvoiceSubjectInterface``. This interface
+contains the list of methods that the ``InvoiceModule`` will need to access on
+the subject so that we are sure that we have access to those methods. This
+interface is also defined in the ``InvoiceModule``:
 
 .. code-block:: php
 
     <?php
-    // src/Acme/InvoiceModule/Model/InvoiceSubjectInterface.php
 
     namespace Acme\InvoiceModule\Model;
 
-    /**
-     * An interface that the invoice Subject object should implement.
-     * In most circumstances, only a single object should implement
-     * this interface as the ResolveTargetDocumentListener can only
-     * change the target to a single object.
-     */
     interface InvoiceSubjectInterface
     {
-        // List any additional methods that your InvoiceModule
-        // will need to access on the subject so that you can
-        // be sure that you have access to those methods.
-
-        /**
-         * @return string
-         */
         public function getName(): string;
     }
 
-Next, we need to configure the listener. Add this to the area where you setup
-Doctrine MongoDB ODM. You must set this up in the way outlined below, otherwise
-you cannot be guaranteed that the targetDocument resolution will occur reliably:
+In the application, the ``Customer`` document class extends the ``Customer``
+class from the ``CustomerModule`` and implements the ``InvoiceSubjectInterface``
+from the ``InvoiceModule``. In most circumstances, only a single document class
+should implement the ``InvoiceSubjectInterface``.
+The ``ResolveTargetDocumentListener`` can only change the target to a single
+object.
+
+.. code-block:: php
+
+    <?php
+
+    namespace App\Document;
+
+    use Acme\CustomerModule\Document\Customer as BaseCustomer;
+    use Acme\InvoiceModule\Model\InvoiceSubjectInterface;
+
+    #[Document]
+    class Customer extends BaseCustomer implements InvoiceSubjectInterface
+    {
+        public function getName(): string
+        {
+            return $this->name;
+        }
+    }
+
+Next, we need to configure a ``ResolveTargetDocumentListener`` to resolve to the
+``Customer`` class of the application when an instance of
+``InvoiceSubjectInterface`` from ``InvoiceModule`` is expected. This must be
+done in the bootstrap code of your application. This is usually done before the
+instantiation of the ``DocumentManager``:
 
 .. code-block:: php
 
@@ -115,7 +124,7 @@ you cannot be guaranteed that the targetDocument resolution will occur reliably:
     // Adds a target-document class
     $rtdl->addResolveTargetDocument(
         \Acme\InvoiceModule\Model\InvoiceSubjectInterface::class,
-        \Acme\CustomerModule\Document\Customer::class,
+        \App\Document\Customer::class,
         []
     );
 
@@ -125,9 +134,39 @@ you cannot be guaranteed that the targetDocument resolution will occur reliably:
     // Create the document manager as you normally would
     $dm = \Doctrine\ODM\MongoDB\DocumentManager::create(null, $config, $evm);
 
+With this configuration, you can create an ``Invoice`` document and set the
+``subject`` property to a ``Customer`` document. When the invoice is retrieved
+from the database, the ``subject`` property will be an instance of
+``Customer``.
+
+.. code-block:: php
+
+    <?php
+
+    use Acme\InvoiceModule\Document\Invoice;
+    use App\Document\Customer;
+
+    $customer         = new Customer();
+    $customer->name   = 'Example Customer';
+    $invoice          = new Invoice();
+    $invoice->subject = $customer;
+
+    $dm->persist($customer);
+    $dm->persist($invoice);
+    $dm->flush();
+    $dm->clear();
+
+    // Retrieve the invoice from the database
+    $invoice = $dm->find(Invoice::class, $invoice->id);
+
+    // The subject property will be an instance of Customer
+    echo $invoice->subject->getName();
+
+
 Final Thoughts
 --------------
 
-With ``ResolveTargetDocumentListener``, we are able to decouple our bundles so
+With ``ResolveTargetDocumentListener``, we are able to decouple our modules so
 that they are usable by themselves and easier to maintain independently, while
-still being able to define relationships between different objects.
+still being able to define relationships between different objects across
+modules.
diff --git a/tests/Documentation/ResolveTargetDocument/Customer.php b/tests/Documentation/ResolveTargetDocument/Customer.php
new file mode 100644
index 000000000..3d41a168a
--- /dev/null
+++ b/tests/Documentation/ResolveTargetDocument/Customer.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\ResolveTargetDocument;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+use Documentation\ResolveTargetDocument\CustomerModule\Customer as BaseCustomer;
+use Documentation\ResolveTargetDocument\InvoiceModule\InvoiceSubjectInterface;
+
+#[Document]
+class Customer extends BaseCustomer implements InvoiceSubjectInterface
+{
+    public function getName(): string
+    {
+        return $this->name;
+    }
+}
diff --git a/tests/Documentation/ResolveTargetDocument/CustomerModule/Customer.php b/tests/Documentation/ResolveTargetDocument/CustomerModule/Customer.php
new file mode 100644
index 000000000..e2d8f64d3
--- /dev/null
+++ b/tests/Documentation/ResolveTargetDocument/CustomerModule/Customer.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\ResolveTargetDocument\CustomerModule;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+
+#[Document]
+abstract class Customer
+{
+    #[Id]
+    public string $id;
+
+    #[Field]
+    public string $name;
+}
diff --git a/tests/Documentation/ResolveTargetDocument/InvoiceModule/Invoice.php b/tests/Documentation/ResolveTargetDocument/InvoiceModule/Invoice.php
new file mode 100644
index 000000000..810f18fc7
--- /dev/null
+++ b/tests/Documentation/ResolveTargetDocument/InvoiceModule/Invoice.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\ResolveTargetDocument\InvoiceModule;
+
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+use Doctrine\ODM\MongoDB\Mapping\Annotations\ReferenceOne;
+
+#[Document]
+class Invoice
+{
+    #[Id]
+    public string $id;
+
+    #[ReferenceOne]
+    public InvoiceSubjectInterface $subject;
+}
diff --git a/tests/Documentation/ResolveTargetDocument/InvoiceModule/InvoiceSubjectInterface.php b/tests/Documentation/ResolveTargetDocument/InvoiceModule/InvoiceSubjectInterface.php
new file mode 100644
index 000000000..c404762c6
--- /dev/null
+++ b/tests/Documentation/ResolveTargetDocument/InvoiceModule/InvoiceSubjectInterface.php
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\ResolveTargetDocument\InvoiceModule;
+
+interface InvoiceSubjectInterface
+{
+    public function getName(): string;
+}
diff --git a/tests/Documentation/ResolveTargetDocument/ResolveTargetDocumentTest.php b/tests/Documentation/ResolveTargetDocument/ResolveTargetDocumentTest.php
new file mode 100644
index 000000000..12505a61e
--- /dev/null
+++ b/tests/Documentation/ResolveTargetDocument/ResolveTargetDocumentTest.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Documentation\ResolveTargetDocument;
+
+use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
+use Doctrine\ODM\MongoDB\Tools\ResolveTargetDocumentListener;
+use Documentation\ResolveTargetDocument\InvoiceModule\Invoice;
+use Documentation\ResolveTargetDocument\InvoiceModule\InvoiceSubjectInterface;
+
+class ResolveTargetDocumentTest extends BaseTestCase
+{
+    public function testTest(): void
+    {
+        $evm  = $this->dm->getEventManager();
+        $rtdl = new ResolveTargetDocumentListener();
+
+        // Adds a target-document class
+        $rtdl->addResolveTargetDocument(
+            InvoiceSubjectInterface::class,
+            Customer::class,
+            [],
+        );
+
+        $evm->addEventSubscriber($rtdl);
+
+        $customer         = new Customer();
+        $customer->name   = 'Example Customer';
+        $invoice          = new Invoice();
+        $invoice->subject = $customer;
+
+        $this->dm->persist($customer);
+        $this->dm->persist($invoice);
+        $this->dm->flush();
+        $this->dm->clear();
+
+        $invoice = $this->dm->find(Invoice::class, $invoice->id);
+        $this->assertInstanceOf(Customer::class, $invoice->subject);
+        $this->assertSame('Example Customer', $invoice->subject->name);
+    }
+}

From e9f80831f6b0f7ffc0ed17ae1c11ae7ad2b74a8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Tue, 2 Jul 2024 13:31:49 +0200
Subject: [PATCH 10/17] Add native type to private properties and final classes
 (#2666)

---
 lib/Doctrine/ODM/MongoDB/APM/Command.php      |  4 +-
 .../ODM/MongoDB/Aggregation/Stage/Bucket.php  |  4 +-
 .../ODM/MongoDB/Aggregation/Stage/Fill.php    |  3 +-
 .../MongoDB/Aggregation/Stage/Fill/Output.php |  2 +-
 .../ODM/MongoDB/Aggregation/Stage/GeoNear.php |  2 +-
 .../MongoDB/Aggregation/Stage/GraphLookup.php |  2 +-
 .../ODM/MongoDB/Aggregation/Stage/Lookup.php  |  2 +-
 .../ODM/MongoDB/Aggregation/Stage/Merge.php   | 14 ++---
 .../ODM/MongoDB/Aggregation/Stage/Out.php     |  7 +--
 .../Aggregation/Stage/Search/Equals.php       |  3 +-
 .../Aggregation/Stage/Search/GeoShape.php     |  3 +-
 .../Aggregation/Stage/Search/GeoWithin.php    |  3 +-
 .../MongoDB/Aggregation/Stage/Search/Near.php |  6 +--
 .../Aggregation/Stage/Search/Range.php        | 12 ++---
 .../Aggregation/Stage/SetWindowFields.php     |  3 +-
 .../MongoDB/Aggregation/Stage/UnionWith.php   |  7 +--
 .../Event/DocumentNotFoundEventArgs.php       |  3 +-
 .../MongoDB/Iterator/HydratingIterator.php    |  2 +-
 .../ODM/MongoDB/Mapping/ClassMetadata.php     | 14 ++---
 .../PersistentCollectionTrait.php             | 42 ++++++---------
 .../Proxy/Factory/StaticProxyFactory.php      |  3 --
 lib/Doctrine/ODM/MongoDB/Query/Builder.php    |  3 +-
 lib/Doctrine/ODM/MongoDB/Query/Expr.php       |  4 +-
 .../ODM/MongoDB/Query/ReferencePrimer.php     | 49 ++++++++---------
 phpstan-baseline.neon                         | 52 +++----------------
 psalm-baseline.xml                            |  8 +++
 26 files changed, 86 insertions(+), 171 deletions(-)

diff --git a/lib/Doctrine/ODM/MongoDB/APM/Command.php b/lib/Doctrine/ODM/MongoDB/APM/Command.php
index 4670253cf..1bc16b9b9 100644
--- a/lib/Doctrine/ODM/MongoDB/APM/Command.php
+++ b/lib/Doctrine/ODM/MongoDB/APM/Command.php
@@ -14,9 +14,7 @@
 final class Command
 {
     private CommandStartedEvent $startedEvent;
-
-    /** @var CommandSucceededEvent|CommandFailedEvent */
-    private $finishedEvent;
+    private CommandSucceededEvent|CommandFailedEvent $finishedEvent;
 
     private function __construct()
     {
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Bucket.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Bucket.php
index eac3323e8..67e9c80d3 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Bucket.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Bucket.php
@@ -10,9 +10,7 @@ class Bucket extends AbstractBucket
 {
     /** @var mixed[] */
     private array $boundaries;
-
-    /** @var mixed */
-    private $default;
+    private mixed $default = null;
 
     /**
      * An array of values based on the groupBy expression that specify the
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Fill.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Fill.php
index f136cbb3d..df1069166 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Fill.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Fill.php
@@ -32,8 +32,7 @@
  */
 class Fill extends Stage
 {
-    /** @var mixed|Expr|null */
-    private $partitionBy = null;
+    private mixed $partitionBy = null;
 
     /** @var array<string> */
     private array $partitionByFields = [];
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Fill/Output.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Fill/Output.php
index d975df84e..f12ce9f62 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Fill/Output.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Fill/Output.php
@@ -23,7 +23,7 @@ class Output extends Stage
     private string $currentField = '';
 
     /** @var array<string, array<string, mixed>> */
-    private $output = [];
+    private array $output = [];
 
     public function __construct(Builder $builder, private Fill $fill)
     {
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GeoNear.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GeoNear.php
index 5de79b710..7c8bd0fcc 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GeoNear.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GeoNear.php
@@ -25,7 +25,7 @@ class GeoNear extends MatchStage
     private ?float $minDistance = null;
 
     /** @var array<string, mixed>|array{int|float, int|float} */
-    private $near;
+    private array $near;
 
     private ?int $num = null;
 
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GraphLookup.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GraphLookup.php
index 4e75de85a..962da0b50 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GraphLookup.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/GraphLookup.php
@@ -26,7 +26,7 @@ class GraphLookup extends Stage
     private ?string $from;
 
     /** @var string|Expr|mixed[]|null */
-    private $startWith;
+    private string|Expr|array|null $startWith;
 
     private ?string $connectFromField = null;
 
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Lookup.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Lookup.php
index c2d4455bd..7b6fcde70 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Lookup.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Lookup.php
@@ -48,7 +48,7 @@ class Lookup extends Stage
      * @var Builder|array<array<string, mixed>>|null
      * @psalm-var Builder|PipelineExpression|null
      */
-    private $pipeline = null;
+    private Builder|array|null $pipeline = null;
 
     private bool $excludeLocalAndForeignField = false;
 
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php
index 0db46342c..356efa3eb 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Merge.php
@@ -33,11 +33,8 @@
  */
 class Merge extends Stage
 {
-    /**
-     * @var string|array
-     * @psalm-var OutputCollection
-     */
-    private $into;
+    /** @psalm-var OutputCollection */
+    private string|array $into;
 
     /** @var list<string> */
     private array $on = [];
@@ -45,11 +42,8 @@ class Merge extends Stage
     /** @var array<string, mixed|Expr> */
     private array $let = [];
 
-    /**
-     * @var string|array|Builder|Stage
-     * @psalm-var WhenMatchedParamType
-     */
-    private $whenMatched;
+    /** @psalm-var WhenMatchedParamType */
+    private string|array|Builder|Stage|null $whenMatched = null;
 
     private ?string $whenNotMatched = null;
 
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Out.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Out.php
index 5343cfead..aa6a33501 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Out.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Out.php
@@ -19,11 +19,8 @@
  */
 class Out extends Stage
 {
-    /**
-     * @var array|string
-     * @psalm-var OutputCollection
-     */
-    private $out;
+    /** @psalm-var OutputCollection */
+    private array|string $out;
 
     public function __construct(Builder $builder, string $collection, private DocumentManager $dm)
     {
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Equals.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Equals.php
index 960013dbe..fbaf401ed 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Equals.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Equals.php
@@ -19,8 +19,7 @@ class Equals extends AbstractSearchOperator implements ScoredSearchOperator
 
     private string $path = '';
 
-    /** @var mixed */
-    private $value;
+    private mixed $value;
 
     /** @param string|int|float|ObjectId|UTCDateTime|null $value */
     public function __construct(Search $search, string $path = '', $value = null)
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/GeoShape.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/GeoShape.php
index 600ad2f93..c7ab3e401 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/GeoShape.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/GeoShape.php
@@ -24,8 +24,7 @@ class GeoShape extends AbstractSearchOperator implements ScoredSearchOperator
     private array $path      = [];
     private string $relation = '';
 
-    /** @var LineString|Point|Polygon|MultiPolygon|array|null */
-    private $geometry = null;
+    private LineString|Point|Polygon|MultiPolygon|array|null $geometry = null;
 
     /** @param LineString|Point|Polygon|MultiPolygon|array|null $geometry */
     public function __construct(Search $search, $geometry = null, string $relation = '', string ...$path)
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/GeoWithin.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/GeoWithin.php
index 5237a194d..c72b283e4 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/GeoWithin.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/GeoWithin.php
@@ -25,8 +25,7 @@ class GeoWithin extends AbstractSearchOperator implements ScoredSearchOperator
     private ?object $box     = null;
     private ?object $circle  = null;
 
-    /** @var array|MultiPolygon|Polygon|null */
-    private $geometry = null;
+    private array|MultiPolygon|Polygon|null $geometry = null;
 
     public function __construct(Search $search, string ...$path)
     {
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Near.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Near.php
index ad0e1d1d3..f244259c9 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Near.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Near.php
@@ -18,11 +18,9 @@ class Near extends AbstractSearchOperator implements ScoredSearchOperator
 {
     use ScoredSearchOperatorTrait;
 
-    /** @var int|float|UTCDateTime|array|Point|null */
-    private $origin;
+    private int|float|UTCDateTime|array|Point|null $origin;
 
-    /** @var int|float|null */
-    private $pivot;
+    private int|float|null $pivot;
 
     /** @var list<string> */
     private array $path;
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Range.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Range.php
index 62bed26a8..af3fc0cdd 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Range.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/Search/Range.php
@@ -15,14 +15,10 @@ class Range extends AbstractSearchOperator implements ScoredSearchOperator
 {
     use ScoredSearchOperatorTrait;
 
-    /** @var int|float|UTCDateTime|null */
-    private $gt = null;
-
-    /** @var int|float|UTCDateTime|null */
-    private $lt = null;
-
-    private bool $includeLowerBound = false;
-    private bool $includeUpperBound = false;
+    private int|float|UTCDateTime|null $gt = null;
+    private int|float|UTCDateTime|null $lt = null;
+    private bool $includeLowerBound        = false;
+    private bool $includeUpperBound        = false;
 
     /** @var list<string> */
     private array $path;
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/SetWindowFields.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/SetWindowFields.php
index 23c6748a4..9d8b43656 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/SetWindowFields.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/SetWindowFields.php
@@ -28,8 +28,7 @@
  */
 class SetWindowFields extends Stage
 {
-    /** @var mixed|Expr|null */
-    private $partitionBy = null;
+    private mixed $partitionBy = null;
 
     /** @var array<string, int> */
     private array $sortBy = [];
diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/UnionWith.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/UnionWith.php
index 8d651ef42..6c468c942 100644
--- a/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/UnionWith.php
+++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Stage/UnionWith.php
@@ -24,11 +24,8 @@
  */
 class UnionWith extends Stage
 {
-    /**
-     * @var array|Builder|null
-     * @psalm-var ?PipelineParamType
-     */
-    private $pipeline = null;
+    /** @psalm-var ?PipelineParamType */
+    private array|Builder|Stage|null $pipeline = null;
 
     public function __construct(Builder $builder, private DocumentManager $dm, private string $collection)
     {
diff --git a/lib/Doctrine/ODM/MongoDB/Event/DocumentNotFoundEventArgs.php b/lib/Doctrine/ODM/MongoDB/Event/DocumentNotFoundEventArgs.php
index 4a2534706..3a31a1ffb 100644
--- a/lib/Doctrine/ODM/MongoDB/Event/DocumentNotFoundEventArgs.php
+++ b/lib/Doctrine/ODM/MongoDB/Event/DocumentNotFoundEventArgs.php
@@ -13,8 +13,7 @@ final class DocumentNotFoundEventArgs extends LifecycleEventArgs
 {
     private bool $disableException = false;
 
-    /** @param mixed $identifier */
-    public function __construct(object $document, DocumentManager $dm, private $identifier)
+    public function __construct(object $document, DocumentManager $dm, private mixed $identifier)
     {
         parent::__construct($document, $dm);
     }
diff --git a/lib/Doctrine/ODM/MongoDB/Iterator/HydratingIterator.php b/lib/Doctrine/ODM/MongoDB/Iterator/HydratingIterator.php
index 3c4cd318f..94f5f219f 100644
--- a/lib/Doctrine/ODM/MongoDB/Iterator/HydratingIterator.php
+++ b/lib/Doctrine/ODM/MongoDB/Iterator/HydratingIterator.php
@@ -25,7 +25,7 @@
 final class HydratingIterator implements Iterator
 {
     /** @var Generator<mixed, array<string, mixed>>|null */
-    private $iterator;
+    private ?Generator $iterator;
 
     /**
      * @param Traversable<mixed, array<string, mixed>> $traversable
diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php
index d47bf6dca..34c13622d 100644
--- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php
+++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php
@@ -539,17 +539,14 @@
     /**
      * Allows users to specify a validation schema for the collection.
      *
-     * @var array|object|null
      * @psalm-var array<string, mixed>|object|null
      */
-    private $validator;
+    private array|object|null $validator = null;
 
     /**
      * Determines whether to error on invalid documents or just warn about the violations but allow invalid documents to be inserted.
-     *
-     * @var string
      */
-    private $validationAction = self::SCHEMA_VALIDATION_ACTION_ERROR;
+    private string $validationAction = self::SCHEMA_VALIDATION_ACTION_ERROR;
 
     /**
      * Determines how strictly MongoDB applies the validation rules to existing documents during an update.
@@ -813,11 +810,8 @@
 
     private ReflectionService $reflectionService;
 
-    /**
-     * @var string|null
-     * @psalm-var class-string|null
-     */
-    private $rootClass;
+    /** @var class-string|null */
+    private ?string $rootClass;
 
     /**
      * Initializes a new ClassMetadata instance that will hold the object-document mapping
diff --git a/lib/Doctrine/ODM/MongoDB/PersistentCollection/PersistentCollectionTrait.php b/lib/Doctrine/ODM/MongoDB/PersistentCollection/PersistentCollectionTrait.php
index cac58fb6a..1ccb2aab0 100644
--- a/lib/Doctrine/ODM/MongoDB/PersistentCollection/PersistentCollectionTrait.php
+++ b/lib/Doctrine/ODM/MongoDB/PersistentCollection/PersistentCollectionTrait.php
@@ -41,71 +41,61 @@ trait PersistentCollectionTrait
      *
      * @var array<TKey, T>
      */
-    private $snapshot = [];
+    private array $snapshot = [];
 
     /**
      * Collection's owning document
-     *
-     * @var object|null
      */
-    private $owner;
+    private ?object $owner = null;
 
     /**
-     * @var array|null
+     * @var array<string, mixed>|null
      * @psalm-var FieldMapping|null
      */
-    private $mapping;
+    private ?array $mapping = null;
 
     /**
      * Whether the collection is dirty and needs to be synchronized with the database
      * when the UnitOfWork that manages its persistent state commits.
-     *
-     * @var bool
      */
-    private $isDirty = false;
+    private bool $isDirty = false;
 
     /**
      * Whether the collection has already been initialized.
-     *
-     * @var bool
      */
-    private $initialized = true;
+    private bool $initialized = true;
 
     /**
      * The wrapped Collection instance.
      *
      * @var BaseCollection<TKey, T>
      */
-    private $coll;
+    private BaseCollection $coll;
 
     /**
      * The DocumentManager that manages the persistence of the collection.
-     *
-     * @var DocumentManager|null
      */
-    private $dm;
+    private DocumentManager $dm;
 
     /**
      * The UnitOfWork that manages the persistence of the collection.
-     *
-     * @var UnitOfWork
      */
-    private $uow;
+    private UnitOfWork $uow;
 
     /**
      * The raw mongo data that will be used to initialize this collection.
      *
      * @var mixed[]
      */
-    private $mongoData = [];
+    private array $mongoData = [];
 
     /**
      * Any hints to account for during reconstitution/lookup of the documents.
      *
-     * @var array
+     * @var array<int, mixed>
      * @psalm-var Hints
      */
-    private $hints = [];
+    private array $hints = [];
 
     public function setDocumentManager(DocumentManager $dm)
     {
@@ -292,7 +282,7 @@ public function getMapping()
 
     public function getTypeClass()
     {
-        if ($this->dm === null) {
+        if (! isset($this->dm)) {
             throw new MongoDBException('No DocumentManager is associated with this PersistentCollection, please set one using setDocumentManager method.');
         }
 
@@ -653,7 +643,7 @@ private function doAdd($value, $arrayAccess)
         $arrayAccess ? $this->coll->offsetSet(null, $value) : $this->coll->add($value);
         $this->changed();
 
-        if ($this->uow !== null && $this->isOrphanRemovalEnabled() && $value !== null) {
+        if (isset($this->uow) && $this->isOrphanRemovalEnabled() && $value !== null) {
             $this->uow->unscheduleOrphanRemoval($value);
         }
 
@@ -702,7 +692,7 @@ private function doSet($offset, $value, bool $arrayAccess): void
         $arrayAccess ? $this->coll->offsetSet($offset, $value) : $this->coll->set($offset, $value);
 
         // Handle orphanRemoval
-        if ($this->uow !== null && $this->isOrphanRemovalEnabled() && $value !== null) {
+        if (isset($this->uow) && $this->isOrphanRemovalEnabled() && $value !== null) {
             $this->uow->unscheduleOrphanRemoval($value);
         }
 
@@ -733,7 +723,7 @@ private function isOrphanRemovalEnabled(): bool
      */
     private function needsSchedulingForSynchronization(): bool
     {
-        return $this->owner && $this->dm && ! empty($this->mapping['isOwningSide'])
+        return $this->owner && isset($this->dm) && ! empty($this->mapping['isOwningSide'])
             && $this->dm->getClassMetadata(get_class($this->owner))->isChangeTrackingNotify();
     }
 
diff --git a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/StaticProxyFactory.php b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/StaticProxyFactory.php
index 08109dadc..737795fe4 100644
--- a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/StaticProxyFactory.php
+++ b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/StaticProxyFactory.php
@@ -25,11 +25,8 @@
  */
 final class StaticProxyFactory implements ProxyFactory
 {
-    /** @var UnitOfWork The UnitOfWork this factory is bound to. */
     private UnitOfWork $uow;
-
     private LifecycleEventManager $lifecycleEventManager;
-
     private LazyLoadingGhostFactory $proxyFactory;
 
     public function __construct(DocumentManager $documentManager)
diff --git a/lib/Doctrine/ODM/MongoDB/Query/Builder.php b/lib/Doctrine/ODM/MongoDB/Query/Builder.php
index a01629873..7e249a5a9 100644
--- a/lib/Doctrine/ODM/MongoDB/Query/Builder.php
+++ b/lib/Doctrine/ODM/MongoDB/Query/Builder.php
@@ -85,10 +85,9 @@ class Builder
     /**
      * Array containing the query data.
      *
-     * @var array
      * @psalm-var QueryShape
      */
-    private $query = ['type' => Query::TYPE_FIND];
+    private array $query = ['type' => Query::TYPE_FIND];
 
     /**
      * The Expr instance used for building this query.
diff --git a/lib/Doctrine/ODM/MongoDB/Query/Expr.php b/lib/Doctrine/ODM/MongoDB/Query/Expr.php
index cd0d81412..ca96ca4fe 100644
--- a/lib/Doctrine/ODM/MongoDB/Query/Expr.php
+++ b/lib/Doctrine/ODM/MongoDB/Query/Expr.php
@@ -40,10 +40,8 @@ class Expr
 {
     /**
      * The query criteria array.
-     *
-     * @var array<string, mixed>|mixed
      */
-    private $query = [];
+    private mixed $query = [];
 
     /**
      * The "new object" array containing either a full document or a number of
diff --git a/lib/Doctrine/ODM/MongoDB/Query/ReferencePrimer.php b/lib/Doctrine/ODM/MongoDB/Query/ReferencePrimer.php
index b754661dd..ab39bff8a 100644
--- a/lib/Doctrine/ODM/MongoDB/Query/ReferencePrimer.php
+++ b/lib/Doctrine/ODM/MongoDB/Query/ReferencePrimer.php
@@ -4,7 +4,6 @@
 
 namespace Doctrine\ODM\MongoDB\Query;
 
-use Closure;
 use Doctrine\ODM\MongoDB\DocumentManager;
 use Doctrine\ODM\MongoDB\Iterator\Iterator;
 use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
@@ -43,13 +42,6 @@
  */
 final class ReferencePrimer
 {
-    /**
-     * The default primer Closure.
-     *
-     * @var Closure
-     */
-    private $defaultPrimer;
-
     /**
      * The DocumentManager instance.
      */
@@ -64,23 +56,6 @@ public function __construct(DocumentManager $dm, UnitOfWork $uow)
     {
         $this->dm  = $dm;
         $this->uow = $uow;
-
-        $this->defaultPrimer = static function (DocumentManager $dm, ClassMetadata $class, array $ids, array $hints): void {
-            if ($class->identifier === null) {
-                return;
-            }
-
-            $qb = $dm->createQueryBuilder($class->name)
-                ->field($class->identifier)->in($ids);
-
-            if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
-                $qb->setReadPreference($hints[Query::HINT_READ_PREFERENCE]);
-            }
-
-            $iterator = $qb->getQuery()->execute();
-            assert($iterator instanceof Iterator);
-            $iterator->toArray();
-        };
     }
 
     /**
@@ -124,7 +99,7 @@ public function primeReferences(ClassMetadata $class, $documents, string $fieldN
             throw new LogicException(sprintf('Field "%s" is an identifier reference without a target document class in class "%s"', $fieldName, $class->name));
         }
 
-        $primer     = $primer ?: $this->defaultPrimer;
+        $primer     = $primer ?: self::defaultPrimer(...);
         $groupedIds = [];
 
         foreach ($documents as $document) {
@@ -276,4 +251,26 @@ private function addManyReferences(PersistentCollectionInterface $persistentColl
             $groupedIds[$className][serialize($id)] = $id;
         }
     }
+
+    /**
+     * @param list<mixed>       $ids
+     * @param array<int, mixed> $hints
+     */
+    private static function defaultPrimer(DocumentManager $dm, ClassMetadata $class, array $ids, array $hints): void
+    {
+        if ($class->identifier === null) {
+            return;
+        }
+
+        $qb = $dm->createQueryBuilder($class->name)
+            ->field($class->identifier)->in($ids);
+
+        if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
+            $qb->setReadPreference($hints[Query::HINT_READ_PREFERENCE]);
+        }
+
+        $iterator = $qb->getQuery()->execute();
+        assert($iterator instanceof Iterator);
+        $iterator->toArray();
+    }
 }
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 736b03a30..ae58dfd95 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -495,58 +495,28 @@ parameters:
 			count: 1
 			path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php
 
-		-
-			message: "#^Access to offset 'embedded' on an unknown class Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\.$#"
-			count: 1
-			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
-
-		-
-			message: "#^Access to offset 'isOwningSide' on an unknown class Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\.$#"
-			count: 3
-			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
-
-		-
-			message: "#^Access to offset 'orphanRemoval' on an unknown class Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\.$#"
-			count: 1
-			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
-
-		-
-			message: "#^Access to offset 'reference' on an unknown class Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\.$#"
-			count: 1
-			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
-
-		-
-			message: "#^Access to offset 'strategy' on an unknown class Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\.$#"
-			count: 4
-			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
-
-		-
-			message: "#^Access to offset 'targetDocument' on an unknown class Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\.$#"
-			count: 2
-			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
-
 		-
 			message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:add\\(\\) with return type void returns true but should not return anything\\.$#"
 			count: 1
 			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
 
 		-
-			message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:getHints\\(\\) should return array\\<int, mixed\\> but returns Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\Hints\\.$#"
+			message: "#^PHPDoc tag @var for property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:\\$hints with type Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\Hints is incompatible with native type array\\.$#"
 			count: 1
 			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
 
 		-
-			message: "#^Method Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:getMapping\\(\\) should return array\\{type\\: string, fieldName\\: string, name\\: string, isCascadeRemove\\: bool, isCascadePersist\\: bool, isCascadeRefresh\\: bool, isCascadeMerge\\: bool, isCascadeDetach\\: bool, \\.\\.\\.\\} but returns Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\|null\\.$#"
+			message: "#^PHPDoc tag @var for property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:\\$mapping with type Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\|null is not subtype of native type array\\|null\\.$#"
 			count: 1
 			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
 
 		-
-			message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:\\$hints \\(Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\Hints\\) does not accept default value of type array\\.$#"
+			message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:\\$hints has unknown class Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\Hints as its type\\.$#"
 			count: 1
 			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
 
 		-
-			message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:\\$hints has unknown class Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\Hints as its type\\.$#"
+			message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:\\$hints type has no value type specified in iterable type array\\.$#"
 			count: 1
 			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
 
@@ -556,12 +526,12 @@ parameters:
 			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
 
 		-
-			message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\<TKey of \\(int\\|string\\),T of object\\>\\:\\:\\$hints \\(Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\Hints\\) does not accept array\\<int, mixed\\>\\.$#"
+			message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\:\\:\\$mapping type has no value type specified in iterable type array\\.$#"
 			count: 1
 			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
 
 		-
-			message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\<TKey of \\(int\\|string\\),T of object\\>\\:\\:\\$mapping \\(Doctrine\\\\ODM\\\\MongoDB\\\\PersistentCollection\\\\FieldMapping\\|null\\) does not accept array\\<string, array\\<int\\|string, mixed\\>\\|bool\\|int\\|string\\|null\\>\\.$#"
+			message: "#^Right side of && is always true\\.$#"
 			count: 1
 			path: lib/Doctrine/ODM/MongoDB/PersistentCollection.php
 
@@ -865,16 +835,6 @@ parameters:
 			count: 1
 			path: tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH1990Test.php
 
-		-
-			message: "#^Dead catch \\- MongoDB\\\\Driver\\\\Exception\\\\BulkWriteException is never thrown in the try block\\.$#"
-			count: 1
-			path: tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH580Test.php
-
-		-
-			message: "#^Unreachable statement \\- code above always terminates\\.$#"
-			count: 1
-			path: tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH580Test.php
-
 		-
 			message: "#^Property Doctrine\\\\ODM\\\\MongoDB\\\\Tests\\\\Functional\\\\Ticket\\\\GH921Post\\:\\:\\$id is never written, only read\\.$#"
 			count: 1
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 366e569ae..110172f14 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -142,7 +142,15 @@
   <file src="lib/Doctrine/ODM/MongoDB/PersistentCollection/PersistentCollectionTrait.php">
     <RedundantCondition>
       <code><![CDATA[is_object($this->coll)]]></code>
+      <code><![CDATA[isset($this->uow) && $this->isOrphanRemovalEnabled() && $value !== null]]></code>
+      <code><![CDATA[isset($this->uow) && $this->isOrphanRemovalEnabled() && $value !== null]]></code>
     </RedundantCondition>
+    <RedundantPropertyInitializationCheck>
+      <code><![CDATA[$this->owner && isset($this->dm)]]></code>
+      <code><![CDATA[isset($this->dm)]]></code>
+      <code><![CDATA[isset($this->uow)]]></code>
+      <code><![CDATA[isset($this->uow)]]></code>
+    </RedundantPropertyInitializationCheck>
   </file>
   <file src="lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php">
     <RedundantFunctionCall>

From 369abc5b7c068db3bd15098d0ef85dc1f86e8c26 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Tue, 2 Jul 2024 13:34:35 +0200
Subject: [PATCH 11/17] Fix wording (#2667)

---
 docs/en/cookbook/resolve-target-document-listener.rst | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/en/cookbook/resolve-target-document-listener.rst b/docs/en/cookbook/resolve-target-document-listener.rst
index 9bff06d13..fc108a6df 100644
--- a/docs/en/cookbook/resolve-target-document-listener.rst
+++ b/docs/en/cookbook/resolve-target-document-listener.rst
@@ -68,7 +68,7 @@ An ``Invoice`` document in the ``InvoiceModule``:
         public InvoiceSubjectInterface $subject;
     }
 
-This class has de reference to the ``InvoiceSubjectInterface``. This interface
+This class has a reference to an ``InvoiceSubjectInterface``. This interface
 contains the list of methods that the ``InvoiceModule`` will need to access on
 the subject so that we are sure that we have access to those methods. This
 interface is also defined in the ``InvoiceModule``:

From 620120edb44fde3afae1d2642a2afec92b866035 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Wed, 3 Jul 2024 14:27:22 +0200
Subject: [PATCH 12/17] Review basic mapping (#2668)

---
 docs/en/reference/annotations-reference.rst |  2 +-
 docs/en/reference/basic-mapping.rst         | 48 ++++++++++++---------
 docs/en/reference/xml-mapping.rst           |  9 +---
 3 files changed, 29 insertions(+), 30 deletions(-)

diff --git a/docs/en/reference/annotations-reference.rst b/docs/en/reference/annotations-reference.rst
index f099ed42f..b2786dfa4 100644
--- a/docs/en/reference/annotations-reference.rst
+++ b/docs/en/reference/annotations-reference.rst
@@ -20,7 +20,7 @@ Change the metadata driver configuration to use the ``AttributeDriver``:
     - $config->setMetadataDriverImpl(AnnotationsDriver::create(__DIR__ . '/Documents'));
     + $config->setMetadataDriverImpl(AttributeDriver::create(__DIR__ . '/Documents'));
 
-Replace the `@ORM\Document` annotations with the `#[ORM\Document]` attribute.
+Replace the ``@ORM\Document`` annotations with the ``#[ORM\Document]`` attribute.
 
 .. code-block:: diff
 
diff --git a/docs/en/reference/basic-mapping.rst b/docs/en/reference/basic-mapping.rst
index 63caef2f6..fbbe373de 100644
--- a/docs/en/reference/basic-mapping.rst
+++ b/docs/en/reference/basic-mapping.rst
@@ -11,22 +11,18 @@ Mapping Drivers
 Doctrine provides several different ways for specifying object
 document mapping metadata:
 
--  Attributes
--  XML
+-  `Attributes <annotations-reference>`_
+-  `XML <xml-mapping>`_
 -  Raw PHP Code
 
 .. note::
 
-    If you're wondering which mapping driver gives the best
-    performance, the answer is: None. Once the metadata of a class has
-    been read from the source (attributes or xml) it is stored
-    in an instance of the
-    ``Doctrine\ODM\MongoDB\Mapping\ClassMetadata`` class and these
-    instances are stored in the metadata cache. Therefore at the end of
-    the day all drivers perform equally well. If you're not using a
-    metadata cache (not recommended!) then the XML driver might have a
-    slight edge in performance due to the powerful native XML support
-    in PHP.
+    If you're wondering which mapping driver gives the best performance, the
+    answer is: None. Once the metadata of a class has been read from the source
+    (Attributes or XML) it is stored in an instance of the
+    ``Doctrine\ODM\MongoDB\Mapping\ClassMetadata`` class and these instances are
+    stored in the metadata cache. Therefore all drivers perform equally well at
+    runtime.
 
 Introduction to Attributes
 --------------------------
@@ -61,6 +57,8 @@ to be designated as a document. This can be done through the
 
         namespace Documents;
 
+        use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+
         #[Document]
         class User
         {
@@ -90,6 +88,8 @@ option as follows:
 
         namespace Documents;
 
+        use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+
         #[Document(db: 'my_db', collection: 'users')]
         class User
         {
@@ -243,11 +243,14 @@ Here is an example:
 
         namespace Documents;
 
+        use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+        use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+
         #[Document]
         class User
         {
             #[Id]
-            private string $id;
+            public string $id;
         }
 
     .. code-block:: xml
@@ -280,16 +283,16 @@ Here is an example how to manually set a string identifier for your documents:
 
         <?php
 
+        namespace Documents;
+
+        use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+        use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;
+
         #[Document]
         class MyPersistentClass
         {
             #[Id(strategy: 'NONE', type: 'string')]
-            private string $id;
-
-            public function setId(string $id): void
-            {
-                $this->id = $id;
-            }
+            public string $id;
 
             //...
         }
@@ -406,13 +409,16 @@ Example:
 
         namespace Documents;
 
+        use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
+        use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;
+
         #[Document]
         class User
         {
             // ...
 
             #[Field(type: 'string')]
-            private string $username;
+            public string $username;
         }
 
     .. code-block:: xml
@@ -445,7 +451,7 @@ as follows:
         class User
         {
             #[Field(name: 'db_name')]
-            private string $name;
+            public string $name;
         }
 
     .. code-block:: xml
diff --git a/docs/en/reference/xml-mapping.rst b/docs/en/reference/xml-mapping.rst
index 008146879..f875eec37 100644
--- a/docs/en/reference/xml-mapping.rst
+++ b/docs/en/reference/xml-mapping.rst
@@ -7,7 +7,7 @@ form of XML documents.
 The XML driver is backed by an XML Schema document that describes
 the structure of a mapping document. The most recent version of the
 XML Schema document is available online at
-`http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd <http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd>`_.
+`http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd <https://www.doctrine-project.org/schemas/odm/doctrine-mongo-mapping.xsd>`_.
 The most convenient way to work with XML mapping files is to use an
 IDE/editor that can provide code-completion based on such an XML
 Schema document. The following is an outline of a XML mapping
@@ -25,13 +25,6 @@ trunk.
 
     </doctrine-mongo-mapping>
 
-.. note::
-
-    If you do not want to use latest XML Schema document please use link like
-    `http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping-1.0.0-BETA12.xsd <http://doctrine-project.org/schemas/odm/doctrine-mongo-mapping-1.0.0-BETA12.xsd>`_.
-    You can change ``1.0.0-BETA12`` part of the URL to
-    `any other ODM version <https://github.com/doctrine/mongodb-odm/releases>`_.
-
 The XML mapping document of a class is loaded on-demand the first
 time it is requested and subsequently stored in the metadata cache.
 In order to work, this requires certain conventions:

From e9314e2def860e9318ab7367c45ca217f50aa700 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Gr=C3=A9goire=20Paris?= <postmaster@greg0ire.fr>
Date: Fri, 5 Jul 2024 09:16:54 +0200
Subject: [PATCH 13/17] Label PRs about GH actions with "CI" (#2632)

People that read the changelog might get confused if they find those
under "dependencies".
---
 .github/dependabot.yml | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 5ace4600a..15bd17299 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,3 +4,5 @@ updates:
     directory: "/"
     schedule:
       interval: "weekly"
+    labels:
+      - "CI"

From dbdecfa5343f28d5fe634b70c1a936b8e9439c1e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= <jerome@tamarelle.net>
Date: Fri, 12 Jul 2024 09:05:25 +0200
Subject: [PATCH 14/17] Fix typo in code example (#2670)

---
 docs/en/reference/sharding.rst | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docs/en/reference/sharding.rst b/docs/en/reference/sharding.rst
index 1d63388c5..41126b942 100644
--- a/docs/en/reference/sharding.rst
+++ b/docs/en/reference/sharding.rst
@@ -18,7 +18,7 @@ the document as well as an appropriate index:
         <?php
 
         #[Document]
-        #Index(keys: ['username' => 'asc'])]
+        #[Index(keys: ['username' => 'asc'])]
         #[ShardKey(keys: ['username' => 'asc'])]
         class User
         {

From 10ec1da1cea9df541cd530dbcc4a36dc3214423f Mon Sep 17 00:00:00 2001
From: Andreas Braun <git@alcaeus.org>
Date: Mon, 26 Aug 2024 04:38:34 -0400
Subject: [PATCH 15/17] Gracefully handle search index exceptions if possible
 (#2671)

* Gracefully handle search index exceptions if possible

* Handle search index exceptions on older server versions
---
 lib/Doctrine/ODM/MongoDB/SchemaManager.php    |  43 ++++++-
 .../ODM/MongoDB/Tests/SchemaManagerTest.php   | 107 ++++++++++++++++++
 2 files changed, 147 insertions(+), 3 deletions(-)

diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php
index 669724d47..c7d7f9b3b 100644
--- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php
+++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php
@@ -8,6 +8,7 @@
 use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface;
 use Doctrine\ODM\MongoDB\Repository\ViewRepository;
 use InvalidArgumentException;
+use MongoDB\Driver\Exception\CommandException;
 use MongoDB\Driver\Exception\RuntimeException;
 use MongoDB\Driver\Exception\ServerException;
 use MongoDB\Driver\WriteConcern;
@@ -32,6 +33,7 @@
 use function iterator_to_array;
 use function ksort;
 use function sprintf;
+use function str_contains;
 
 /**
  * @psalm-import-type IndexMapping from ClassMetadata
@@ -44,6 +46,7 @@ final class SchemaManager
     private const GRIDFS_CHUNKS_COLLECTION_INDEX = ['filename' => 1, 'uploadDate' => 1];
 
     private const CODE_SHARDING_ALREADY_INITIALIZED = 23;
+    private const CODE_COMMAND_NOT_SUPPORTED        = 115;
 
     private const ALLOWED_MISSING_INDEX_OPTIONS = [
         'background',
@@ -408,8 +411,19 @@ public function updateDocumentSearchIndexes(string $documentName): void
         $searchIndexes = $class->getSearchIndexes();
         $collection    = $this->dm->getDocumentCollection($class->name);
 
-        $definedNames  = array_column($searchIndexes, 'name');
-        $existingNames = array_column(iterator_to_array($collection->listSearchIndexes()), 'name');
+        $definedNames = array_column($searchIndexes, 'name');
+        try {
+            $existingNames = array_column(iterator_to_array($collection->listSearchIndexes()), 'name');
+        } catch (CommandException $e) {
+            /* If $listSearchIndexes doesn't exist, only throw if search indexes have been defined.
+             * If no search indexes are defined and the server doesn't support search indexes, there's
+             * nothing for us to do here and we can safely return */
+            if ($definedNames === [] && $this->isSearchIndexCommandException($e)) {
+                return;
+            }
+
+            throw $e;
+        }
 
         foreach (array_diff($existingNames, $definedNames) as $name) {
             $collection->dropSearchIndex($name);
@@ -450,7 +464,18 @@ public function deleteDocumentSearchIndexes(string $documentName): void
 
         $collection = $this->dm->getDocumentCollection($class->name);
 
-        foreach ($collection->listSearchIndexes() as $searchIndex) {
+        try {
+            $searchIndexes = $collection->listSearchIndexes();
+        } catch (CommandException $e) {
+            // If the server does not support search indexes, there are no indexes to remove in any case
+            if ($this->isSearchIndexCommandException($e)) {
+                return;
+            }
+
+            throw $e;
+        }
+
+        foreach ($searchIndexes as $searchIndex) {
             $collection->dropSearchIndex($searchIndex['name']);
         }
     }
@@ -1029,4 +1054,16 @@ private function getWriteOptions(?int $maxTimeMs = null, ?WriteConcern $writeCon
 
         return $options;
     }
+
+    private function isSearchIndexCommandException(CommandException $e): bool
+    {
+        // MongoDB 6.0.7+ and 7.0+: "Search indexes are only available on Atlas"
+        if ($e->getCode() === self::CODE_COMMAND_NOT_SUPPORTED && str_contains($e->getMessage(), 'Search index')) {
+            return true;
+        }
+
+        // Older server versions don't support $listSearchIndexes
+        // We don't check for an error code here as the code is not documented and we can't rely on it
+        return str_contains($e->getMessage(), 'Unrecognized pipeline stage name: \'$listSearchIndexes\'');
+    }
 }
diff --git a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php
index a8418d009..83f4dd559 100644
--- a/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php
+++ b/tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php
@@ -26,6 +26,7 @@
 use MongoDB\Client;
 use MongoDB\Collection;
 use MongoDB\Database;
+use MongoDB\Driver\Exception\CommandException;
 use MongoDB\Driver\WriteConcern;
 use MongoDB\GridFS\Bucket;
 use MongoDB\Model\CollectionInfo;
@@ -420,6 +421,27 @@ public function testCreateDocumentSearchIndexes(): void
         $this->schemaManager->createDocumentSearchIndexes(CmsArticle::class);
     }
 
+    public function testCreateDocumentSearchIndexesNotSupported(): void
+    {
+        $exception = $this->createSearchIndexCommandException();
+
+        $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())
+                    ->willThrowException($exception);
+            } else {
+                $collection->expects($this->never())->method('createSearchIndexes');
+            }
+        }
+
+        $this->expectExceptionObject($exception);
+        $this->schemaManager->createDocumentSearchIndexes(CmsArticle::class);
+    }
+
     public function testUpdateDocumentSearchIndexes(): void
     {
         $collectionName = $this->dm->getClassMetadata(CmsArticle::class)->getCollection();
@@ -443,6 +465,66 @@ public function testUpdateDocumentSearchIndexes(): void
         $this->schemaManager->updateDocumentSearchIndexes(CmsArticle::class);
     }
 
+    public function testUpdateDocumentSearchIndexesNotSupportedForClassWithoutSearchIndexes(): void
+    {
+        // Class has no search indexes, so if the server doesn't support them we assume everything is fine
+        $collectionName = $this->dm->getClassMetadata(CmsProduct::class)->getCollection();
+        $collection     = $this->documentCollections[$collectionName];
+        $collection
+            ->expects($this->once())
+            ->method('listSearchIndexes')
+            ->willThrowException($this->createSearchIndexCommandException());
+        $collection
+            ->expects($this->never())
+            ->method('dropSearchIndex');
+        $collection
+            ->expects($this->never())
+            ->method('updateSearchIndex');
+
+        $this->schemaManager->updateDocumentSearchIndexes(CmsProduct::class);
+    }
+
+    public function testUpdateDocumentSearchIndexesNotSupportedForClassWithoutSearchIndexesOnOlderServers(): void
+    {
+        // Class has no search indexes, so if the server doesn't support them we assume everything is fine
+        $collectionName = $this->dm->getClassMetadata(CmsProduct::class)->getCollection();
+        $collection     = $this->documentCollections[$collectionName];
+        $collection
+            ->expects($this->once())
+            ->method('listSearchIndexes')
+            ->willThrowException($this->createSearchIndexCommandExceptionForOlderServers());
+        $collection
+            ->expects($this->never())
+            ->method('dropSearchIndex');
+        $collection
+            ->expects($this->never())
+            ->method('updateSearchIndex');
+
+        $this->schemaManager->updateDocumentSearchIndexes(CmsProduct::class);
+    }
+
+    public function testUpdateDocumentSearchIndexesNotSupportedForClassWithSearchIndexes(): void
+    {
+        $exception = $this->createSearchIndexCommandException();
+
+        // This class has search indexes, so we do expect an exception
+        $collectionName = $this->dm->getClassMetadata(CmsArticle::class)->getCollection();
+        $collection     = $this->documentCollections[$collectionName];
+        $collection
+            ->expects($this->once())
+            ->method('listSearchIndexes')
+            ->willThrowException($exception);
+        $collection
+            ->expects($this->never())
+            ->method('dropSearchIndex');
+        $collection
+            ->expects($this->never())
+            ->method('updateSearchIndex');
+
+        $this->expectExceptionObject($exception);
+        $this->schemaManager->updateDocumentSearchIndexes(CmsArticle::class);
+    }
+
     public function testDeleteDocumentSearchIndexes(): void
     {
         $collectionName = $this->dm->getClassMetadata(CmsArticle::class)->getCollection();
@@ -459,6 +541,21 @@ public function testDeleteDocumentSearchIndexes(): void
         $this->schemaManager->deleteDocumentSearchIndexes(CmsArticle::class);
     }
 
+    public function testDeleteDocumentSearchIndexesNotSupported(): void
+    {
+        $collectionName = $this->dm->getClassMetadata(CmsArticle::class)->getCollection();
+        $collection     = $this->documentCollections[$collectionName];
+        $collection
+            ->expects($this->once())
+            ->method('listSearchIndexes')
+            ->willThrowException($this->createSearchIndexCommandException());
+        $collection
+            ->expects($this->never())
+            ->method('dropSearchIndex');
+
+        $this->schemaManager->deleteDocumentSearchIndexes(CmsArticle::class);
+    }
+
     public function testUpdateValidators(): void
     {
         $dbCommands = [];
@@ -1239,4 +1336,14 @@ private function writeOptions(array $expectedWriteOptions): Constraint
             return true;
         });
     }
+
+    private function createSearchIndexCommandException(): CommandException
+    {
+        return new CommandException('PlanExecutor error during aggregation :: caused by :: Search index commands are only supported with Atlas.', 115);
+    }
+
+    private function createSearchIndexCommandExceptionForOlderServers(): CommandException
+    {
+        return new CommandException('Unrecognized pipeline stage name: \'$listSearchIndexes\'', 40234);
+    }
 }

From 511a476f3e024cd8ed31dc5385b36e28725d0f59 Mon Sep 17 00:00:00 2001
From: Ali Jafari <ali@jafari.li>
Date: Fri, 6 Sep 2024 13:43:57 +0200
Subject: [PATCH 16/17] Gracefully handle search index exceptions on non
 MongoDB Atlas (#2673)

* Gracefully handle search index exceptions on non MongoDB Atlas

* Fix php Coding Standards pipeline
---
 lib/Doctrine/ODM/MongoDB/SchemaManager.php | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/lib/Doctrine/ODM/MongoDB/SchemaManager.php b/lib/Doctrine/ODM/MongoDB/SchemaManager.php
index c7d7f9b3b..b142f1902 100644
--- a/lib/Doctrine/ODM/MongoDB/SchemaManager.php
+++ b/lib/Doctrine/ODM/MongoDB/SchemaManager.php
@@ -1062,6 +1062,11 @@ private function isSearchIndexCommandException(CommandException $e): bool
             return true;
         }
 
+        // MongoDB 6.0.7+ and 7.0+: "$listSearchIndexes stage is only allowed on MongoDB Atlas"
+        if ($e->getMessage() === '$listSearchIndexes stage is only allowed on MongoDB Atlas') {
+            return true;
+        }
+
         // Older server versions don't support $listSearchIndexes
         // We don't check for an error code here as the code is not documented and we can't rely on it
         return str_contains($e->getMessage(), 'Unrecognized pipeline stage name: \'$listSearchIndexes\'');

From 3c9b1e8668343408cb70c9c9cd724b834d95438a Mon Sep 17 00:00:00 2001
From: Baptiste Lafontaine <magnetik@users.noreply.github.com>
Date: Fri, 20 Sep 2024 14:31:14 +0200
Subject: [PATCH 17/17] Fixes changeset being empty when datetime change is sub
 second (#2676)

* Update UnitOfWork.php

* Add test (and remove fix to see failure)

* Add comment

* add fix

* phpcs & use fix

* fix typo

* Fix phpcs
---
 lib/Doctrine/ODM/MongoDB/UnitOfWork.php           |  8 +++-----
 .../ODM/MongoDB/Tests/Functional/DateTest.php     | 15 +++++++++++++++
 2 files changed, 18 insertions(+), 5 deletions(-)

diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php
index 71f5f925f..eb9591f99 100644
--- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php
+++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php
@@ -24,7 +24,6 @@
 use Doctrine\Persistence\NotifyPropertyChanged;
 use Doctrine\Persistence\PropertyChangedListener;
 use InvalidArgumentException;
-use MongoDB\BSON\UTCDateTime;
 use MongoDB\Driver\Exception\RuntimeException;
 use MongoDB\Driver\Session;
 use MongoDB\Driver\WriteConcern;
@@ -835,10 +834,9 @@ private function computeOrRecomputeChangeSet(ClassMetadata $class, object $docum
                     $dbOrgValue    = $dateType->convertToDatabaseValue($orgValue);
                     $dbActualValue = $dateType->convertToDatabaseValue($actualValue);
 
-                    $orgTimestamp    = $dbOrgValue instanceof UTCDateTime ? $dbOrgValue->toDateTime()->getTimestamp() : null;
-                    $actualTimestamp = $dbActualValue instanceof UTCDateTime ? $dbActualValue->toDateTime()->getTimestamp() : null;
-
-                    if ($orgTimestamp === $actualTimestamp) {
+                    // We rely on loose comparison to compare every field (including microseconds)
+                    // phpcs:ignore SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator
+                    if ($dbOrgValue == $dbActualValue) {
                         continue;
                     }
                 }
diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/DateTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/DateTest.php
index 76fecd68e..35aa83b83 100644
--- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/DateTest.php
+++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/DateTest.php
@@ -69,6 +69,21 @@ public static function provideEquivalentDates(): array
         ];
     }
 
+    public function testDateInstanceChangeWhenValueDifferenceIsSubSecond(): void
+    {
+        $user = new User();
+        $user->setCreatedAt(new UTCDateTime(100000000000));
+        $this->dm->persist($user);
+        $this->dm->flush();
+        $this->dm->clear();
+
+        $user = $this->dm->getRepository($user::class)->findOneBy([]);
+        $user->setCreatedAt(new UTCDateTime(100000000123));
+        $this->dm->getUnitOfWork()->computeChangeSets();
+        $changeset = $this->dm->getUnitOfWork()->getDocumentChangeSet($user);
+        self::assertNotEmpty($changeset);
+    }
+
     public function testDateInstanceValueChangeDoesCauseUpdateIfValueIsTheSame(): void
     {
         $user = new User();