From 81897149be49edb29b785aef4bb2704a3c4c0848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 27 Sep 2025 00:17:14 +0200 Subject: [PATCH 01/31] Import all PropertyAccessor classes from ORM https://github.com/doctrine/orm/blob/4.0.x/src/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php --- .../EmbeddablePropertyAccessor.php | 53 ++++++++++++ .../EnumPropertyAccessor.php | 85 +++++++++++++++++++ .../PropertyAccessors/PropertyAccessor.php | 27 ++++++ .../PropertyAccessorFactory.php | 28 ++++++ .../RawValuePropertyAccessor.php | 57 +++++++++++++ .../PropertyAccessors/ReadonlyAccessor.php | 53 ++++++++++++ .../TypedNoDefaultPropertyAccessor.php | 69 +++++++++++++++ 7 files changed, 372 insertions(+) create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EnumPropertyAccessor.php create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessor.php create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ReadonlyAccessor.php create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/TypedNoDefaultPropertyAccessor.php diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php new file mode 100644 index 000000000..5191f046e --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php @@ -0,0 +1,53 @@ +parent->getValue($object); + + if ($embeddedObject === null) { + self::$instantiator ??= new Instantiator(); + + $embeddedObject = self::$instantiator->instantiate($this->embeddedClass); + + $this->parent->setValue($object, $embeddedObject); + } + + $this->child->setValue($embeddedObject, $value); + } + + public function getValue(object $object): mixed + { + $embeddedObject = $this->parent->getValue($object); + + if ($embeddedObject === null) { + return null; + } + + return $this->child->getValue($embeddedObject); + } + + public function getUnderlyingReflector(): ReflectionProperty + { + return $this->child->getUnderlyingReflector(); + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EnumPropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EnumPropertyAccessor.php new file mode 100644 index 000000000..eba80a3be --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EnumPropertyAccessor.php @@ -0,0 +1,85 @@ + $enumType */ + public function __construct(private PropertyAccessor $parent, private string $enumType) + { + } + + public function setValue(object $object, mixed $value): void + { + if ($value !== null) { + $value = $this->toEnum($value); + } + + $this->parent->setValue($object, $value); + } + + public function getValue(object $object): mixed + { + $enum = $this->parent->getValue($object); + + if ($enum === null) { + return null; + } + + return $this->fromEnum($enum); + } + + /** + * @param BackedEnum|BackedEnum[] $enum + * + * @return ($enum is BackedEnum ? (string|int) : (string[]|int[])) + */ + private function fromEnum($enum) // phpcs:ignore + { + if (is_array($enum)) { + return array_map(static function (BackedEnum $enum) { + return $enum->value; + }, $enum); + } + + return $enum->value; + } + + /** + * @phpstan-param BackedEnum|BackedEnum[]|int|string|int[]|string[] $value + * + * @return ($value is int|string|BackedEnum ? BackedEnum : BackedEnum[]) + */ + private function toEnum($value): BackedEnum|array + { + if ($value instanceof BackedEnum) { + return $value; + } + + if (is_array($value)) { + $v = reset($value); + if ($v instanceof BackedEnum) { + return $value; + } + + return array_map([$this->enumType, 'from'], $value); + } + + return $this->enumType::from($value); + } + + public function getUnderlyingReflector(): ReflectionProperty + { + return $this->parent->getUnderlyingReflector(); + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessor.php new file mode 100644 index 000000000..da46932df --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessor.php @@ -0,0 +1,27 @@ +hasType() && ! $reflectionProperty->getType()->allowsNull()) { + $accessor = new TypedNoDefaultPropertyAccessor($accessor, $reflectionProperty); + } + + if ($reflectionProperty->isReadOnly()) { + $accessor = new ReadonlyAccessor($accessor, $reflectionProperty); + } + + return $accessor; + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php new file mode 100644 index 000000000..151b25e01 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php @@ -0,0 +1,57 @@ +getName(); + $key = $reflectionProperty->isPrivate() ? "\0" . ltrim($reflectionProperty->getDeclaringClass()->getName(), '\\') . "\0" . $name : ($reflectionProperty->isProtected() ? "\0*\0" . $name : $name); + + return new self($reflectionProperty, $key); + } + + private function __construct(private ReflectionProperty $reflectionProperty, private string $key) + { + } + + public function setValue(object $object, mixed $value): void + { + if (! ($object instanceof InternalProxy && ! $object->__isInitialized())) { + $this->reflectionProperty->setRawValue($object, $value); + + return; + } + + $object->__setInitialized(true); + + $this->reflectionProperty->setRawValue($object, $value); + + $object->__setInitialized(false); + } + + public function getValue(object $object): mixed + { + return ((array) $object)[$this->key] ?? null; + } + + public function getUnderlyingReflector(): ReflectionProperty + { + return $this->reflectionProperty; + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ReadonlyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ReadonlyAccessor.php new file mode 100644 index 000000000..4e255aae1 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ReadonlyAccessor.php @@ -0,0 +1,53 @@ +reflectionProperty->isReadOnly()) { + throw new InvalidArgumentException(sprintf( + '%s::$%s must be readonly property', + $this->reflectionProperty->getDeclaringClass()->getName(), + $this->reflectionProperty->getName(), + )); + } + } + + public function setValue(object $object, mixed $value): void + { + if (! $this->reflectionProperty->isInitialized($object)) { + $this->parent->setValue($object, $value); + + return; + } + + if ($this->parent->getValue($object) !== $value) { + throw new LogicException(sprintf( + 'Attempting to change readonly property %s::$%s.', + $this->reflectionProperty->getDeclaringClass()->getName(), + $this->reflectionProperty->getName(), + )); + } + } + + public function getValue(object $object): mixed + { + return $this->parent->getValue($object); + } + + public function getUnderlyingReflector(): ReflectionProperty + { + return $this->reflectionProperty; + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/TypedNoDefaultPropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/TypedNoDefaultPropertyAccessor.php new file mode 100644 index 000000000..027c95644 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/TypedNoDefaultPropertyAccessor.php @@ -0,0 +1,69 @@ +reflectionProperty->hasType()) { + throw new InvalidArgumentException(sprintf( + '%s::$%s must have a type when used with TypedNoDefaultPropertyAccessor', + $this->reflectionProperty->getDeclaringClass()->getName(), + $this->reflectionProperty->getName(), + )); + } + + if ($this->reflectionProperty->getType()->allowsNull()) { + throw new InvalidArgumentException(sprintf( + '%s::$%s must not be nullable when used with TypedNoDefaultPropertyAccessor', + $this->reflectionProperty->getDeclaringClass()->getName(), + $this->reflectionProperty->getName(), + )); + } + } + + public function setValue(object $object, mixed $value): void + { + if ($value === null) { + if ($this->unsetter === null) { + $propertyName = $this->reflectionProperty->getName(); + $this->unsetter = function () use ($propertyName): void { + unset($this->$propertyName); + }; + } + + $unsetter = $this->unsetter->bindTo($object, $this->reflectionProperty->getDeclaringClass()->getName()); + + assert($unsetter instanceof Closure); + + $unsetter(); + + return; + } + + $this->parent->setValue($object, $value); + } + + public function getValue(object $object): mixed + { + return $this->reflectionProperty->isInitialized($object) ? $this->parent->getValue($object) : null; + } + + public function getUnderlyingReflector(): ReflectionProperty + { + return $this->reflectionProperty; + } +} From c42217e4febf13c4ceff50af0cf960a3732b372c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 27 Sep 2025 01:05:29 +0200 Subject: [PATCH 02/31] Replace ReflectionField with PropertyAccessor --- .../ODM/MongoDB/Hydrator/HydratorFactory.php | 14 +- .../ODM/MongoDB/Mapping/ClassMetadata.php | 51 ++++-- .../MongoDB/Mapping/ClassMetadataFactory.php | 10 +- .../Mapping/LegacyReflectionFields.php | 170 ++++++++++++++++++ .../Persisters/CollectionPersister.php | 2 +- .../MongoDB/Persisters/DocumentPersister.php | 18 +- .../MongoDB/Persisters/PersistenceBuilder.php | 3 +- lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 34 ++-- .../MongoDB/Utility/LifecycleEventManager.php | 6 +- .../Mapping/AbstractMappingDriverTestCase.php | 6 +- .../Mapping/BasicInheritanceMappingTest.php | 6 +- .../Tests/Mapping/ClassMetadataTest.php | 8 +- 12 files changed, 258 insertions(+), 70 deletions(-) create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php diff --git a/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php b/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php index e9542f5b4..050dd61ca 100644 --- a/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php @@ -187,7 +187,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla if (array_key_exists('%1$s', $data) && ($data['%1$s'] !== null || ($this->class->fieldMappings['%2$s']['nullable'] ?? false))) { $value = $data['%1$s']; %3$s - $this->class->reflFields['%2$s']->setValue($document, $return === null ? null : clone $return); + $this->class->propertyAccessors['%2$s']->setValue($document, $return === null ? null : clone $return); $hydratedData['%2$s'] = $return; } @@ -210,7 +210,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla } else { \$return = null; } - \$this->class->reflFields['%2\$s']->setValue(\$document, \$return); + \$this->class->propertyAccessors['%2\$s']->setValue(\$document, \$return); \$hydratedData['%2\$s'] = \$return; } @@ -239,7 +239,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla $return = $this->dm->getReference($className, $id); } - $this->class->reflFields['%2$s']->setValue($document, $return); + $this->class->propertyAccessors['%2$s']->setValue($document, $return); $hydratedData['%2$s'] = $return; } @@ -256,7 +256,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla $className = $this->class->fieldMappings['%2$s']['targetDocument']; $return = $this->dm->getRepository($className)->%3$s($document); - $this->class->reflFields['%2$s']->setValue($document, $return); + $this->class->propertyAccessors['%2$s']->setValue($document, $return); $hydratedData['%2$s'] = $return; EOF @@ -280,7 +280,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla ); $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); + $this->class->propertyAccessors['%2$s']->setValue($document, $return); $hydratedData['%2$s'] = $return; EOF @@ -307,7 +307,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla if ($mongoData) { $return->setMongoData($mongoData); } - $this->class->reflFields['%2$s']->setValue($document, $return); + $this->class->propertyAccessors['%2$s']->setValue($document, $return); $hydratedData['%2$s'] = $return; EOF @@ -345,7 +345,7 @@ private function generateHydratorClass(ClassMetadata $class, string $hydratorCla } } - $this->class->reflFields['%2$s']->setValue($document, $return); + $this->class->propertyAccessors['%2$s']->setValue($document, $return); $hydratedData['%2$s'] = $return; } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 3088a770e..3e8e11a89 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -15,6 +15,9 @@ use Doctrine\ODM\MongoDB\LockException; use Doctrine\ODM\MongoDB\Mapping\Annotations\EncryptQuery; use Doctrine\ODM\MongoDB\Mapping\Annotations\TimeSeries; +use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\EnumPropertyAccessor; +use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\PropertyAccessor; +use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\PropertyAccessorFactory; use Doctrine\ODM\MongoDB\Proxy\InternalProxy; use Doctrine\ODM\MongoDB\Types\Incrementable; use Doctrine\ODM\MongoDB\Types\Type; @@ -23,7 +26,6 @@ use Doctrine\Persistence\Mapping\ClassMetadata as BaseClassMetadata; use Doctrine\Persistence\Mapping\ReflectionService; use Doctrine\Persistence\Mapping\RuntimeReflectionService; -use Doctrine\Persistence\Reflection\EnumReflectionProperty; use InvalidArgumentException; use LogicException; use MongoDB\BSON\Decimal128; @@ -638,10 +640,13 @@ /** * The ReflectionProperty instances of the mapped class. * - * @var ReflectionProperty[] + * @var LegacyReflectionFields|ReflectionProperty[] */ public $reflFields = []; + /** @var array */ + public array $propertyAccessors = []; + /** * READ-ONLY: The inheritance mapping type used by the class. * @@ -1498,13 +1503,23 @@ public function isChangeTrackingNotify(): bool /** * Gets the ReflectionProperties of the mapped class. * - * @return ReflectionProperty[] + * @return LegacyReflectionFields|ReflectionProperty[] */ - public function getReflectionProperties(): array + public function getReflectionProperties(): array|LegacyReflectionFields { return $this->reflFields; } + /** + * Gets the ReflectionProperties of the mapped class. + * + * @return PropertyAccessor[] An array of PropertyAccessor instances. + */ + public function getPropertyAccessors(): array + { + return $this->propertyAccessors; + } + /** * Gets a ReflectionProperty for a specific field of the mapped class. */ @@ -1513,6 +1528,11 @@ public function getReflectionProperty(string $name): ReflectionProperty return $this->reflFields[$name]; } + public function getPropertyAccessor(string $name): PropertyAccessor|null + { + return $this->propertyAccessors[$name] ?? null; + } + /** @return class-string */ public function getName(): string { @@ -1915,7 +1935,7 @@ public function getDatabaseIdentifierValue($id) public function setIdentifierValue(object $document, $id): void { $id = $this->getPHPIdentifierValue($id); - $this->reflFields[$this->identifier]->setValue($document, $id); + $this->propertyAccessors[$this->identifier]->setValue($document, $id); } /** @@ -1925,7 +1945,7 @@ public function setIdentifierValue(object $document, $id): void */ public function getIdentifierValue(object $document) { - return $this->reflFields[$this->identifier]->getValue($document); + return $this->propertyAccessors[$this->identifier]->getValue($document); } /** @@ -1965,7 +1985,7 @@ public function setFieldValue(object $document, string $field, $value): void $document->initializeProxy(); } - $this->reflFields[$field]->setValue($document, $value); + $this->propertyAccessors[$field]->setValue($document, $value); } /** @@ -1981,7 +2001,7 @@ public function getFieldValue(object $document, string $field) $document->initializeProxy(); } - return $this->reflFields[$field]->getValue($document); + return $this->propertyAccessors[$field]->getValue($document); } /** @@ -2567,8 +2587,7 @@ public function mapField(array $mapping): array $this->associationMappings[$mapping['fieldName']] = $mapping; } - $reflProp = $this->reflectionService->getAccessibleProperty($this->name, $mapping['fieldName']); - assert($reflProp instanceof ReflectionProperty); + $accessor = PropertyAccessorFactory::createPropertyAccessor($this->name, $mapping['fieldName']); if (isset($mapping['enumType'])) { if (! enum_exists($mapping['enumType'])) { @@ -2580,10 +2599,10 @@ public function mapField(array $mapping): array throw MappingException::nonBackedEnumMapped($this->name, $mapping['fieldName'], $mapping['enumType']); } - $reflProp = new EnumReflectionProperty($reflProp, $mapping['enumType']); + $accessor = new EnumPropertyAccessor($accessor, $mapping['enumType']); } - $this->reflFields[$mapping['fieldName']] = $reflProp; + $this->propertyAccessors[$mapping['fieldName']] = $accessor; return $mapping; } @@ -2598,6 +2617,7 @@ public function mapField(array $mapping): array * Parts that are also NOT serialized because they can not be properly unserialized: * - reflClass (ReflectionClass) * - reflFields (ReflectionProperty array) + * - propertyAccessors (ReflectionProperty array) * * @return array The names of all the fields that should be serialized. */ @@ -2709,14 +2729,13 @@ public function __wakeup() $this->instantiator = new Instantiator(); foreach ($this->fieldMappings as $field => $mapping) { - $prop = $this->reflectionService->getAccessibleProperty($mapping['declared'] ?? $this->name, $field); - assert($prop instanceof ReflectionProperty); + $accessor = PropertyAccessorFactory::createPropertyAccessor($mapping['declared'] ?? $this->name, $field); if (isset($mapping['enumType'])) { - $prop = new EnumReflectionProperty($prop, $mapping['enumType']); + $accessor = new EnumPropertyAccessor($accessor, $mapping['enumType']); } - $this->reflFields[$field] = $prop; + $this->propertyAccessors[$field] = $accessor; } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php index 87b073d75..c59673fe1 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php @@ -241,12 +241,12 @@ private function generateAutoIdGenerator(ClassMetadata $class): void $class->setIdGenerator(new ObjectIdGenerator()); break; case 'uuid': - $reflectionProperty = $class->getReflectionProperty($identifierMapping['fieldName']); - if (! $reflectionProperty->getType() instanceof ReflectionNamedType) { + $type = $class->propertyAccessors[$identifierMapping['fieldName']]->getUnderlyingReflector()->getType(); + if (! $type instanceof ReflectionNamedType) { throw MappingException::autoIdGeneratorNeedsType($class->name, $identifierMapping['fieldName']); } - $class->setIdGenerator(new SymfonyUuidGenerator($reflectionProperty->getType()->getName())); + $class->setIdGenerator(new SymfonyUuidGenerator($type->getName())); break; default: throw MappingException::unsupportedTypeForAutoGenerator( @@ -348,8 +348,8 @@ private function addInheritedFields(ClassMetadata $subClass, ClassMetadata $pare $subClass->addInheritedFieldMapping($mapping); } - foreach ($parentClass->reflFields as $name => $field) { - $subClass->reflFields[$name] = $field; + foreach ($parentClass->propertyAccessors as $name => $field) { + $subClass->propertyAccessors[$name] = $field; } } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php new file mode 100644 index 000000000..351623240 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php @@ -0,0 +1,170 @@ + + * @template-implements IteratorAggregate + */ +class LegacyReflectionFields implements ArrayAccess, IteratorAggregate +{ + /** @var array */ + private array $reflFields = []; + + public function __construct(private ClassMetadata $classMetadata, private ReflectionService $reflectionService) + { + } + + /** @param string $offset */ + public function offsetExists($offset): bool // phpcs:ignore + { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/11659', + 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.', + ); + + return isset($this->classMetadata->propertyAccessors[$offset]); + } + + /** + * @param string $field + * + * @psalm-suppress LessSpecificImplementedReturnType + */ + public function offsetGet($field): mixed // phpcs:ignore + { + if (isset($this->reflFields[$field])) { + return $this->reflFields[$field]; + } + + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/11659', + 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.', + ); + + if (isset($this->classMetadata->propertyAccessors[$field])) { + $fieldName = str_contains($field, '.') ? $this->classMetadata->fieldMappings[$field]->originalField : $field; + $className = $this->classMetadata->name; + + assert(is_string($fieldName)); + + if (isset($this->classMetadata->fieldMappings[$field]) && $this->classMetadata->fieldMappings[$field]->originalClass !== null) { + $className = $this->classMetadata->fieldMappings[$field]->originalClass; + } elseif (isset($this->classMetadata->fieldMappings[$field]) && $this->classMetadata->fieldMappings[$field]->declared !== null) { + $className = $this->classMetadata->fieldMappings[$field]->declared; + } elseif (isset($this->classMetadata->associationMappings[$field]) && $this->classMetadata->associationMappings[$field]->declared !== null) { + $className = $this->classMetadata->associationMappings[$field]->declared; + } elseif (isset($this->classMetadata->embeddedClasses[$field]) && $this->classMetadata->embeddedClasses[$field]->declared !== null) { + $className = $this->classMetadata->embeddedClasses[$field]->declared; + } + + /** @psalm-suppress ArgumentTypeCoercion */ + $this->reflFields[$field] = $this->getAccessibleProperty($className, $fieldName); + + if (isset($this->classMetadata->fieldMappings[$field])) { + if ($this->classMetadata->fieldMappings[$field]->enumType !== null) { + $this->reflFields[$field] = new EnumReflectionProperty( + $this->reflFields[$field], + $this->classMetadata->fieldMappings[$field]->enumType, + ); + } + + if ($this->classMetadata->fieldMappings[$field]->originalField !== null) { + $parentField = str_replace('.' . $fieldName, '', $field); + $originalClass = $this->classMetadata->fieldMappings[$field]->originalClass; + + if (! str_contains($parentField, '.')) { + $parentClass = $this->classMetadata->name; + } else { + $parentClass = $this->classMetadata->fieldMappings[$parentField]->originalClass; + } + + /** @psalm-var class-string $parentClass */ + /** @psalm-var class-string $originalClass */ + + $this->reflFields[$field] = new ReflectionEmbeddedProperty( + $this->getAccessibleProperty($parentClass, $parentField), + $this->reflFields[$field], + $originalClass, + ); + } + } + + return $this->reflFields[$field]; + } + + throw new OutOfBoundsException('Unknown field: ' . $this->classMetadata->name . ' ::$' . $field); + } + + /** + * @param string $offset + * @param ReflectionProperty $value + */ + public function offsetSet($offset, $value): void // phpcs:ignore + { + $this->reflFields[$offset] = $value; + } + + /** @param string $offset */ + public function offsetUnset($offset): void // phpcs:ignore + { + unset($this->reflFields[$offset]); + } + + /** @psalm-param class-string $class */ + private function getAccessibleProperty(string $class, string $field): ReflectionProperty + { + $reflectionProperty = $this->reflectionService->getAccessibleProperty($class, $field); + + assert($reflectionProperty !== null); + + if ($reflectionProperty->isReadOnly()) { + $declaringClass = $reflectionProperty->class; + if ($declaringClass !== $class) { + $reflectionProperty = $this->reflectionService->getAccessibleProperty($declaringClass, $field); + + assert($reflectionProperty !== null); + } + + $reflectionProperty = new ReflectionReadonlyProperty($reflectionProperty); + } + + return $reflectionProperty; + } + + /** @return Generator */ + public function getIterator(): Traversable + { + Deprecation::trigger( + 'doctrine/orm', + 'https://github.com/doctrine/orm/pull/11659', + 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine MongoDB ODM 3.0.', + ); + + $keys = array_keys($this->classMetadata->propertyAccessors); + + foreach ($keys as $key) { + yield $key => $this->offsetGet($key); + } + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Persisters/CollectionPersister.php b/lib/Doctrine/ODM/MongoDB/Persisters/CollectionPersister.php index 8b99e0ccb..e65b500a8 100644 --- a/lib/Doctrine/ODM/MongoDB/Persisters/CollectionPersister.php +++ b/lib/Doctrine/ODM/MongoDB/Persisters/CollectionPersister.php @@ -455,7 +455,7 @@ private function executeQuery(object $document, array $newObj, array $options): $id = $class->getDatabaseIdentifierValue($this->uow->getDocumentIdentifier($document)); $query = ['_id' => $id]; if ($class->isVersioned) { - $query[$class->fieldMappings[$class->versionField]['name']] = $class->reflFields[$class->versionField]->getValue($document); + $query[$class->fieldMappings[$class->versionField]['name']] = $class->propertyAccessors[$class->versionField]->getValue($document); } $collection = $this->dm->getDocumentCollection($className); diff --git a/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php b/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php index 1bf370fc0..c74c668fe 100644 --- a/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php +++ b/lib/Doctrine/ODM/MongoDB/Persisters/DocumentPersister.php @@ -211,12 +211,12 @@ public function executeInserts(array $options = []): void // Set the initial version for each insert if ($this->class->isVersioned) { $versionMapping = $this->class->fieldMappings[$this->class->versionField]; - $nextVersion = $this->class->reflFields[$this->class->versionField]->getValue($document); + $nextVersion = $this->class->propertyAccessors[$this->class->versionField]->getValue($document); $type = Type::getType($versionMapping['type']); assert($type instanceof Versionable); if ($nextVersion === null) { $nextVersion = $type->getNextVersion(null); - $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion); + $this->class->propertyAccessors[$this->class->versionField]->setValue($document, $nextVersion); } $data[$versionMapping['name']] = $type->convertToDatabaseValue($nextVersion); @@ -289,12 +289,12 @@ private function executeUpsert(object $document, array $options): void // Set the initial version for each upsert if ($this->class->isVersioned) { $versionMapping = $this->class->fieldMappings[$this->class->versionField]; - $nextVersion = $this->class->reflFields[$this->class->versionField]->getValue($document); + $nextVersion = $this->class->propertyAccessors[$this->class->versionField]->getValue($document); $type = Type::getType($versionMapping['type']); assert($type instanceof Versionable); if ($nextVersion === null) { $nextVersion = $type->getNextVersion(null); - $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion); + $this->class->propertyAccessors[$this->class->versionField]->setValue($document, $nextVersion); } $data['$set'][$versionMapping['name']] = $type->convertToDatabaseValue($nextVersion); @@ -374,7 +374,7 @@ public function update(object $document, array $options = []): void $nextVersion = null; if ($this->class->isVersioned) { $versionMapping = $this->class->fieldMappings[$this->class->versionField]; - $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document); + $currentVersion = $this->class->propertyAccessors[$this->class->versionField]->getValue($document); $type = Type::getType($versionMapping['type']); assert($type instanceof Versionable); $nextVersion = $type->getNextVersion($currentVersion); @@ -386,7 +386,7 @@ public function update(object $document, array $options = []): void // Include locking logic so that if the document object in memory is currently // locked then it will remove it, otherwise it ensures the document is not locked. if ($this->class->isLockable) { - $isLocked = $this->class->reflFields[$this->class->lockField]->getValue($document); + $isLocked = $this->class->propertyAccessors[$this->class->lockField]->getValue($document); $lockMapping = $this->class->fieldMappings[$this->class->lockField]; if ($isLocked) { $update['$unset'] = [$lockMapping['name'] => true]; @@ -405,7 +405,7 @@ public function update(object $document, array $options = []): void } if ($this->class->isVersioned) { - $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion); + $this->class->propertyAccessors[$this->class->versionField]->setValue($document, $nextVersion); } } @@ -615,7 +615,7 @@ public function lock(object $document, int $lockMode): void $lockMapping = $this->class->fieldMappings[$this->class->lockField]; assert($this->collection instanceof Collection); $this->collection->updateOne($criteria, ['$set' => [$lockMapping['name'] => $lockMode]]); - $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode); + $this->class->propertyAccessors[$this->class->lockField]->setValue($document, $lockMode); } /** @@ -628,7 +628,7 @@ public function unlock(object $document): void $lockMapping = $this->class->fieldMappings[$this->class->lockField]; assert($this->collection instanceof Collection); $this->collection->updateOne($criteria, ['$unset' => [$lockMapping['name'] => true]]); - $this->class->reflFields[$this->class->lockField]->setValue($document, null); + $this->class->propertyAccessors[$this->class->lockField]->setValue($document, null); } /** diff --git a/lib/Doctrine/ODM/MongoDB/Persisters/PersistenceBuilder.php b/lib/Doctrine/ODM/MongoDB/Persisters/PersistenceBuilder.php index beb53ee47..130269412 100644 --- a/lib/Doctrine/ODM/MongoDB/Persisters/PersistenceBuilder.php +++ b/lib/Doctrine/ODM/MongoDB/Persisters/PersistenceBuilder.php @@ -366,8 +366,7 @@ public function prepareEmbeddedDocumentValue(array $embeddedMapping, $embeddedDo continue; } - // Inline ClassMetadata::getFieldValue() - $rawValue = $class->reflFields[$mapping['fieldName']]->getValue($embeddedDocument); + $rawValue = $class->propertyAccessors[$mapping['fieldName']]->getValue($embeddedDocument); $value = null; diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index f6b9b28c2..c52fb59fd 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -616,7 +616,7 @@ public function getDocumentActualData(object $document): array { $class = $this->dm->getClassMetadata($document::class); $actualData = []; - foreach ($class->reflFields as $name => $refProp) { + foreach ($class->propertyAccessors as $name => $refProp) { $mapping = $class->fieldMappings[$name]; // skip not saved fields if (isset($mapping['notSaved']) && $mapping['notSaved'] === true) { @@ -637,7 +637,7 @@ public function getDocumentActualData(object $document): array $coll = $this->dm->getConfiguration()->getPersistentCollectionFactory()->create($this->dm, $mapping, $value); $coll->setOwner($document, $mapping); $coll->setDirty(! $value->isEmpty()); - $class->reflFields[$name]->setValue($document, $coll); + $class->propertyAccessors[$name]->setValue($document, $coll); $actualData[$name] = $coll; } else { $actualData[$name] = $value; @@ -863,7 +863,7 @@ private function computeOrRecomputeChangeSet(ClassMetadata $class, object $docum ); foreach ($associationMappings as $mapping) { - $value = $class->reflFields[$mapping['fieldName']]->getValue($document); + $value = $class->propertyAccessors[$mapping['fieldName']]->getValue($document); if ($value === null) { continue; @@ -1274,7 +1274,7 @@ private function executeDeletions(ClassMetadata $class, array $documents, array continue; } - $value = $class->reflFields[$fieldMapping['fieldName']]->getValue($document); + $value = $class->propertyAccessors[$fieldMapping['fieldName']]->getValue($document); if (! ($value instanceof PersistentCollectionInterface)) { continue; } @@ -1938,8 +1938,8 @@ private function doMerge(object $document, array &$visited, ?object $prevManaged } if ($class->isVersioned) { - $managedCopyVersion = $class->reflFields[$class->versionField]->getValue($managedCopy); - $documentVersion = $class->reflFields[$class->versionField]->getValue($document); + $managedCopyVersion = $class->propertyAccessors[$class->versionField]->getValue($managedCopy); + $documentVersion = $class->propertyAccessors[$class->versionField]->getValue($document); // Throw exception if versions don't match if ($managedCopyVersion !== $documentVersion) { @@ -2054,12 +2054,12 @@ private function doMerge(object $document, array &$visited, ?object $prevManaged $prevClass = $this->dm->getClassMetadata($prevManagedCopy::class); if ($assoc['type'] === ClassMetadata::ONE) { - $prevClass->reflFields[$assocField]->setValue($prevManagedCopy, $managedCopy); + $prevClass->propertyAccessors[$assocField]->setValue($prevManagedCopy, $managedCopy); } else { - $prevClass->reflFields[$assocField]->getValue($prevManagedCopy)->add($managedCopy); + $prevClass->propertyAccessors[$assocField]->getValue($prevManagedCopy)->add($managedCopy); if ($assoc['type'] === ClassMetadata::MANY && isset($assoc['mappedBy'])) { - $class->reflFields[$assoc['mappedBy']]->setValue($managedCopy, $prevManagedCopy); + $class->propertyAccessors[$assoc['mappedBy']]->setValue($managedCopy, $prevManagedCopy); } } } @@ -2180,7 +2180,7 @@ private function cascadeRefresh(object $document, array &$visited): void ); foreach ($associationMappings as $mapping) { - $relatedDocuments = $class->reflFields[$mapping['fieldName']]->getValue($document); + $relatedDocuments = $class->propertyAccessors[$mapping['fieldName']]->getValue($document); if ($relatedDocuments instanceof Collection || is_array($relatedDocuments)) { if ($relatedDocuments instanceof PersistentCollectionInterface) { // Unwrap so that foreach() does not initialize @@ -2209,7 +2209,7 @@ private function cascadeDetach(object $document, array &$visited): void continue; } - $relatedDocuments = $class->reflFields[$mapping['fieldName']]->getValue($document); + $relatedDocuments = $class->propertyAccessors[$mapping['fieldName']]->getValue($document); if ($relatedDocuments instanceof Collection || is_array($relatedDocuments)) { if ($relatedDocuments instanceof PersistentCollectionInterface) { // Unwrap so that foreach() does not initialize @@ -2240,10 +2240,10 @@ private function cascadeMerge(object $document, object $managedCopy, array &$vis ); foreach ($associationMappings as $assoc) { - $relatedDocuments = $class->reflFields[$assoc['fieldName']]->getValue($document); + $relatedDocuments = $class->propertyAccessors[$assoc['fieldName']]->getValue($document); if ($relatedDocuments instanceof Collection || is_array($relatedDocuments)) { - if ($relatedDocuments === $class->reflFields[$assoc['fieldName']]->getValue($managedCopy)) { + if ($relatedDocuments === $class->propertyAccessors[$assoc['fieldName']]->getValue($managedCopy)) { // Collections are the same, so there is nothing to do continue; } @@ -2272,7 +2272,7 @@ private function cascadePersist(object $document, array &$visited): void ); foreach ($associationMappings as $fieldName => $mapping) { - $relatedDocuments = $class->reflFields[$fieldName]->getValue($document); + $relatedDocuments = $class->propertyAccessors[$fieldName]->getValue($document); if ($relatedDocuments instanceof Collection || is_array($relatedDocuments)) { if ($relatedDocuments instanceof PersistentCollectionInterface) { @@ -2330,7 +2330,7 @@ private function cascadeRemove(object $document, array &$visited): void $this->initializeObject($document); - $relatedDocuments = $class->reflFields[$mapping['fieldName']]->getValue($document); + $relatedDocuments = $class->propertyAccessors[$mapping['fieldName']]->getValue($document); if ($relatedDocuments instanceof Collection || is_array($relatedDocuments)) { // If its a PersistentCollection initialization is intended! No unwrap! foreach ($relatedDocuments as $relatedDocument) { @@ -2365,7 +2365,7 @@ public function lock(object $document, int $lockMode, ?int $lockVersion = null): } if ($lockVersion !== null) { - $documentVersion = $class->reflFields[$class->versionField]->getValue($document); + $documentVersion = $class->propertyAccessors[$class->versionField]->getValue($document); if ($documentVersion !== $lockVersion) { throw LockException::lockFailedVersionMissmatch($document, $lockVersion, $documentVersion); } @@ -2483,7 +2483,7 @@ private function fixPersistentCollectionOwnership(PersistentCollectionInterface $this->initializeObject($coll); // we have to do this otherwise the cols share state $newValue = clone $coll; $newValue->setOwner($document, $class->fieldMappings[$propName]); - $class->reflFields[$propName]->setValue($document, $newValue); + $class->propertyAccessors[$propName]->setValue($document, $newValue); if ($this->isScheduledForUpdate($document)) { // @todo following line should be superfluous once collections are stored in change sets $this->setOriginalDocumentProperty(spl_object_id($document), $propName, $newValue); diff --git a/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php b/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php index ca7ef6691..4214c0156 100644 --- a/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php +++ b/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php @@ -213,7 +213,7 @@ public function preUpdate(ClassMetadata $class, object $document, ?Session $sess private function cascadePreUpdate(ClassMetadata $class, object $document, ?Session $session = null): void { foreach ($class->getEmbeddedFieldsMappings() as $mapping) { - $value = $class->reflFields[$mapping['fieldName']]->getValue($document); + $value = $class->propertyAccessors[$mapping['fieldName']]->getValue($document); if ($value === null) { continue; } @@ -241,7 +241,7 @@ private function cascadePreUpdate(ClassMetadata $class, object $document, ?Sessi private function cascadePostUpdate(ClassMetadata $class, object $document, ?Session $session = null): void { foreach ($class->getEmbeddedFieldsMappings() as $mapping) { - $value = $class->reflFields[$mapping['fieldName']]->getValue($document); + $value = $class->propertyAccessors[$mapping['fieldName']]->getValue($document); if ($value === null) { continue; } @@ -281,7 +281,7 @@ private function cascadePostUpdate(ClassMetadata $class, object $document, ?Sess private function cascadePostPersist(ClassMetadata $class, object $document, ?Session $session = null): void { foreach ($class->getEmbeddedFieldsMappings() as $mapping) { - $value = $class->reflFields[$mapping['fieldName']]->getValue($document); + $value = $class->propertyAccessors[$mapping['fieldName']]->getValue($document); if ($value === null) { continue; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index c4a8be354..ab7d1c1a1 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php @@ -11,6 +11,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\EnumPropertyAccessor; use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity; use Doctrine\ODM\MongoDB\Repository\DefaultGridFSRepository; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; @@ -18,7 +19,6 @@ use Doctrine\ODM\MongoDB\Tests\BaseTestCase; use Doctrine\ODM\MongoDB\Types\Type; use Doctrine\Persistence\Mapping\Driver\MappingDriver; -use Doctrine\Persistence\Reflection\EnumReflectionProperty; use Documents\Card; use Documents\CustomCollection; use Documents\Suit; @@ -699,12 +699,12 @@ public function testEnumType(): void self::assertSame(Suit::class, $metadata->fieldMappings['suit']['enumType']); self::assertSame('string', $metadata->fieldMappings['suit']['type']); self::assertFalse($metadata->fieldMappings['suit']['nullable']); - self::assertInstanceOf(EnumReflectionProperty::class, $metadata->reflFields['suit']); + self::assertInstanceOf(EnumPropertyAccessor::class, $metadata->propertyAccessors['suit']); self::assertSame(Suit::class, $metadata->fieldMappings['nullableSuit']['enumType']); self::assertSame('string', $metadata->fieldMappings['nullableSuit']['type']); self::assertTrue($metadata->fieldMappings['nullableSuit']['nullable']); - self::assertInstanceOf(EnumReflectionProperty::class, $metadata->reflFields['nullableSuit']); + self::assertInstanceOf(EnumPropertyAccessor::class, $metadata->propertyAccessors['nullableSuit']); } public function testTimeSeriesDocumentWithGranularity(): void diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/BasicInheritanceMappingTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/BasicInheritanceMappingTest.php index 832a208fe..2cabb3648 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/BasicInheritanceMappingTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/BasicInheritanceMappingTest.php @@ -67,9 +67,9 @@ public function testSerializationWithPrivateFieldsFromMappedSuperclass(): void $class2 = unserialize(serialize($class)); - self::assertTrue(isset($class2->reflFields['mapped1'])); - self::assertTrue(isset($class2->reflFields['mapped2'])); - self::assertTrue(isset($class2->reflFields['mappedRelated1'])); + self::assertTrue(isset($class2->propertyAccessors['mapped1'])); + self::assertTrue(isset($class2->propertyAccessors['mapped2'])); + self::assertTrue(isset($class2->propertyAccessors['mappedRelated1'])); } public function testReadPreferenceIsInherited(): void diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php index 5c8a78020..ceda14e4b 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/ClassMetadataTest.php @@ -9,13 +9,13 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; +use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\EnumPropertyAccessor; use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\ODM\MongoDB\Tests\BaseTestCase; use Doctrine\ODM\MongoDB\Tests\ClassMetadataTestUtil; use Doctrine\ODM\MongoDB\Types\Type; use Doctrine\ODM\MongoDB\Utility\CollectionHelper; -use Doctrine\Persistence\Reflection\EnumReflectionProperty; use DoctrineGlobal_Article; use DoctrineGlobal_User; use Documents\Account; @@ -203,17 +203,17 @@ public function testEnumTypeFromReflection(): void self::assertFalse($cm->isNullable('nullableSuit')); } - public function testEnumReflectionPropertySerialization(): void + public function testEnumPropertyAccessorSerialization(): void { $cm = new ClassMetadata(Card::class); $cm->mapField(['fieldName' => 'suit']); - self::assertInstanceOf(EnumReflectionProperty::class, $cm->reflFields['suit']); + self::assertInstanceOf(EnumPropertyAccessor::class, $cm->propertyAccessors['suit']); $serialized = serialize($cm); $cm = unserialize($serialized); - self::assertInstanceOf(EnumReflectionProperty::class, $cm->reflFields['suit']); + self::assertInstanceOf(EnumPropertyAccessor::class, $cm->propertyAccessors['suit']); } public function testEnumTypeFromReflectionMustBeBacked(): void From 40f56f74b97caf892b8af903cabe0e691b25f935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 27 Sep 2025 01:22:21 +0200 Subject: [PATCH 03/31] Compatibility PHP < 8.4 --- .../ObjectCastPropertyAccessor.php | 51 +++++++++++++++++++ .../PropertyAccessorFactory.php | 6 ++- .../RawValuePropertyAccessor.php | 8 ++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php new file mode 100644 index 000000000..05e686fe6 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php @@ -0,0 +1,51 @@ +getName(); + $key = $reflectionProperty->isPrivate() ? "\0" . ltrim($reflectionProperty->getDeclaringClass()->getName(), '\\') . "\0" . $name : ($reflectionProperty->isProtected() ? "\0*\0" . $name : $name); + + return new self($reflectionProperty, $key); + } + + private function __construct(private ReflectionProperty $reflectionProperty, private string $key) + { + } + + public function setValue(object $object, mixed $value): void + { + if (! ($object instanceof InternalProxy && ! $object->__isInitialized())) { + $this->reflectionProperty->setValue($object, $value); + + return; + } + + $object->__setInitialized(true); + + $this->reflectionProperty->setValue($object, $value); + + $object->__setInitialized(false); + } + + public function getValue(object $object): mixed + { + return ((array) $object)[$this->key] ?? null; + } + + public function getUnderlyingReflector(): ReflectionProperty + { + return $this->reflectionProperty; + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php index 046538f1f..97e5135f6 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php @@ -6,6 +6,8 @@ use ReflectionProperty; +use const PHP_VERSION_ID; + class PropertyAccessorFactory { /** @phpstan-param class-string $className */ @@ -13,7 +15,9 @@ public static function createPropertyAccessor(string $className, string $propert { $reflectionProperty = new ReflectionProperty($className, $propertyName); - $accessor = RawValuePropertyAccessor::fromReflectionProperty($reflectionProperty); + $accessor = PHP_VERSION_ID >= 80400 + ? RawValuePropertyAccessor::fromReflectionProperty($reflectionProperty) + : ObjectCastPropertyAccessor::fromReflectionProperty($reflectionProperty); if ($reflectionProperty->hasType() && ! $reflectionProperty->getType()->allowsNull()) { $accessor = new TypedNoDefaultPropertyAccessor($accessor, $reflectionProperty); diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php index 151b25e01..356710c9d 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php @@ -5,10 +5,13 @@ namespace Doctrine\ODM\MongoDB\Mapping\PropertyAccessors; use Doctrine\ODM\MongoDB\Proxy\InternalProxy; +use LogicException; use ReflectionProperty; use function ltrim; +use const PHP_VERSION_ID; + /** * This is a PHP 8.4 and up only class and replaces {@see ObjectCastPropertyAccessor}. * @@ -28,12 +31,15 @@ public static function fromReflectionProperty(ReflectionProperty $reflectionProp private function __construct(private ReflectionProperty $reflectionProperty, private string $key) { + if (PHP_VERSION_ID < 80400) { + throw new LogicException('This class requires PHP 8.4 or higher.'); + } } public function setValue(object $object, mixed $value): void { if (! ($object instanceof InternalProxy && ! $object->__isInitialized())) { - $this->reflectionProperty->setRawValue($object, $value); + $this->reflectionProperty->setRawValueWithoutLazyInitialization($object, $value); return; } From c73c94b84a819b3c4725156052a3da123340bada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 27 Sep 2025 02:11:18 +0200 Subject: [PATCH 04/31] Import tests of PropertyAccessors --- .../Mapping/LegacyReflectionFields.php | 22 ++--- .../ObjectCastPropertyAccessor.php | 10 +++ .../Tests/Functional/PropertyHooksTest.php | 8 ++ .../EnumPropertyAccessorTest.php | 64 ++++++++++++++ .../ObjectCastPropertyAccessorTest.php | 84 +++++++++++++++++++ .../ReadOnlyAccessorTest.php | 43 ++++++++++ .../TypedNoDefaultPropertyAccessorTest.php | 43 ++++++++++ .../PropertyHooks/MappingVirtualProperty.php | 8 ++ tests/Documents/PropertyHooks/User.php | 53 ++++++++++++ 9 files changed, 319 insertions(+), 16 deletions(-) create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/EnumPropertyAccessorTest.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/TypedNoDefaultPropertyAccessorTest.php create mode 100644 tests/Documents/PropertyHooks/MappingVirtualProperty.php create mode 100644 tests/Documents/PropertyHooks/User.php diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php index 351623240..709177a71 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php @@ -5,7 +5,7 @@ namespace Doctrine\ODM\MongoDB\Mapping; use ArrayAccess; -use Doctrine\Deprecations\Deprecation; +use Doctrine\ORM\Mapping\ReflectionReadonlyProperty; use Doctrine\Persistence\Mapping\ReflectionService; use Doctrine\Persistence\Reflection\EnumReflectionProperty; use Generator; @@ -19,6 +19,7 @@ use function is_string; use function str_contains; use function str_replace; +use function trigger_deprecation; /** * @template-implements ArrayAccess @@ -36,11 +37,7 @@ public function __construct(private ClassMetadata $classMetadata, private Reflec /** @param string $offset */ public function offsetExists($offset): bool // phpcs:ignore { - Deprecation::trigger( - 'doctrine/orm', - 'https://github.com/doctrine/orm/pull/11659', - 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.', - ); + trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); return isset($this->classMetadata->propertyAccessors[$offset]); } @@ -56,12 +53,9 @@ public function offsetGet($field): mixed // phpcs:ignore return $this->reflFields[$field]; } - Deprecation::trigger( - 'doctrine/orm', - 'https://github.com/doctrine/orm/pull/11659', - 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.', - ); + trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); + // @todo originalField and originalClass does not exist in ODM if (isset($this->classMetadata->propertyAccessors[$field])) { $fieldName = str_contains($field, '.') ? $this->classMetadata->fieldMappings[$field]->originalField : $field; $className = $this->classMetadata->name; @@ -155,11 +149,7 @@ private function getAccessibleProperty(string $class, string $field): Reflection /** @return Generator */ public function getIterator(): Traversable { - Deprecation::trigger( - 'doctrine/orm', - 'https://github.com/doctrine/orm/pull/11659', - 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine MongoDB ODM 3.0.', - ); + trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); $keys = array_keys($this->classMetadata->propertyAccessors); diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php index 05e686fe6..578bb2e18 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php @@ -12,6 +12,16 @@ /** @internal */ class ObjectCastPropertyAccessor implements PropertyAccessor { + /** @param class-string $class */ + public static function fromNames(string $class, string $name): self + { + $reflectionProperty = new ReflectionProperty($class, $name); + + $key = $reflectionProperty->isPrivate() ? "\0" . ltrim($class, '\\') . "\0" . $name : ($reflectionProperty->isProtected() ? "\0*\0" . $name : $name); + + return new self($reflectionProperty, $key); + } + public static function fromReflectionProperty(ReflectionProperty $reflectionProperty): self { $name = $reflectionProperty->getName(); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php new file mode 100644 index 000000000..b57dea865 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php @@ -0,0 +1,8 @@ +setValue($object, EnumType::A); + + $this->assertEquals($object->enum, EnumType::A); + $this->assertEquals(EnumType::A->value, $accessor->getValue($object)); + } + + public function testEnumSetDatabaseGetValue(): void + { + $object = new EnumClass(); + $accessor = PropertyAccessorFactory::createPropertyAccessor(EnumClass::class, 'enum'); + + $accessor = new EnumPropertyAccessor($accessor, EnumType::class); + + $accessor->setValue($object, EnumType::A->value); + + $this->assertEquals($object->enum, EnumType::A); + $this->assertEquals(EnumType::A->value, $accessor->getValue($object)); + } + + public function testEnumSetDatabaseArrayGetValue(): void + { + $object = new EnumClass(); + $accessor = PropertyAccessorFactory::createPropertyAccessor(EnumClass::class, 'enumList'); + + $accessor = new EnumPropertyAccessor($accessor, EnumType::class); + + $accessor->setValue($object, $values = [EnumType::A->value, EnumType::B->value, EnumType::C->value]); + + $this->assertEquals($object->enumList, [EnumType::A, EnumType::B, EnumType::C]); + $this->assertEquals($values, $accessor->getValue($object)); + } +} + +class EnumClass +{ + public EnumType $enum; + public array $enumList; +} + +enum EnumType: string +{ + case A = 'a'; + case B = 'b'; + case C = 'c'; +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php new file mode 100644 index 000000000..35bb26006 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php @@ -0,0 +1,84 @@ +setValue($object, 'value'); + + $this->assertEquals($object->property, 'value'); + $this->assertEquals('value', $accessor->getValue($object)); + } + + public function testSetGetPrivatePropertyValue(): void + { + $object = new ObjectClass(); + $accessor = ObjectCastPropertyAccessor::fromNames(ObjectClass::class, 'property2'); + + $accessor->setValue($object, 'value'); + + $this->assertEquals($object->getProperty2(), 'value'); + $this->assertEquals('value', $accessor->getValue($object)); + } + + public function testSetGetInternalProxyValue(): void + { + $object = new ObjectClassInternalProxy(); + $accessor = ObjectCastPropertyAccessor::fromNames(ObjectClassInternalProxy::class, 'property'); + + $accessor->setValue($object, 'value'); + + $this->assertEquals($object->property, 'value'); + $this->assertEquals('value', $accessor->getValue($object)); + $this->assertFalse($object->isInitialized); + $this->assertEquals(2, $object->counter); + } +} + +class ObjectClass +{ + /** @var string */ + public $property; + /** @var string */ + private $property2; + + public function getProperty2(): string + { + return $this->property2; + } +} + +class ObjectClassInternalProxy implements InternalProxy +{ + /** @var string */ + public $property; + public bool $isInitialized = false; + public int $counter = 0; + + public function __setInitialized(bool $initialized): void + { + $this->isInitialized = $initialized; + $this->counter++; + } + + public function __load(): void + { + } + + /** Returns whether this proxy is initialized or not. */ + public function __isInitialized(): bool + { + return $this->isInitialized; + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php new file mode 100644 index 000000000..e7797c756 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php @@ -0,0 +1,43 @@ +assertInstanceOf(ReadonlyAccessor::class, $accessor); + + $accessor->setValue($object, 1); + + $this->assertEquals($object->property, 1); + $this->assertEquals(1, $accessor->getValue($object)); + } + + public function testReadOnlyPropertyOnlyOnce(): void + { + $object = new ReadOnlyClass(); + $accessor = PropertyAccessorFactory::createPropertyAccessor(ReadOnlyClass::class, 'property'); + + $this->assertInstanceOf(ReadonlyAccessor::class, $accessor); + + $accessor->setValue($object, 1); + $this->expectException(LogicException::class); + $accessor->setValue($object, 2); + } +} + +class ReadOnlyClass +{ + public readonly int $property; +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/TypedNoDefaultPropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/TypedNoDefaultPropertyAccessorTest.php new file mode 100644 index 000000000..8138c7be8 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/TypedNoDefaultPropertyAccessorTest.php @@ -0,0 +1,43 @@ +assertInstanceOf(TypedNoDefaultPropertyAccessor::class, $accessor); + + $object = new TypedClass(); + $accessor->setValue($object, 42); + $this->assertEquals(42, $accessor->getValue($object)); + } + + public function testSetNullWithoutDefault(): void + { + $accessor = PropertyAccessorFactory::createPropertyAccessor(TypedClass::class, 'property'); + + $object = new TypedClass(); + $accessor->setValue($object, null); + $this->assertNull($accessor->getValue($object)); + + $accessor->setValue($object, 42); + $this->assertEquals(42, $accessor->getValue($object)); + + $accessor->setValue($object, null); + $this->assertNull($accessor->getValue($object)); + } +} + +class TypedClass +{ + public int $property; +} diff --git a/tests/Documents/PropertyHooks/MappingVirtualProperty.php b/tests/Documents/PropertyHooks/MappingVirtualProperty.php new file mode 100644 index 000000000..ac852e422 --- /dev/null +++ b/tests/Documents/PropertyHooks/MappingVirtualProperty.php @@ -0,0 +1,8 @@ +first = $value; + } + } + + #[Field] + public string $last { + set { + if (strlen($value) === 0) { + throw new ValueError("Name must be non-empty"); + } + $this->last = $value; + } + } + + public string $fullName { + get => $this->first . " " . $this->last; + set { + [$this->first, $this->last] = explode(' ', $value, 2); + } + } + + #[Field] + public string $language = 'de' { + // Override the "read" action with arbitrary logic. + get => strtoupper($this->language); + + // Override the "write" action with arbitrary logic. + set { + $this->language = strtolower($value); + } + } +} \ No newline at end of file From b7631ed78f7df91ebf59dbea86165bbbcfd2933f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 27 Sep 2025 03:17:15 +0200 Subject: [PATCH 05/31] Add support for PHP 8.4 Lazy Objects with configuration flag --- .github/workflows/continuous-integration.yml | 8 ++ lib/Doctrine/ODM/MongoDB/Configuration.php | 46 +++++++-- lib/Doctrine/ODM/MongoDB/DocumentManager.php | 18 ++-- .../RawValuePropertyAccessor.php | 6 +- .../Proxy/Factory/NativeLazyObjectFactory.php | 53 ++++++++++ lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 7 +- .../ODM/MongoDB/Tests/BaseTestCase.php | 7 +- .../Tests/Functional/PropertyHooksTest.php | 99 ++++++++++++++++++- .../Tests/Functional/ReferencePrimerTest.php | 1 - .../RawValuePropertyAccessorTest.php | 54 ++++++++++ .../PropertyHooks/MappingVirtualProperty.php | 24 ++++- 11 files changed, 296 insertions(+), 27 deletions(-) create mode 100644 lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 16407bfd9..a3e3360a9 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -77,6 +77,13 @@ jobs: dependencies: "highest" symfony-version: "stable" proxy: "proxy-manager" + # Test with Native Lazy Objects + - php-version: "8.4" + mongodb-version: "8.0" + driver-version: "stable" + dependencies: "highest" + symfony-version: "stable" + proxy: "native" # Test with extension 1.21 - topology: "server" php-version: "8.2" @@ -163,4 +170,5 @@ jobs: env: DOCTRINE_MONGODB_SERVER: ${{ steps.setup-mongodb.outputs.cluster-uri }} USE_LAZY_GHOST_OBJECTS: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }}" + USE_NATIVE_LAZY_OBJECTS: ${{ matrix.proxy == 'native' && '1' || '0' }}" CRYPT_SHARED_LIB_PATH: ${{ steps.setup-mongodb.outputs.crypt-shared-lib-path }} diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 6934bd60f..1d80fffb3 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -47,6 +47,8 @@ use function trigger_deprecation; use function trim; +use const PHP_VERSION_ID; + /** * Configuration class for the DocumentManager. When setting up your DocumentManager * you can optionally specify an instance of this class as the second argument. @@ -145,7 +147,8 @@ class Configuration private bool $useTransactionalFlush = false; - private bool $useLazyGhostObject = false; + private bool $lazyGhostObject = false; + private bool $nativeLazyObjects = false; private static string $version; @@ -686,26 +689,49 @@ public function isTransactionalFlushEnabled(): bool * Generate proxy classes using Symfony VarExporter's LazyGhostTrait if true. * Otherwise, use ProxyManager's LazyLoadingGhostFactory (deprecated) */ - public function setUseLazyGhostObject(bool $flag): void + public function setLazyGhostObject(bool $flag): void { - if ($flag === false) { + if ($this->nativeLazyObjects) { + throw new LogicException('Cannot enable or disable LazyGhostObject when native lazy objects are enabled.'); + } + + if ($flag) { if (! class_exists(ProxyManagerConfiguration::class)) { throw new LogicException('Package "friendsofphp/proxy-manager-lts" is required to disable LazyGhostObject.'); } - trigger_deprecation( - 'doctrine/mongodb-odm', - '2.10', - 'Using "friendsofphp/proxy-manager-lts" is deprecated. Use "symfony/var-exporter" LazyGhostObjects instead.', - ); + trigger_deprecation('doctrine/mongodb-odm', '2.10', 'Using "friendsofphp/proxy-manager-lts" is deprecated. Use "symfony/var-exporter" LazyGhostObjects instead.'); } - $this->useLazyGhostObject = $flag; + if ($flag === true && PHP_VERSION_ID >= 80400) { + trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Using "symfony/var-exporter" lazy ghost objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); + } + + $this->lazyGhostObject = $flag; } public function isLazyGhostObjectEnabled(): bool { - return $this->useLazyGhostObject; + return $this->lazyGhostObject; + } + + public function enableNativeLazyObjects(bool $nativeLazyObjects): void + { + if (PHP_VERSION_ID >= 80400 && ! $nativeLazyObjects) { + trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Disabling native lazy objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); + } + + if (PHP_VERSION_ID < 80400 && $nativeLazyObjects) { + throw new LogicException('Lazy loading proxies require PHP 8.4 or higher.'); + } + + $this->nativeLazyObjects = $nativeLazyObjects; + $this->lazyGhostObject = ! $nativeLazyObjects || $this->lazyGhostObject; + } + + public function isNativeLazyObjectsEnabled(): bool + { + return $this->nativeLazyObjects; } /** diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index b9f440aef..2ec433061 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php +++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php @@ -10,6 +10,7 @@ use Doctrine\ODM\MongoDB\Mapping\ClassMetadataFactoryInterface; use Doctrine\ODM\MongoDB\Mapping\MappingException; use Doctrine\ODM\MongoDB\Proxy\Factory\LazyGhostProxyFactory; +use Doctrine\ODM\MongoDB\Proxy\Factory\NativeLazyObjectFactory; use Doctrine\ODM\MongoDB\Proxy\Factory\ProxyFactory; use Doctrine\ODM\MongoDB\Proxy\Factory\StaticProxyFactory; use Doctrine\ODM\MongoDB\Proxy\Resolver\CachingClassNameResolver; @@ -31,7 +32,6 @@ use MongoDB\Driver\ClientEncryption; use MongoDB\Driver\ReadPreference; use MongoDB\GridFS\Bucket; -use ProxyManager\Proxy\GhostObjectInterface; use RuntimeException; use Throwable; @@ -179,11 +179,13 @@ protected function __construct(?Client $client = null, ?Configuration $config = $this->config->getAutoGenerateHydratorClasses(), ); - $this->unitOfWork = new UnitOfWork($this, $this->eventManager, $this->hydratorFactory); - $this->schemaManager = new SchemaManager($this, $this->metadataFactory); - $this->proxyFactory = $this->config->isLazyGhostObjectEnabled() - ? new LazyGhostProxyFactory($this, $this->config->getProxyDir(), $this->config->getProxyNamespace(), $this->config->getAutoGenerateProxyClasses()) - : new StaticProxyFactory($this); + $this->unitOfWork = new UnitOfWork($this, $this->eventManager, $this->hydratorFactory); + $this->schemaManager = new SchemaManager($this, $this->metadataFactory); + $this->proxyFactory = match (true) { + $this->config->isNativeLazyObjectsEnabled() => new NativeLazyObjectFactory($this->unitOfWork), + $this->config->isLazyGhostObjectEnabled() => new LazyGhostProxyFactory($this, $this->config->getProxyDir(), $this->config->getProxyNamespace(), $this->config->getAutoGenerateProxyClasses()), + default => new StaticProxyFactory($this), + }; $this->repositoryFactory = $this->config->getRepositoryFactory(); } @@ -607,7 +609,7 @@ public function flush(array $options = []): void * @param mixed $identifier * @param class-string $documentName * - * @return T|(T&GhostObjectInterface) + * @return T * * @template T of object */ @@ -624,7 +626,7 @@ public function getReference(string $documentName, $identifier): object return $document; } - /** @var T&GhostObjectInterface $document */ + /** @var T $document */ $document = $this->proxyFactory->getProxy($class, $identifier); $this->unitOfWork->registerManaged($document, $identifier, []); diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php index 356710c9d..48d445f23 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php @@ -6,6 +6,7 @@ use Doctrine\ODM\MongoDB\Proxy\InternalProxy; use LogicException; +use ProxyManager\Proxy\GhostObjectInterface; use ReflectionProperty; use function ltrim; @@ -38,7 +39,10 @@ private function __construct(private ReflectionProperty $reflectionProperty, pri public function setValue(object $object, mixed $value): void { - if (! ($object instanceof InternalProxy && ! $object->__isInitialized())) { + if ( + ! ($object instanceof InternalProxy && ! $object->__isInitialized()) && + ! ($object instanceof GhostObjectInterface && ! $object->isProxyInitialized()) + ) { $this->reflectionProperty->setRawValueWithoutLazyInitialization($object, $value); return; diff --git a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php new file mode 100644 index 000000000..3de1098dd --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php @@ -0,0 +1,53 @@ +unitOfWork->getDocumentPersister($metadata->name); + + $proxy = $metadata->reflClass->newLazyGhost(static function (object $object) use ( + $identifier, + $documentPersister, + $metadata, + ): void { + $original = $documentPersister->load([$metadata->identifier => $identifier], $object); + if ($original === null) { + throw DocumentNotFoundException::documentNotFound($metadata->name, $identifier); + } + }, ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE); + + $metadata->propertyAccessors[$metadata->identifier]->setValue($proxy, $identifier); + + return $proxy; + } +} diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index c52fb59fd..5019f63ac 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -2783,12 +2783,14 @@ public function getOrCreateDocument(string $className, array $data, array &$hint $document = $this->identityMap[$class->name][$serializedId]; $oid = spl_object_id($document); if ($this->isUninitializedObject($document)) { - if ($document instanceof InternalProxy) { + if ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) { + $class->reflClass->markLazyObjectAsInitialized($document); + } elseif ($document instanceof InternalProxy) { $document->__setInitialized(true); } elseif ($document instanceof GhostObjectInterface) { $document->setProxyInitializer(null); } else { - throw new \RuntimeException(sprintf('Expected uninitialized proxy or ghost object from class "%s"', $document::name)); + throw new \RuntimeException(sprintf('Expected uninitialized proxy or ghost object from class "%s"', $document::class)); } $overrideLocalValues = true; @@ -3090,6 +3092,7 @@ public function isUninitializedObject(object $obj): bool $obj instanceof InternalProxy => $obj->__isInitialized() === false, $obj instanceof GhostObjectInterface => $obj->isProxyInitialized() === false, $obj instanceof PersistentCollectionInterface => $obj->isInitialized() === false, + $this->dm->getConfiguration()->isNativeLazyObjectsEnabled() => $this->dm->getClassMetadata($obj::class)->reflClass->isUninitializedLazyObject($obj), default => false }; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 665f4ef96..ad123cea2 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -104,7 +104,8 @@ protected static function getConfiguration(): Configuration $config->setPersistentCollectionNamespace('PersistentCollections'); $config->setDefaultDB(DOCTRINE_MONGODB_DATABASE); $config->setMetadataDriverImpl(static::createMetadataDriverImpl()); - $config->setUseLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECTS']); + $config->setLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECTS']); + $config->enableNativeLazyObjects((bool) $_ENV['USE_NATIVE_LAZY_OBJECTS']); $config->addFilter('testFilter', Filter::class); $config->addFilter('testFilter2', Filter::class); @@ -134,6 +135,10 @@ public static function assertArraySubset(array $subset, array $array, bool $chec public static function isLazyObject(object $document): bool { + if (PHP_VERSION_ID >= 80400 && (new \ReflectionClass($document))->getLazyInitializer($document)) { + return true; + } + return $document instanceof InternalProxy || $document instanceof LazyLoadingInterface; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php index b57dea865..e14b6190e 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php @@ -1,8 +1,103 @@ = 8.4.0')] +class PropertyHooksTest extends BaseTestCase { + protected function setUp(): void + { + parent::setUp(); + + if ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) { + return; + } + + $this->markTestSkipped('Property hooks require native lazy objects to be enabled.'); + } + + public function testMapPropertyHooks(): void + { + $user = new User(); + $user->fullName = 'John Doe'; + $user->language = 'EN'; + + $this->dm->persist($user); + $this->dm->flush(); + $this->dm->clear(); + + $user = $this->dm->find(User::class, $user->id); + + self::assertSame('John', $user->first); + self::assertSame('Doe', $user->last); + self::assertSame('John Doe', $user->fullName); + self::assertSame('EN', $user->language, 'The property hook uppercases the language.'); + + $document = $this->dm->createQueryBuilder() + ->find(User::class) + ->field('id')->equals($user->id) + ->select('language') + ->hydrate(false) + ->getQuery() + ->getSingleResult(); + + self::assertSame('en', $document['language'], 'Selecting a field without hydration does not go through the property hook, accessing raw data.'); + + $this->dm->clear(); + + $user = $this->dm->getRepository(User::class)->findOneBy(['language' => 'EN']); + + self::assertNull($user); + + $user = $this->dm->getRepository(User::class)->findOneBy(['language' => 'en']); + + self::assertNotNull($user); + } + + public function testTriggerLazyLoadingWhenAccessingPropertyHooks(): void + { + $user = new User(); + $user->fullName = 'Ludwig von Beethoven'; + $user->language = 'DE'; + + $this->dm->persist($user); + $this->dm->flush(); + $this->dm->clear(); + + $user = $this->dm->getReference(User::class, $user->id); + + $this->assertTrue($this->dm->getUnitOfWork()->isUninitializedObject($user)); + + self::assertSame('Ludwig', $user->first); + self::assertSame('von Beethoven', $user->last); + self::assertSame('Ludwig von Beethoven', $user->fullName); + self::assertSame('DE', $user->language, 'The property hook uppercases the language.'); + + $this->assertFalse($this->dm->getUnitOfWork()->isUninitializedObject($user)); + + $this->dm->clear(); + + $user = $this->dm->getReference(User::class, $user->id); + + self::assertSame('Ludwig von Beethoven', $user->fullName); + } + + public function testMappingVirtualPropertyIsNotSupported(): void + { + // @todo remove if not relevant + self::markTestSkipped('Imported from ORM, but there is no virtual property support in MongoDB ODM.'); + + $this->expectException(MappingException::class); + $this->expectExceptionMessage('Mapping virtual property "fullName" on entity "Documents\PropertyHooks\MappingVirtualProperty" is not allowed.'); -} \ No newline at end of file + $this->dm->getClassMetadata(MappingVirtualProperty::class); + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php index 8017b89f3..f7ad51830 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php @@ -94,7 +94,6 @@ public function testPrimeReferencesWithDBRefObjects(): void ->field('groups')->prime(true); foreach ($qb->getQuery() as $user) { - self::assertTrue(self::isLazyObject($user->getAccount())); self::assertFalse($this->uow->isUninitializedObject($user->getAccount())); self::assertCount(2, $user->getGroups()); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php new file mode 100644 index 000000000..847c794d7 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php @@ -0,0 +1,54 @@ += 8.4.0')] +class RawValuePropertyAccessorTest extends BaseTestCase +{ + public function testSetGetValue(): void + { + $object = new User(); + $reflection = new ReflectionObject($object); + $accessorFirst = RawValuePropertyAccessor::fromReflectionProperty($reflection->getProperty('first')); + $accessorLast = RawValuePropertyAccessor::fromReflectionProperty($reflection->getProperty('last')); + + $accessorFirst->setValue($object, 'Benjamin'); + $accessorLast->setValue($object, 'Eberlei'); + + self::assertEquals('Benjamin Eberlei', $object->fullName); + self::assertEquals('Benjamin', $accessorFirst->getValue($object)); + self::assertEquals('Eberlei', $accessorLast->getValue($object)); + + $accessorFirst->setValue($object, ''); + $accessorLast->setValue($object, ''); + + self::assertEquals('', trim($object->fullName)); + } + + public function testSetGetValueWithLanguage(): void + { + $object = new User(); + $reflection = new ReflectionObject($object); + $accessor = RawValuePropertyAccessor::fromReflectionProperty($reflection->getProperty('language')); + + $accessor->setValue($object, 'en'); + + self::assertEquals('EN', $object->language); + self::assertEquals('en', $accessor->getValue($object)); + + $accessor->setValue($object, 'EN'); + + self::assertEquals('EN', $object->language); + self::assertEquals('EN', $accessor->getValue($object)); + } +} diff --git a/tests/Documents/PropertyHooks/MappingVirtualProperty.php b/tests/Documents/PropertyHooks/MappingVirtualProperty.php index ac852e422..f6981e06e 100644 --- a/tests/Documents/PropertyHooks/MappingVirtualProperty.php +++ b/tests/Documents/PropertyHooks/MappingVirtualProperty.php @@ -1,8 +1,28 @@ - $this->first . " " . $this->last; + set { + [$this->first, $this->last] = explode(' ', $value, 2); + } + } +} From a8f2ea9e0c4d254097768af53cef3860a8b1fd59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 27 Sep 2025 19:52:26 +0200 Subject: [PATCH 06/31] Move classes requiring PHP 8.4 outside of the Documents directory, as its scanned by several tests --- composer.json | 1 + lib/Doctrine/ODM/MongoDB/DocumentManager.php | 1 - tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php | 4 +++- .../ODM/MongoDB/Tests/Functional/PropertyHooksTest.php | 4 ++-- .../Tests/Mapping/AbstractAnnotationDriverTestCase.php | 4 ++-- .../PropertyAccessors/RawValuePropertyAccessorTest.php | 2 +- .../PropertyHooks/MappingVirtualProperty.php | 2 +- tests/{Documents => Documents84}/PropertyHooks/User.php | 2 +- 8 files changed, 11 insertions(+), 9 deletions(-) rename tests/{Documents => Documents84}/PropertyHooks/MappingVirtualProperty.php (93%) rename tests/{Documents => Documents84}/PropertyHooks/User.php (97%) diff --git a/composer.json b/composer.json index 0f9d2fd14..84c07eb1c 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ "Doctrine\\ODM\\MongoDB\\Tests\\": "tests/Doctrine/ODM/MongoDB/Tests", "Documentation\\": "tests/Documentation", "Documents\\": "tests/Documents", + "Documents84\\": "tests/Documents84", "Stubs\\": "tests/Stubs", "TestDocuments\\" :"tests/Doctrine/ODM/MongoDB/Tests/Mapping/Driver/fixtures" } diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index 2ec433061..911bcd2cc 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php +++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php @@ -626,7 +626,6 @@ public function getReference(string $documentName, $identifier): object return $document; } - /** @var T $document */ $document = $this->proxyFactory->getProxy($class, $identifier); $this->unitOfWork->registerManaged($document, $identifier, []); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index ad123cea2..522289831 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -20,6 +20,7 @@ use MongoDB\Model\DatabaseInfo; use PHPUnit\Framework\TestCase; use ProxyManager\Proxy\LazyLoadingInterface; +use ReflectionClass; use function array_key_exists; use function array_map; @@ -39,6 +40,7 @@ use const DOCTRINE_MONGODB_DATABASE; use const DOCTRINE_MONGODB_SERVER; +use const PHP_VERSION_ID; abstract class BaseTestCase extends TestCase { @@ -135,7 +137,7 @@ public static function assertArraySubset(array $subset, array $array, bool $chec public static function isLazyObject(object $document): bool { - if (PHP_VERSION_ID >= 80400 && (new \ReflectionClass($document))->getLazyInitializer($document)) { + if (PHP_VERSION_ID >= 80400 && (new ReflectionClass($document))->getLazyInitializer($document)) { return true; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php index e14b6190e..cad605f27 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php @@ -6,8 +6,8 @@ use Doctrine\ODM\MongoDB\Mapping\MappingException; use Doctrine\ODM\MongoDB\Tests\BaseTestCase; -use Documents\PropertyHooks\MappingVirtualProperty; -use Documents\PropertyHooks\User; +use Documents84\PropertyHooks\MappingVirtualProperty; +use Documents84\PropertyHooks\User; use PHPUnit\Framework\Attributes\RequiresPhp; #[RequiresPhp('>= 8.4.0')] diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php index be73863dc..304d9a74c 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php @@ -16,6 +16,7 @@ use stdClass; use function assert; +use function class_exists; abstract class AbstractAnnotationDriverTestCase extends AbstractMappingDriverTestCase { @@ -96,8 +97,6 @@ public function testGetAllClassNamesIsIdempotent(): void { $annotationDriver = $this->loadDriverForCMSDocuments(); $original = $annotationDriver->getAllClassNames(); - - $annotationDriver = $this->loadDriverForCMSDocuments(); $afterTestReset = $annotationDriver->getAllClassNames(); self::assertEquals($original, $afterTestReset); @@ -118,6 +117,7 @@ public function testGetAllClassNamesIsIdempotentEvenWithDifferentDriverInstances /** @group DDC-318 */ public function testGetAllClassNamesReturnsAlreadyLoadedClassesIfAppropriate(): void { + self::assertTrue(class_exists(CmsUser::class), 'Pre-load the class'); $annotationDriver = $this->loadDriverForCMSDocuments(); $classes = $annotationDriver->getAllClassNames(); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php index 847c794d7..0bf67acb0 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php @@ -6,7 +6,7 @@ use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\RawValuePropertyAccessor; use Doctrine\ODM\MongoDB\Tests\BaseTestCase; -use Documents\PropertyHooks\User; +use Documents84\PropertyHooks\User; use PHPUnit\Framework\Attributes\RequiresPhp; use ReflectionObject; diff --git a/tests/Documents/PropertyHooks/MappingVirtualProperty.php b/tests/Documents84/PropertyHooks/MappingVirtualProperty.php similarity index 93% rename from tests/Documents/PropertyHooks/MappingVirtualProperty.php rename to tests/Documents84/PropertyHooks/MappingVirtualProperty.php index f6981e06e..7a7a2b6db 100644 --- a/tests/Documents/PropertyHooks/MappingVirtualProperty.php +++ b/tests/Documents84/PropertyHooks/MappingVirtualProperty.php @@ -1,6 +1,6 @@ Date: Sun, 28 Sep 2025 14:25:40 +0200 Subject: [PATCH 07/31] Use a WeakMap to track generated lazy objects --- .../Proxy/Factory/NativeLazyObjectFactory.php | 28 +++++++++++++++++++ lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 6 ++-- .../ODM/MongoDB/Tests/BaseTestCase.php | 9 ++++-- .../Tests/Functional/Ticket/GH852Test.php | 1 + 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php index 3de1098dd..53455a623 100644 --- a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php @@ -9,13 +9,17 @@ use Doctrine\ODM\MongoDB\UnitOfWork; use LogicException; use ReflectionClass; +use WeakMap; use function count; use const PHP_VERSION_ID; +/** @internal */ class NativeLazyObjectFactory implements ProxyFactory { + private static ?WeakMap $lazyObjects = null; + public function __construct( private readonly UnitOfWork $unitOfWork, ) { @@ -48,6 +52,30 @@ public function getProxy(ClassMetadata $metadata, $identifier): object $metadata->propertyAccessors[$metadata->identifier]->setValue($proxy, $identifier); + if (isset(self::$lazyObjects)) { + self::$lazyObjects[$proxy] = true; + } + return $proxy; } + + /** Only for internal tests */ + public static function enableTracking(bool $enabled = true): void + { + if ($enabled) { + self::$lazyObjects ??= new WeakMap(); + } else { + self::$lazyObjects = null; + } + } + + /** Only for internal tests */ + public static function isLazyObject(object $object): bool + { + if (! isset(self::$lazyObjects)) { + throw new LogicException('Lazy object tracking is not enabled.'); + } + + return self::$lazyObjects->offsetExists($object); + } } diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index 5019f63ac..ee278cff2 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -3089,9 +3089,9 @@ public function initializeObject(object $obj): void public function isUninitializedObject(object $obj): bool { return match (true) { - $obj instanceof InternalProxy => $obj->__isInitialized() === false, - $obj instanceof GhostObjectInterface => $obj->isProxyInitialized() === false, - $obj instanceof PersistentCollectionInterface => $obj->isInitialized() === false, + $obj instanceof InternalProxy => ! $obj->__isInitialized(), + $obj instanceof GhostObjectInterface => ! $obj->isProxyInitialized(), + $obj instanceof PersistentCollectionInterface => ! $obj->isInitialized(), $this->dm->getConfiguration()->isNativeLazyObjectsEnabled() => $this->dm->getClassMetadata($obj::class)->reflClass->isUninitializedLazyObject($obj), default => false }; diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 522289831..12903b97d 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -7,6 +7,7 @@ use Doctrine\ODM\MongoDB\Configuration; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\Driver\AttributeDriver; +use Doctrine\ODM\MongoDB\Proxy\Factory\NativeLazyObjectFactory; use Doctrine\ODM\MongoDB\Proxy\InternalProxy; use Doctrine\ODM\MongoDB\Tests\Query\Filter\Filter; use Doctrine\ODM\MongoDB\UnitOfWork; @@ -109,6 +110,10 @@ protected static function getConfiguration(): Configuration $config->setLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECTS']); $config->enableNativeLazyObjects((bool) $_ENV['USE_NATIVE_LAZY_OBJECTS']); + if ($config->isNativeLazyObjectsEnabled()) { + NativeLazyObjectFactory::enableTracking(); + } + $config->addFilter('testFilter', Filter::class); $config->addFilter('testFilter2', Filter::class); @@ -137,8 +142,8 @@ public static function assertArraySubset(array $subset, array $array, bool $chec public static function isLazyObject(object $document): bool { - if (PHP_VERSION_ID >= 80400 && (new ReflectionClass($document))->getLazyInitializer($document)) { - return true; + if (PHP_VERSION_ID >= 80400 && $_ENV['USE_LAZY_GHOST_OBJECTS'] ?? false) { + return NativeLazyObjectFactory::isLazyObject($document); } return $document instanceof InternalProxy || $document instanceof LazyLoadingInterface; diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH852Test.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH852Test.php index 3895f4325..14e454515 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH852Test.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH852Test.php @@ -14,6 +14,7 @@ use MongoDB\BSON\Binary; use PHPUnit\Framework\Attributes\DataProvider; +/** @see https://github.com/doctrine/mongodb-odm/pull/852 */ class GH852Test extends BaseTestCase { #[DataProvider('provideIdGenerators')] From a588e0bfc64d6e53eb5b14434dda9113b21d9a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sun, 28 Sep 2025 14:26:15 +0200 Subject: [PATCH 08/31] Clear ClassMetadata::(get|set)FieldValue using propertyAccessors --- lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 3e8e11a89..9a02b721c 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -1977,14 +1977,6 @@ public function getIdentifierObject(object $document) */ public function setFieldValue(object $document, string $field, $value): void { - if ($document instanceof InternalProxy && ! $document->__isInitialized()) { - //property changes to an uninitialized proxy will not be tracked or persisted, - //so the proxy needs to be loaded first. - $document->__load(); - } elseif ($document instanceof GhostObjectInterface && ! $document->isProxyInitialized()) { - $document->initializeProxy(); - } - $this->propertyAccessors[$field]->setValue($document, $value); } @@ -1995,12 +1987,6 @@ public function setFieldValue(object $document, string $field, $value): void */ public function getFieldValue(object $document, string $field) { - if ($document instanceof InternalProxy && $field !== $this->identifier && ! $document->__isInitialized()) { - $document->__load(); - } elseif ($document instanceof GhostObjectInterface && $field !== $this->identifier && ! $document->isProxyInitialized()) { - $document->initializeProxy(); - } - return $this->propertyAccessors[$field]->getValue($document); } From 4a263e3611c36263435fb2ab8963c8cb92d8d21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 12:14:41 +0200 Subject: [PATCH 09/31] Fix initialization of lazy objects --- .../ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php | 1 + lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 2 ++ .../ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php | 2 ++ 3 files changed, 5 insertions(+) diff --git a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php index 53455a623..37b448114 100644 --- a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php @@ -18,6 +18,7 @@ /** @internal */ class NativeLazyObjectFactory implements ProxyFactory { + /** @var WeakMap|null */ private static ?WeakMap $lazyObjects = null; public function __construct( diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index ee278cff2..61a28f3ff 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -3078,6 +3078,8 @@ public function initializeObject(object $obj): void $obj->initializeProxy(); } elseif ($obj instanceof PersistentCollectionInterface) { $obj->initialize(); + } elseif ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) { + $this->dm->getClassMetadata($obj::class)->getReflectionClass()->initializeLazyObject($obj); } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php index 7941ff50b..a2e73dab1 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php @@ -89,6 +89,8 @@ public function testCreateProxyForDocumentWithUnmappedProperties(): void $proxy->__setInitialized(true); } elseif ($proxy instanceof GhostObjectInterface) { $proxy->setProxyInitializer(null); + } elseif ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) { + $this->dm->getClassMetadata($proxy::class)->getReflectionClass()->markLazyObjectAsInitialized($proxy); } self::assertSame('bar', $proxy->foo); From 718170f6bd899498d109856cc183735f9296572d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 15:46:01 +0200 Subject: [PATCH 10/31] skip identifier field in computeChangeSet --- lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 5 ++++ .../ODM/MongoDB/Tests/UnitOfWorkTest.php | 29 +++++++++++++++++++ tests/Documents/ForumAvatar.php | 6 ++-- tests/Documents/ForumStar.php | 17 +++++++++++ tests/Documents/ForumUser.php | 13 +++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 tests/Documents/ForumStar.php diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index 61a28f3ff..f9efbb188 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -761,6 +761,11 @@ private function computeOrRecomputeChangeSet(ClassMetadata $class, object $docum continue; } + // skip identifier field + if (in_array($propName, $class->getIdentifierFieldNames(), true)) { + continue; + } + $orgValue = $originalData[$propName] ?? null; // skip if value has not changed diff --git a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php index 55d61fedb..5f7209e4c 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php @@ -20,6 +20,7 @@ use Documents\File; use Documents\FileWithoutMetadata; use Documents\ForumAvatar; +use Documents\ForumStar; use Documents\ForumUser; use Documents\Functional\NotSaved; use Documents\User; @@ -497,6 +498,34 @@ public function testRecomputeChangesetForUninitializedProxyDoesNotCreateChangese self::assertEquals([], $this->uow->getDocumentChangeSet($user->getAvatar())); } + /** + * Native lazy ghosts objects are marked as initialized when the last property + * is set. It appends when the document class has only one property: the id. + */ + public function testRecomputeChangesetForNativeLazyGhostWithOnlyIdDoesNotCreateChangeset(): void + { + $user = new ForumUser(); + $user->username = '12345'; + $user->setStar(new ForumStar()); + + $this->dm->persist($user); + $this->dm->flush(); + + $id = $user->getId(); + $this->dm->clear(); + + $user = $this->dm->find(ForumUser::class, $id); + self::assertInstanceOf(ForumUser::class, $user); + + self::assertTrue(self::isLazyObject($user->getStar())); + + $classMetadata = $this->dm->getClassMetadata(ForumStar::class); + + $this->uow->recomputeSingleDocumentChangeSet($classMetadata, $user->getStar()); + + self::assertEquals([], $this->uow->getDocumentChangeSet($user->getStar())); + } + public function testCommitsInProgressIsUpdatedOnException(): void { $this->dm->getEventManager()->addEventSubscriber( diff --git a/tests/Documents/ForumAvatar.php b/tests/Documents/ForumAvatar.php index 1dc18f24f..c0187a6c6 100644 --- a/tests/Documents/ForumAvatar.php +++ b/tests/Documents/ForumAvatar.php @@ -9,7 +9,9 @@ #[ODM\Document] class ForumAvatar { - /** @var string|null */ #[ODM\Id] - public $id; + public ?string $id; + + #[ODM\Field] + public ?string $url = null; } diff --git a/tests/Documents/ForumStar.php b/tests/Documents/ForumStar.php new file mode 100644 index 000000000..df3fb2ad4 --- /dev/null +++ b/tests/Documents/ForumStar.php @@ -0,0 +1,17 @@ +avatar = $avatar; } + + public function getStar(): ForumStar + { + return $this->star; + } + + public function setStar(ForumStar $star): void + { + $this->star = $star; + } } From 2e7d87ec88c9ffa9d0f217c1a6d19e876aecc55d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 15:46:20 +0200 Subject: [PATCH 11/31] Fix CS --- lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php | 2 -- tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php | 1 - 2 files changed, 3 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 9a02b721c..59346316f 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -18,7 +18,6 @@ use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\EnumPropertyAccessor; use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\PropertyAccessor; use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\PropertyAccessorFactory; -use Doctrine\ODM\MongoDB\Proxy\InternalProxy; use Doctrine\ODM\MongoDB\Types\Incrementable; use Doctrine\ODM\MongoDB\Types\Type; use Doctrine\ODM\MongoDB\Types\Versionable; @@ -31,7 +30,6 @@ use MongoDB\BSON\Decimal128; use MongoDB\BSON\Int64; use MongoDB\BSON\UTCDateTime; -use ProxyManager\Proxy\GhostObjectInterface; use ReflectionClass; use ReflectionEnum; use ReflectionNamedType; diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 12903b97d..14bf4896b 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -21,7 +21,6 @@ use MongoDB\Model\DatabaseInfo; use PHPUnit\Framework\TestCase; use ProxyManager\Proxy\LazyLoadingInterface; -use ReflectionClass; use function array_key_exists; use function array_map; From c32bafce96b3afca99eae8ee16253a2fb5806972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 16:55:10 +0200 Subject: [PATCH 12/31] initialize lazy object when getting/setting a prop value --- .../ODM/MongoDB/Mapping/ClassMetadata.php | 22 +++++++++++++++++++ lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 10 ++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 59346316f..df503e25e 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -18,6 +18,7 @@ use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\EnumPropertyAccessor; use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\PropertyAccessor; use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\PropertyAccessorFactory; +use Doctrine\ODM\MongoDB\Proxy\InternalProxy; use Doctrine\ODM\MongoDB\Types\Incrementable; use Doctrine\ODM\MongoDB\Types\Type; use Doctrine\ODM\MongoDB\Types\Versionable; @@ -30,6 +31,7 @@ use MongoDB\BSON\Decimal128; use MongoDB\BSON\Int64; use MongoDB\BSON\UTCDateTime; +use ProxyManager\Proxy\GhostObjectInterface; use ReflectionClass; use ReflectionEnum; use ReflectionNamedType; @@ -61,6 +63,8 @@ use function strtoupper; use function trigger_deprecation; +use const PHP_VERSION_ID; + /** * A ClassMetadata instance holds all the object-document mapping metadata * of a document and it's references. @@ -1975,6 +1979,16 @@ public function getIdentifierObject(object $document) */ public function setFieldValue(object $document, string $field, $value): void { + if ($document instanceof InternalProxy && ! $document->__isInitialized()) { + //property changes to an uninitialized proxy will not be tracked or persisted, + //so the proxy needs to be loaded first. + $document->__load(); + } elseif ($document instanceof GhostObjectInterface && ! $document->isProxyInitialized()) { + $document->initializeProxy(); + } elseif (PHP_VERSION_ID >= 80400 && $this->reflClass->isUninitializedLazyObject($document)) { + $this->reflClass->initializeLazyObject($document); + } + $this->propertyAccessors[$field]->setValue($document, $value); } @@ -1985,6 +1999,14 @@ public function setFieldValue(object $document, string $field, $value): void */ public function getFieldValue(object $document, string $field) { + if ($document instanceof InternalProxy && $field !== $this->identifier && ! $document->__isInitialized()) { + $document->__load(); + } elseif ($document instanceof GhostObjectInterface && $field !== $this->identifier && ! $document->isProxyInitialized()) { + $document->initializeProxy(); + } elseif (PHP_VERSION_ID >= 80400 && $field !== $this->identifier && $this->reflClass->isUninitializedLazyObject($document)) { + $this->reflClass->initializeLazyObject($document); + } + return $this->propertyAccessors[$field]->getValue($document); } diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index f9efbb188..dbeee3210 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -753,6 +753,11 @@ private function computeOrRecomputeChangeSet(ClassMetadata $class, object $docum } foreach ($actualData as $propName => $actualValue) { + // skip identifier field + if ($propName === $class->identifier) { + continue; + } + // skip not saved fields if ( (isset($class->fieldMappings[$propName]['notSaved']) && $class->fieldMappings[$propName]['notSaved'] === true) || @@ -761,11 +766,6 @@ private function computeOrRecomputeChangeSet(ClassMetadata $class, object $docum continue; } - // skip identifier field - if (in_array($propName, $class->getIdentifierFieldNames(), true)) { - continue; - } - $orgValue = $originalData[$propName] ?? null; // skip if value has not changed From cc703913c38e7f06ad567469519a397353afa46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 17:48:23 +0200 Subject: [PATCH 13/31] Mark lazy object as initialized when hydrated --- lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php b/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php index 050dd61ca..5a821677f 100644 --- a/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php @@ -33,6 +33,7 @@ use function uniqid; use const DIRECTORY_SEPARATOR; +use const PHP_VERSION_ID; /** * The HydratorFactory class is responsible for instantiating a correct hydrator @@ -450,6 +451,10 @@ public function hydrate(object $document, array $data, array $hints = []): array } } + if (PHP_VERSION_ID >= 80400) { + $metadata->reflClass->markLazyObjectAsInitialized($document); + } + if ($document instanceof InternalProxy) { // Skip initialization to not load any object data $document->__setInitialized(true); From d38bbdfd6bfdfc0403bc2037476c3552553f7831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 18:33:55 +0200 Subject: [PATCH 14/31] Fix updating id on embedded documents --- lib/Doctrine/ODM/MongoDB/DocumentManager.php | 2 +- lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index 911bcd2cc..de1e91929 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php +++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php @@ -627,7 +627,7 @@ public function getReference(string $documentName, $identifier): object } $document = $this->proxyFactory->getProxy($class, $identifier); - $this->unitOfWork->registerManaged($document, $identifier, []); + $this->unitOfWork->registerManaged($document, $identifier, [$class->identifier => $identifier]); return $document; } diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index dbeee3210..486af2b3d 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -753,11 +753,6 @@ private function computeOrRecomputeChangeSet(ClassMetadata $class, object $docum } foreach ($actualData as $propName => $actualValue) { - // skip identifier field - if ($propName === $class->identifier) { - continue; - } - // skip not saved fields if ( (isset($class->fieldMappings[$propName]['notSaved']) && $class->fieldMappings[$propName]['notSaved'] === true) || @@ -1995,7 +1990,7 @@ private function doMerge(object $document, array &$visited, ?object $prevManaged ->dm ->getProxyFactory() ->getProxy($targetClass, $relatedId); - $this->registerManaged($other, $relatedId, []); + $this->registerManaged($other, $relatedId, [$targetClass->identifier => $relatedId]); } } From 6987219c08ae4929ba9c283ea4bb8c0bd979b694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 19:04:45 +0200 Subject: [PATCH 15/31] Enable event listeners in initializer --- lib/Doctrine/ODM/MongoDB/DocumentManager.php | 2 +- .../Proxy/Factory/NativeLazyObjectFactory.php | 29 ++++++++++++++----- lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 6 ++-- .../Tests/Functional/ReferencesTest.php | 6 ++++ 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index de1e91929..25176d7da 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php +++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php @@ -182,7 +182,7 @@ protected function __construct(?Client $client = null, ?Configuration $config = $this->unitOfWork = new UnitOfWork($this, $this->eventManager, $this->hydratorFactory); $this->schemaManager = new SchemaManager($this, $this->metadataFactory); $this->proxyFactory = match (true) { - $this->config->isNativeLazyObjectsEnabled() => new NativeLazyObjectFactory($this->unitOfWork), + $this->config->isNativeLazyObjectsEnabled() => new NativeLazyObjectFactory($this), $this->config->isLazyGhostObjectEnabled() => new LazyGhostProxyFactory($this, $this->config->getProxyDir(), $this->config->getProxyNamespace(), $this->config->getAutoGenerateProxyClasses()), default => new StaticProxyFactory($this), }; diff --git a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php index 37b448114..ca72408e7 100644 --- a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php @@ -4,9 +4,12 @@ namespace Doctrine\ODM\MongoDB\Proxy\Factory; +use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\DocumentNotFoundException; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\UnitOfWork; +use Doctrine\ODM\MongoDB\Utility\LifecycleEventManager; +use Doctrine\Persistence\NotifyPropertyChanged; use LogicException; use ReflectionClass; use WeakMap; @@ -21,12 +24,18 @@ class NativeLazyObjectFactory implements ProxyFactory /** @var WeakMap|null */ private static ?WeakMap $lazyObjects = null; + private readonly UnitOfWork $unitOfWork; + private readonly LifecycleEventManager $lifecycleEventManager; + public function __construct( - private readonly UnitOfWork $unitOfWork, + DocumentManager $documentManager, ) { if (PHP_VERSION_ID < 80400) { throw new LogicException('Native lazy objects require PHP 8.4 or higher.'); } + + $this->unitOfWork = $documentManager->getUnitOfWork(); + $this->lifecycleEventManager = new LifecycleEventManager($documentManager, $this->unitOfWork, $documentManager->getEventManager()); } public function generateProxyClasses(array $classes): int @@ -38,15 +47,21 @@ public function generateProxyClasses(array $classes): int public function getProxy(ClassMetadata $metadata, $identifier): object { - $documentPersister = $this->unitOfWork->getDocumentPersister($metadata->name); - - $proxy = $metadata->reflClass->newLazyGhost(static function (object $object) use ( + $proxy = $metadata->reflClass->newLazyGhost(function (object $object) use ( $identifier, - $documentPersister, $metadata, ): void { - $original = $documentPersister->load([$metadata->identifier => $identifier], $object); - if ($original === null) { + $original = $this->unitOfWork->getDocumentPersister($metadata->name)->load([$metadata->identifier => $identifier], $object); + + if ($object instanceof NotifyPropertyChanged) { + $object->addPropertyChangedListener($this->unitOfWork); + } + + if ($original !== null) { + return; + } + + if (! $this->lifecycleEventManager->documentNotFound($object, $identifier)) { throw DocumentNotFoundException::documentNotFound($metadata->name, $identifier); } }, ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE); diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index 486af2b3d..2bf097378 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -52,6 +52,8 @@ use function sprintf; use function trigger_deprecation; +use const PHP_VERSION_ID; + /** * The UnitOfWork is responsible for tracking changes to objects during an * "object-level" transaction and for writing out changes to the database @@ -3078,8 +3080,8 @@ public function initializeObject(object $obj): void $obj->initializeProxy(); } elseif ($obj instanceof PersistentCollectionInterface) { $obj->initialize(); - } elseif ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) { - $this->dm->getClassMetadata($obj::class)->getReflectionClass()->initializeLazyObject($obj); + } elseif (PHP_VERSION_ID >= 80400) { + $this->dm->getClassMetadata($obj::class)->reflClass->initializeLazyObject($obj); } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencesTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencesTest.php index 88cc979f0..88cfad35f 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencesTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencesTest.php @@ -523,6 +523,9 @@ class DocumentWithArrayId /** @var array */ #[ODM\Id(strategy: 'none', options: ['type' => 'hash'])] public $id; + + #[ODM\Field] + public string $name; } @@ -544,6 +547,9 @@ class DocumentWithMongoBinDataId /** @var string|null */ #[ODM\Id(strategy: 'none', options: ['type' => 'bin'])] public $id; + + #[ODM\Field] + public string $name; } class DocumentNotFoundListener From 368dd4cb028e8ecc20325e2065d4167ae60c23bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 19:09:59 +0200 Subject: [PATCH 16/31] Named arguments not allowed in PHPUnit attributes --- .../Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php index 0bf67acb0..bf116c16e 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php @@ -12,7 +12,7 @@ use function trim; -#[RequiresPhp(versionRequirement: '>= 8.4.0')] +#[RequiresPhp('>= 8.4.0')] class RawValuePropertyAccessorTest extends BaseTestCase { public function testSetGetValue(): void From fe87c12ed9f18373ff188bf8d6b7a481cba65676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 8 Oct 2025 19:19:57 +0200 Subject: [PATCH 17/31] CS fixes --- tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php | 3 +-- .../Mapping/PropertyAccessors/EnumPropertyAccessorTest.php | 2 ++ .../PropertyAccessors/ObjectCastPropertyAccessorTest.php | 1 + .../Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 14bf4896b..567d01da0 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -40,7 +40,6 @@ use const DOCTRINE_MONGODB_DATABASE; use const DOCTRINE_MONGODB_SERVER; -use const PHP_VERSION_ID; abstract class BaseTestCase extends TestCase { @@ -141,7 +140,7 @@ public static function assertArraySubset(array $subset, array $array, bool $chec public static function isLazyObject(object $document): bool { - if (PHP_VERSION_ID >= 80400 && $_ENV['USE_LAZY_GHOST_OBJECTS'] ?? false) { + if ($_ENV['USE_LAZY_GHOST_OBJECTS']) { return NativeLazyObjectFactory::isLazyObject($document); } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/EnumPropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/EnumPropertyAccessorTest.php index 019f9a24b..1353ac57d 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/EnumPropertyAccessorTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/EnumPropertyAccessorTest.php @@ -53,6 +53,8 @@ public function testEnumSetDatabaseArrayGetValue(): void class EnumClass { public EnumType $enum; + + /** @var EnumType[] */ public array $enumList; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php index 35bb26006..62563b76a 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php @@ -59,6 +59,7 @@ public function getProperty2(): string } } +/** @implements InternalProxy */ class ObjectClassInternalProxy implements InternalProxy { /** @var string */ diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php index e7797c756..2bdbd681b 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php @@ -39,5 +39,6 @@ public function testReadOnlyPropertyOnlyOnce(): void class ReadOnlyClass { + // @phpstan-ignore property.uninitializedReadonly public readonly int $property; } From 15dabfb6a376fce3c4d741e2c6e1db5d3d30e716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 9 Oct 2025 09:34:46 +0200 Subject: [PATCH 18/31] Remove unused class --- .../EmbeddablePropertyAccessor.php | 53 ------------------- .../ODM/MongoDB/Tests/BaseTestCase.php | 2 +- 2 files changed, 1 insertion(+), 54 deletions(-) delete mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php deleted file mode 100644 index 5191f046e..000000000 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EmbeddablePropertyAccessor.php +++ /dev/null @@ -1,53 +0,0 @@ -parent->getValue($object); - - if ($embeddedObject === null) { - self::$instantiator ??= new Instantiator(); - - $embeddedObject = self::$instantiator->instantiate($this->embeddedClass); - - $this->parent->setValue($object, $embeddedObject); - } - - $this->child->setValue($embeddedObject, $value); - } - - public function getValue(object $object): mixed - { - $embeddedObject = $this->parent->getValue($object); - - if ($embeddedObject === null) { - return null; - } - - return $this->child->getValue($embeddedObject); - } - - public function getUnderlyingReflector(): ReflectionProperty - { - return $this->child->getUnderlyingReflector(); - } -} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 567d01da0..5c2d71e6c 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -140,7 +140,7 @@ public static function assertArraySubset(array $subset, array $array, bool $chec public static function isLazyObject(object $document): bool { - if ($_ENV['USE_LAZY_GHOST_OBJECTS']) { + if ($_ENV['USE_NATIVE_LAZY_OBJECTS']) { return NativeLazyObjectFactory::isLazyObject($document); } From 5a20551656a8086ede5da4a6f995e98e18dc5643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 9 Oct 2025 09:47:42 +0200 Subject: [PATCH 19/31] Fix support for GhostObjectInterface --- .../ObjectCastPropertyAccessor.php | 20 +++++++++-------- .../RawValuePropertyAccessor.php | 22 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php index 578bb2e18..bdc87c92d 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php @@ -5,6 +5,7 @@ namespace Doctrine\ODM\MongoDB\Mapping\PropertyAccessors; use Doctrine\ODM\MongoDB\Proxy\InternalProxy; +use ProxyManager\Proxy\GhostObjectInterface; use ReflectionProperty; use function ltrim; @@ -36,17 +37,18 @@ private function __construct(private ReflectionProperty $reflectionProperty, pri public function setValue(object $object, mixed $value): void { - if (! ($object instanceof InternalProxy && ! $object->__isInitialized())) { + if ($object instanceof InternalProxy && ! $object->__isInitialized()) { + $object->__setInitialized(true); + $this->reflectionProperty->setValue($object, $value); + $object->__setInitialized(false); + } elseif ($object instanceof GhostObjectInterface && ! $object->isProxyInitialized()) { + $initializer = $object->getProxyInitializer(); + $object->setProxyInitializer(); + $this->reflectionProperty->setValue($object, $value); + $object->setProxyInitializer($initializer); + } else { $this->reflectionProperty->setValue($object, $value); - - return; } - - $object->__setInitialized(true); - - $this->reflectionProperty->setValue($object, $value); - - $object->__setInitialized(false); } public function getValue(object $object): mixed diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php index 48d445f23..deb449bfb 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php @@ -39,20 +39,18 @@ private function __construct(private ReflectionProperty $reflectionProperty, pri public function setValue(object $object, mixed $value): void { - if ( - ! ($object instanceof InternalProxy && ! $object->__isInitialized()) && - ! ($object instanceof GhostObjectInterface && ! $object->isProxyInitialized()) - ) { + if ($object instanceof InternalProxy && ! $object->__isInitialized()) { + $object->__setInitialized(true); + $this->reflectionProperty->setRawValue($object, $value); + $object->__setInitialized(false); + } elseif ($object instanceof GhostObjectInterface && ! $object->isProxyInitialized()) { + $initializer = $object->getProxyInitializer(); + $object->setProxyInitializer(null); + $this->reflectionProperty->setRawValue($object, $value); + $object->setProxyInitializer($initializer); + } else { $this->reflectionProperty->setRawValueWithoutLazyInitialization($object, $value); - - return; } - - $object->__setInitialized(true); - - $this->reflectionProperty->setRawValue($object, $value); - - $object->__setInitialized(false); } public function getValue(object $object): mixed From b2dd58b3381476f03061ab5562b3ec8073d61a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 9 Oct 2025 23:38:21 +0200 Subject: [PATCH 20/31] Improve LegacyReflectionFields --- .../ODM/MongoDB/Mapping/ClassMetadata.php | 27 ++++-- .../MongoDB/Mapping/ClassMetadataFactory.php | 2 + .../Mapping/LegacyReflectionFields.php | 82 +++++++------------ .../Mapping/LegacyReflectionFieldsTest.php | 49 +++++++++++ tests/Documents/BaseDocument.php | 2 +- 5 files changed, 101 insertions(+), 61 deletions(-) create mode 100644 tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index df503e25e..f3c4d1261 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -642,7 +642,9 @@ /** * The ReflectionProperty instances of the mapped class. * - * @var LegacyReflectionFields|ReflectionProperty[] + * @deprecated Since 2.13, use $propertyAccessors instead. + * + * @var LegacyReflectionFields|array */ public $reflFields = []; @@ -1505,6 +1507,8 @@ public function isChangeTrackingNotify(): bool /** * Gets the ReflectionProperties of the mapped class. * + * @deprecated Since 2.13, use getPropertyAccessors() instead. + * * @return LegacyReflectionFields|ReflectionProperty[] */ public function getReflectionProperties(): array|LegacyReflectionFields @@ -1524,6 +1528,8 @@ public function getPropertyAccessors(): array /** * Gets a ReflectionProperty for a specific field of the mapped class. + * + * @deprecated Since 2.13, use getPropertyAccessor() instead. */ public function getReflectionProperty(string $name): ReflectionProperty { @@ -2620,7 +2626,7 @@ public function mapField(array $mapping): array * That means any metadata properties that are not set or empty or simply have * their default value are NOT serialized. * - * Parts that are also NOT serialized because they can not be properly unserialized: + * Parts that are also NOT serialized because they cannot be properly unserialized: * - reflClass (ReflectionClass) * - reflFields (ReflectionProperty array) * - propertyAccessors (ReflectionProperty array) @@ -2724,15 +2730,14 @@ public function __sleep() return $serialized; } - /** - * Restores some state that can not be serialized/unserialized. - */ - public function __wakeup() + /** @internal */ + public function wakeupReflection($reflectionService): void { // Restore ReflectionClass and properties - $this->reflectionService = new RuntimeReflectionService(); + $this->reflectionService = $reflectionService; $this->reflClass = new ReflectionClass($this->name); $this->instantiator = new Instantiator(); + $this->reflFields = new LegacyReflectionFields($this, $reflectionService); foreach ($this->fieldMappings as $field => $mapping) { $accessor = PropertyAccessorFactory::createPropertyAccessor($mapping['declared'] ?? $this->name, $field); @@ -2745,6 +2750,14 @@ public function __wakeup() } } + /** + * Restores some state that can not be serialized/unserialized. + */ + public function __wakeup() + { + $this->wakeupReflection(new RuntimeReflectionService()); + } + /** * Creates a new instance of the mapped class, without invoking the constructor. * diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php index c59673fe1..aa32938a2 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php @@ -17,6 +17,7 @@ use Doctrine\ODM\MongoDB\Id\ObjectIdGenerator; use Doctrine\ODM\MongoDB\Id\SymfonyUuidGenerator; use Doctrine\ODM\MongoDB\Id\UuidGenerator; +use Doctrine\ORM\Mapping\MappingException; use Doctrine\Persistence\Mapping\AbstractClassMetadataFactory; use Doctrine\Persistence\Mapping\ClassMetadata as ClassMetadataInterface; use Doctrine\Persistence\Mapping\Driver\MappingDriver; @@ -117,6 +118,7 @@ protected function getDriver(): MappingDriver protected function wakeupReflection(ClassMetadataInterface $class, ReflectionService $reflService): void { + $class->wakeupReflection($reflService); } protected function initializeReflection(ClassMetadataInterface $class, ReflectionService $reflService): void diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php index 709177a71..52853de5b 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php @@ -5,6 +5,7 @@ namespace Doctrine\ODM\MongoDB\Mapping; use ArrayAccess; +use Countable; use Doctrine\ORM\Mapping\ReflectionReadonlyProperty; use Doctrine\Persistence\Mapping\ReflectionService; use Doctrine\Persistence\Reflection\EnumReflectionProperty; @@ -16,16 +17,16 @@ use function array_keys; use function assert; -use function is_string; -use function str_contains; -use function str_replace; +use function count; use function trigger_deprecation; /** + * @internal + * * @template-implements ArrayAccess * @template-implements IteratorAggregate */ -class LegacyReflectionFields implements ArrayAccess, IteratorAggregate +class LegacyReflectionFields implements ArrayAccess, IteratorAggregate, Countable { /** @var array */ private array $reflFields = []; @@ -55,59 +56,27 @@ public function offsetGet($field): mixed // phpcs:ignore trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); - // @todo originalField and originalClass does not exist in ODM - if (isset($this->classMetadata->propertyAccessors[$field])) { - $fieldName = str_contains($field, '.') ? $this->classMetadata->fieldMappings[$field]->originalField : $field; - $className = $this->classMetadata->name; - - assert(is_string($fieldName)); - - if (isset($this->classMetadata->fieldMappings[$field]) && $this->classMetadata->fieldMappings[$field]->originalClass !== null) { - $className = $this->classMetadata->fieldMappings[$field]->originalClass; - } elseif (isset($this->classMetadata->fieldMappings[$field]) && $this->classMetadata->fieldMappings[$field]->declared !== null) { - $className = $this->classMetadata->fieldMappings[$field]->declared; - } elseif (isset($this->classMetadata->associationMappings[$field]) && $this->classMetadata->associationMappings[$field]->declared !== null) { - $className = $this->classMetadata->associationMappings[$field]->declared; - } elseif (isset($this->classMetadata->embeddedClasses[$field]) && $this->classMetadata->embeddedClasses[$field]->declared !== null) { - $className = $this->classMetadata->embeddedClasses[$field]->declared; - } + if (! isset($this->classMetadata->propertyAccessors[$field])) { + throw new OutOfBoundsException('Unknown field: ' . $this->classMetadata->name . ' ::$' . $field); + } - /** @psalm-suppress ArgumentTypeCoercion */ - $this->reflFields[$field] = $this->getAccessibleProperty($className, $fieldName); - - if (isset($this->classMetadata->fieldMappings[$field])) { - if ($this->classMetadata->fieldMappings[$field]->enumType !== null) { - $this->reflFields[$field] = new EnumReflectionProperty( - $this->reflFields[$field], - $this->classMetadata->fieldMappings[$field]->enumType, - ); - } - - if ($this->classMetadata->fieldMappings[$field]->originalField !== null) { - $parentField = str_replace('.' . $fieldName, '', $field); - $originalClass = $this->classMetadata->fieldMappings[$field]->originalClass; - - if (! str_contains($parentField, '.')) { - $parentClass = $this->classMetadata->name; - } else { - $parentClass = $this->classMetadata->fieldMappings[$parentField]->originalClass; - } - - /** @psalm-var class-string $parentClass */ - /** @psalm-var class-string $originalClass */ - - $this->reflFields[$field] = new ReflectionEmbeddedProperty( - $this->getAccessibleProperty($parentClass, $parentField), - $this->reflFields[$field], - $originalClass, - ); - } - } + $className = $this->classMetadata->fieldMappings[$field]['inherited'] + ?? $this->classMetadata->fieldMappings[$field]['declared'] + ?? $this->classMetadata->associationMappings[$field]['declared'] + ?? $this->classMetadata->name; - return $this->reflFields[$field]; + $this->reflFields[$field] = $this->getAccessibleProperty($className, $field); + + if (isset($this->classMetadata->fieldMappings[$field])) { + if ($this->classMetadata->fieldMappings[$field]['enumType'] ?? null) { + $this->reflFields[$field] = new EnumReflectionProperty( + $this->reflFields[$field], + $this->classMetadata->fieldMappings[$field]['enumType'], + ); + } } - throw new OutOfBoundsException('Unknown field: ' . $this->classMetadata->name . ' ::$' . $field); + return $this->reflFields[$field]; } /** @@ -157,4 +126,11 @@ public function getIterator(): Traversable yield $key => $this->offsetGet($key); } } + + public function count(): int + { + trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); + + return count($this->classMetadata->propertyAccessors); + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php new file mode 100644 index 000000000..f36f957f6 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php @@ -0,0 +1,49 @@ +dm->getClassMetadata(User::class); + self::assertInstanceOf(LegacyReflectionFields::class, $class->reflFields); + + $user = new User(); + $user->setUsername('Jean'); + $user->setInheritedProperty('inherited'); + $address = new Address(); + $address->setCity('Paris'); + $user->setAddress($address); + + $this->dm->persist($user); + $this->dm->flush(); + $this->dm->clear(); + + $user = $this->dm->find(User::class, $user->getId()); + + // Accessing the field directly through reflection + self::assertEquals('Jean', $class->getReflectionProperty('username')->getValue($user)); + $class->getReflectionProperty('username')->setValue($user, 'Marie'); + self::assertEquals('Marie', $class->getReflectionProperty('username')->getValue($user)); + + // Accessing a private field 'inheritedProperty' of the parent class through reflection + self::assertEquals('inherited', $class->getReflectionProperty('inheritedProperty')->getValue($user)); + $class->getReflectionProperty('inheritedProperty')->setValue($user, 'changed'); + self::assertEquals('changed', $class->getReflectionProperty('inheritedProperty')->getValue($user)); + + // Accessing a field in a related document through reflection + self::assertEquals('Paris', $class->getReflectionProperty('address')->getValue($user)->getCity()); + $class->getReflectionProperty('address')->setValue($user, $newAddress = new Address()); + self::assertSame($newAddress, $class->getReflectionProperty('address')->getValue($user)); + } +} diff --git a/tests/Documents/BaseDocument.php b/tests/Documents/BaseDocument.php index b107762f3..0f9d335bf 100644 --- a/tests/Documents/BaseDocument.php +++ b/tests/Documents/BaseDocument.php @@ -15,7 +15,7 @@ abstract class BaseDocument /** @var string|null */ #[ODM\Field(type: 'string')] - protected $inheritedProperty; + private $inheritedProperty; public function setInheritedProperty(string $value): void { From cc4051389e446059a748e175402c7f16b6fc9446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 9 Oct 2025 23:48:58 +0200 Subject: [PATCH 21/31] Mapping virtual property is not supported --- .../ODM/MongoDB/Mapping/ClassMetadataFactory.php | 15 ++++++++++++++- .../ODM/MongoDB/Mapping/MappingException.php | 9 +++++++++ .../Tests/Functional/PropertyHooksTest.php | 5 +---- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php index aa32938a2..d30c6fc7e 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php @@ -17,7 +17,6 @@ use Doctrine\ODM\MongoDB\Id\ObjectIdGenerator; use Doctrine\ODM\MongoDB\Id\SymfonyUuidGenerator; use Doctrine\ODM\MongoDB\Id\UuidGenerator; -use Doctrine\ORM\Mapping\MappingException; use Doctrine\Persistence\Mapping\AbstractClassMetadataFactory; use Doctrine\Persistence\Mapping\ClassMetadata as ClassMetadataInterface; use Doctrine\Persistence\Mapping\Driver\MappingDriver; @@ -32,6 +31,8 @@ use function trigger_deprecation; use function ucfirst; +use const PHP_VERSION_ID; + /** * The ClassMetadataFactory is used to create ClassMetadata objects that contain all the * metadata mapping informations of a class which describes how a class should be mapped @@ -119,6 +120,18 @@ protected function getDriver(): MappingDriver protected function wakeupReflection(ClassMetadataInterface $class, ReflectionService $reflService): void { $class->wakeupReflection($reflService); + + if (PHP_VERSION_ID < 80400) { + return; + } + + foreach ($class->propertyAccessors as $propertyAccessor) { + $property = $propertyAccessor->getUnderlyingReflector(); + + if ($property->isVirtual()) { + throw MappingException::mappingVirtualPropertyNotAllowed($class->name, $property->getName()); + } + } } protected function initializeReflection(ClassMetadataInterface $class, ReflectionService $reflService): void diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php index abd19c153..d91b95e5b 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php @@ -337,4 +337,13 @@ public static function autoIdGeneratorNeedsType(string $className, string $ident $identifierFieldName, )); } + + public static function mappingVirtualPropertyNotAllowed(string $entityName, string $propertyName): self + { + return new self(sprintf( + 'Mapping virtual property "%s" on document "%s" is not allowed.', + $propertyName, + $entityName, + )); + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php index cad605f27..88df0329a 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php @@ -92,11 +92,8 @@ public function testTriggerLazyLoadingWhenAccessingPropertyHooks(): void public function testMappingVirtualPropertyIsNotSupported(): void { - // @todo remove if not relevant - self::markTestSkipped('Imported from ORM, but there is no virtual property support in MongoDB ODM.'); - $this->expectException(MappingException::class); - $this->expectExceptionMessage('Mapping virtual property "fullName" on entity "Documents\PropertyHooks\MappingVirtualProperty" is not allowed.'); + $this->expectExceptionMessage('Mapping virtual property "fullName" on document "Documents84\PropertyHooks\MappingVirtualProperty" is not allowed.'); $this->dm->getClassMetadata(MappingVirtualProperty::class); } From 88f7deeaf811c94b1408d3d7cc9e31c7e0abeb23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 11 Oct 2025 21:48:17 +0200 Subject: [PATCH 22/31] Remove unused $reflectionService property --- .../ODM/MongoDB/Mapping/ClassMetadata.php | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index f3c4d1261..9ecf1d94a 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -24,7 +24,6 @@ use Doctrine\ODM\MongoDB\Types\Versionable; use Doctrine\ODM\MongoDB\Utility\CollectionHelper; use Doctrine\Persistence\Mapping\ClassMetadata as BaseClassMetadata; -use Doctrine\Persistence\Mapping\ReflectionService; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use InvalidArgumentException; use LogicException; @@ -862,8 +861,6 @@ private InstantiatorInterface $instantiator; - private ReflectionService $reflectionService; - /** @var class-string|null */ private ?string $rootClass; @@ -875,10 +872,9 @@ */ public function __construct(string $documentName) { - $this->name = $documentName; - $this->rootDocumentName = $documentName; - $this->reflectionService = new RuntimeReflectionService(); - $this->reflClass = new ReflectionClass($documentName); + $this->name = $documentName; + $this->rootDocumentName = $documentName; + $this->reflClass = new ReflectionClass($documentName); $this->setCollection($this->reflClass->getShortName()); $this->instantiator = new Instantiator(); } @@ -2734,10 +2730,9 @@ public function __sleep() public function wakeupReflection($reflectionService): void { // Restore ReflectionClass and properties - $this->reflectionService = $reflectionService; - $this->reflClass = new ReflectionClass($this->name); - $this->instantiator = new Instantiator(); - $this->reflFields = new LegacyReflectionFields($this, $reflectionService); + $this->reflClass = new ReflectionClass($this->name); + $this->instantiator = new Instantiator(); + $this->reflFields = new LegacyReflectionFields($this, $reflectionService); foreach ($this->fieldMappings as $field => $mapping) { $accessor = PropertyAccessorFactory::createPropertyAccessor($mapping['declared'] ?? $this->name, $field); From 69436d9bf7dba9cebf9c644ce3a9c7966a60f869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 13 Oct 2025 17:54:47 +0200 Subject: [PATCH 23/31] Revert wakeupReflection Never implemented in ODM --- lib/Doctrine/ODM/MongoDB/Configuration.php | 2 +- .../ODM/MongoDB/Mapping/ClassMetadata.php | 38 ++++++++++--------- .../MongoDB/Mapping/ClassMetadataFactory.php | 2 - 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 1d80fffb3..c8c97adaf 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -695,7 +695,7 @@ public function setLazyGhostObject(bool $flag): void throw new LogicException('Cannot enable or disable LazyGhostObject when native lazy objects are enabled.'); } - if ($flag) { + if ($flag === false) { if (! class_exists(ProxyManagerConfiguration::class)) { throw new LogicException('Package "friendsofphp/proxy-manager-lts" is required to disable LazyGhostObject.'); } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 9ecf1d94a..8b351816b 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -24,6 +24,7 @@ use Doctrine\ODM\MongoDB\Types\Versionable; use Doctrine\ODM\MongoDB\Utility\CollectionHelper; use Doctrine\Persistence\Mapping\ClassMetadata as BaseClassMetadata; +use Doctrine\Persistence\Mapping\ReflectionService; use Doctrine\Persistence\Mapping\RuntimeReflectionService; use InvalidArgumentException; use LogicException; @@ -861,6 +862,8 @@ private InstantiatorInterface $instantiator; + private ReflectionService $reflectionService; + /** @var class-string|null */ private ?string $rootClass; @@ -872,9 +875,11 @@ */ public function __construct(string $documentName) { - $this->name = $documentName; - $this->rootDocumentName = $documentName; - $this->reflClass = new ReflectionClass($documentName); + $this->name = $documentName; + $this->rootDocumentName = $documentName; + $this->reflectionService = new RuntimeReflectionService(); + $this->reflClass = new ReflectionClass($documentName); + $this->reflFields = new LegacyReflectionFields($this, $this->reflectionService); $this->setCollection($this->reflClass->getShortName()); $this->instantiator = new Instantiator(); } @@ -1505,7 +1510,7 @@ public function isChangeTrackingNotify(): bool * * @deprecated Since 2.13, use getPropertyAccessors() instead. * - * @return LegacyReflectionFields|ReflectionProperty[] + * @return array|LegacyReflectionFields */ public function getReflectionProperties(): array|LegacyReflectionFields { @@ -2597,6 +2602,10 @@ public function mapField(array $mapping): array $accessor = PropertyAccessorFactory::createPropertyAccessor($this->name, $mapping['fieldName']); + if (PHP_VERSION_ID >= 80400 && $accessor->getUnderlyingReflector()->isVirtual()) { + throw MappingException::mappingVirtualPropertyNotAllowed($this->name, $mapping['fieldName']); + } + if (isset($mapping['enumType'])) { if (! enum_exists($mapping['enumType'])) { throw MappingException::nonEnumTypeMapped($this->name, $mapping['fieldName'], $mapping['enumType']); @@ -2726,13 +2735,16 @@ public function __sleep() return $serialized; } - /** @internal */ - public function wakeupReflection($reflectionService): void + /** + * Restores some state that cannot be serialized/unserialized. + */ + public function __wakeup(): void { // Restore ReflectionClass and properties - $this->reflClass = new ReflectionClass($this->name); - $this->instantiator = new Instantiator(); - $this->reflFields = new LegacyReflectionFields($this, $reflectionService); + $this->reflClass = new ReflectionClass($this->name); + $this->instantiator = new Instantiator(); + $this->reflectionService = new RuntimeReflectionService(); + $this->reflFields = new LegacyReflectionFields($this, $this->reflectionService); foreach ($this->fieldMappings as $field => $mapping) { $accessor = PropertyAccessorFactory::createPropertyAccessor($mapping['declared'] ?? $this->name, $field); @@ -2745,14 +2757,6 @@ public function wakeupReflection($reflectionService): void } } - /** - * Restores some state that can not be serialized/unserialized. - */ - public function __wakeup() - { - $this->wakeupReflection(new RuntimeReflectionService()); - } - /** * Creates a new instance of the mapped class, without invoking the constructor. * diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php index d30c6fc7e..6f02bde63 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php @@ -119,8 +119,6 @@ protected function getDriver(): MappingDriver protected function wakeupReflection(ClassMetadataInterface $class, ReflectionService $reflService): void { - $class->wakeupReflection($reflService); - if (PHP_VERSION_ID < 80400) { return; } From c938489ad96df52028da7b15161a636f76b194af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 13 Oct 2025 18:02:43 +0200 Subject: [PATCH 24/31] Baseline phpstan false-positive --- phpstan-baseline.neon | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b123bec67..3674e98f4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -384,18 +384,6 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/Annotations/SearchIndex.php - - - message: '#^Call to function assert\(\) with true will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - - - message: '#^Instanceof between Doctrine\\Persistence\\Reflection\\RuntimeReflectionProperty and ReflectionProperty will always evaluate to true\.$#' - identifier: instanceof.alwaysTrue - count: 1 - path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php - - message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:addInheritedAssociationMapping\(\) has Doctrine\\ODM\\MongoDB\\Mapping\\MappingException in PHPDoc @throws tag but it''s not thrown\.$#' identifier: throws.unusedType @@ -498,6 +486,18 @@ parameters: count: 1 path: lib/Doctrine/ODM/MongoDB/Mapping/Driver/XmlDriver.php + - + message: '#^Parameter \#1 \$initializer of method ProxyManager\\Proxy\\GhostObjectInterface\\:\:setProxyInitializer\(\) expects \(Closure\(ProxyManager\\Proxy\\GhostObjectInterface\\=, string\=, array\\=, Closure\|null\=, array\\=\)\: bool\)\|null, \(Closure\(ProxyManager\\Proxy\\GhostObjectInterface\\=, string, array\\=, Closure\|null\=, array\\=\)\: bool\)\|null given\.$#' + identifier: argument.type + count: 1 + path: lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php + + - + message: '#^Parameter \#1 \$initializer of method ProxyManager\\Proxy\\GhostObjectInterface\\:\:setProxyInitializer\(\) expects \(Closure\(ProxyManager\\Proxy\\GhostObjectInterface\\=, string\=, array\\=, Closure\|null\=, array\\=\)\: bool\)\|null, \(Closure\(ProxyManager\\Proxy\\GhostObjectInterface\\=, string, array\\=, Closure\|null\=, array\\=\)\: bool\)\|null given\.$#' + identifier: argument.type + count: 1 + path: lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php + - message: '#^Call to function is_object\(\) with Doctrine\\Common\\Collections\\Collection will always evaluate to true\.$#' identifier: function.alreadyNarrowedType From 2aa3e69b5539142201fe8c84815dbcdbc31e65e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 20 Oct 2025 18:17:37 +0200 Subject: [PATCH 25/31] Update deprecation version numbers --- lib/Doctrine/ODM/MongoDB/Configuration.php | 4 ++-- .../ODM/MongoDB/Mapping/LegacyReflectionFields.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index c8c97adaf..1a7590dc0 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -704,7 +704,7 @@ public function setLazyGhostObject(bool $flag): void } if ($flag === true && PHP_VERSION_ID >= 80400) { - trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Using "symfony/var-exporter" lazy ghost objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); + trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Using "symfony/var-exporter" lazy ghost objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); } $this->lazyGhostObject = $flag; @@ -718,7 +718,7 @@ public function isLazyGhostObjectEnabled(): bool public function enableNativeLazyObjects(bool $nativeLazyObjects): void { if (PHP_VERSION_ID >= 80400 && ! $nativeLazyObjects) { - trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Disabling native lazy objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); + trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Disabling native lazy objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); } if (PHP_VERSION_ID < 80400 && $nativeLazyObjects) { diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php index 52853de5b..bbea05d07 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php @@ -38,7 +38,7 @@ public function __construct(private ClassMetadata $classMetadata, private Reflec /** @param string $offset */ public function offsetExists($offset): bool // phpcs:ignore { - trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); + trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); return isset($this->classMetadata->propertyAccessors[$offset]); } @@ -54,7 +54,7 @@ public function offsetGet($field): mixed // phpcs:ignore return $this->reflFields[$field]; } - trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); + trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); if (! isset($this->classMetadata->propertyAccessors[$field])) { throw new OutOfBoundsException('Unknown field: ' . $this->classMetadata->name . ' ::$' . $field); @@ -118,7 +118,7 @@ private function getAccessibleProperty(string $class, string $field): Reflection /** @return Generator */ public function getIterator(): Traversable { - trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); + trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); $keys = array_keys($this->classMetadata->propertyAccessors); @@ -129,7 +129,7 @@ public function getIterator(): Traversable public function count(): int { - trigger_deprecation('doctrine/mongodb-odm', '2.13', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); + trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Access to ClassMetadata::$reflFields is deprecated and will be removed in Doctrine ODM 3.0.'); return count($this->classMetadata->propertyAccessors); } From b91d647f453e864137719e2fc6f89a0c01c1dda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 20 Oct 2025 18:17:37 +0200 Subject: [PATCH 26/31] More tests --- .../ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php index f36f957f6..15afe5600 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php @@ -45,5 +45,10 @@ public function testGetSet(): void self::assertEquals('Paris', $class->getReflectionProperty('address')->getValue($user)->getCity()); $class->getReflectionProperty('address')->setValue($user, $newAddress = new Address()); self::assertSame($newAddress, $class->getReflectionProperty('address')->getValue($user)); + + // ArrayAccess and Countable interfaces + self::assertCount(32, $class->reflFields); + self::assertArrayHasKey('username', $class->reflFields); + self::assertArrayNotHasKey('nonExistentField', $class->reflFields); } } From a0cdabfa77b919ca03a9ff54e1db76be211f1731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 21 Oct 2025 16:50:19 +0200 Subject: [PATCH 27/31] Review --- lib/Doctrine/ODM/MongoDB/Configuration.php | 16 ++++++++-------- lib/Doctrine/ODM/MongoDB/DocumentManager.php | 2 +- .../RawValuePropertyAccessor.php | 2 ++ .../Proxy/Factory/NativeLazyObjectFactory.php | 4 ++-- lib/Doctrine/ODM/MongoDB/UnitOfWork.php | 4 ++-- .../Doctrine/ODM/MongoDB/Tests/BaseTestCase.php | 6 +++--- .../Tests/Functional/PropertyHooksTest.php | 2 +- .../Tests/Functional/ReferencePrimerTest.php | 1 + .../Mapping/AbstractAnnotationDriverTestCase.php | 2 ++ .../Tests/Proxy/Factory/ProxyFactoryTest.php | 2 +- 10 files changed, 23 insertions(+), 18 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 1a7590dc0..145aa0dbb 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -689,7 +689,7 @@ public function isTransactionalFlushEnabled(): bool * Generate proxy classes using Symfony VarExporter's LazyGhostTrait if true. * Otherwise, use ProxyManager's LazyLoadingGhostFactory (deprecated) */ - public function setLazyGhostObject(bool $flag): void + public function setUseLazyGhostObject(bool $flag): void { if ($this->nativeLazyObjects) { throw new LogicException('Cannot enable or disable LazyGhostObject when native lazy objects are enabled.'); @@ -715,21 +715,21 @@ public function isLazyGhostObjectEnabled(): bool return $this->lazyGhostObject; } - public function enableNativeLazyObjects(bool $nativeLazyObjects): void + public function setUseNativeLazyObject(bool $nativeLazyObject): void { - if (PHP_VERSION_ID >= 80400 && ! $nativeLazyObjects) { + if (PHP_VERSION_ID >= 80400 && ! $nativeLazyObject) { trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Disabling native lazy objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); } - if (PHP_VERSION_ID < 80400 && $nativeLazyObjects) { - throw new LogicException('Lazy loading proxies require PHP 8.4 or higher.'); + if (PHP_VERSION_ID < 80400 && $nativeLazyObject) { + throw new LogicException('Native lazy objects require PHP 8.4 or higher.'); } - $this->nativeLazyObjects = $nativeLazyObjects; - $this->lazyGhostObject = ! $nativeLazyObjects || $this->lazyGhostObject; + $this->nativeLazyObjects = $nativeLazyObject; + $this->lazyGhostObject = ! $nativeLazyObject || $this->lazyGhostObject; } - public function isNativeLazyObjectsEnabled(): bool + public function isNativeLazyObjectEnabled(): bool { return $this->nativeLazyObjects; } diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index 25176d7da..7ed9e3e73 100644 --- a/lib/Doctrine/ODM/MongoDB/DocumentManager.php +++ b/lib/Doctrine/ODM/MongoDB/DocumentManager.php @@ -182,7 +182,7 @@ protected function __construct(?Client $client = null, ?Configuration $config = $this->unitOfWork = new UnitOfWork($this, $this->eventManager, $this->hydratorFactory); $this->schemaManager = new SchemaManager($this, $this->metadataFactory); $this->proxyFactory = match (true) { - $this->config->isNativeLazyObjectsEnabled() => new NativeLazyObjectFactory($this), + $this->config->isNativeLazyObjectEnabled() => new NativeLazyObjectFactory($this), $this->config->isLazyGhostObjectEnabled() => new LazyGhostProxyFactory($this, $this->config->getProxyDir(), $this->config->getProxyNamespace(), $this->config->getAutoGenerateProxyClasses()), default => new StaticProxyFactory($this), }; diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php index deb449bfb..54fee78b6 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php @@ -19,6 +19,8 @@ * It works based on the raw values of a property, which for a case of property hooks * is the backed value. If we kept using setValue/getValue, this would go through the hooks, * which potentially change the data. + * + * @internal */ class RawValuePropertyAccessor implements PropertyAccessor { diff --git a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php index ca72408e7..35552cfb6 100644 --- a/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php @@ -75,7 +75,7 @@ public function getProxy(ClassMetadata $metadata, $identifier): object return $proxy; } - /** Only for internal tests */ + /** @internal Only for tests */ public static function enableTracking(bool $enabled = true): void { if ($enabled) { @@ -85,7 +85,7 @@ public static function enableTracking(bool $enabled = true): void } } - /** Only for internal tests */ + /** @internal Only for tests */ public static function isLazyObject(object $object): bool { if (! isset(self::$lazyObjects)) { diff --git a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php index 2bf097378..47fb4233a 100644 --- a/lib/Doctrine/ODM/MongoDB/UnitOfWork.php +++ b/lib/Doctrine/ODM/MongoDB/UnitOfWork.php @@ -2785,7 +2785,7 @@ public function getOrCreateDocument(string $className, array $data, array &$hint $document = $this->identityMap[$class->name][$serializedId]; $oid = spl_object_id($document); if ($this->isUninitializedObject($document)) { - if ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) { + if ($this->dm->getConfiguration()->isNativeLazyObjectEnabled()) { $class->reflClass->markLazyObjectAsInitialized($document); } elseif ($document instanceof InternalProxy) { $document->__setInitialized(true); @@ -3096,7 +3096,7 @@ public function isUninitializedObject(object $obj): bool $obj instanceof InternalProxy => ! $obj->__isInitialized(), $obj instanceof GhostObjectInterface => ! $obj->isProxyInitialized(), $obj instanceof PersistentCollectionInterface => ! $obj->isInitialized(), - $this->dm->getConfiguration()->isNativeLazyObjectsEnabled() => $this->dm->getClassMetadata($obj::class)->reflClass->isUninitializedLazyObject($obj), + $this->dm->getConfiguration()->isNativeLazyObjectEnabled() => $this->dm->getClassMetadata($obj::class)->reflClass->isUninitializedLazyObject($obj), default => false }; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 5c2d71e6c..57ec11f97 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -105,10 +105,10 @@ protected static function getConfiguration(): Configuration $config->setPersistentCollectionNamespace('PersistentCollections'); $config->setDefaultDB(DOCTRINE_MONGODB_DATABASE); $config->setMetadataDriverImpl(static::createMetadataDriverImpl()); - $config->setLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECTS']); - $config->enableNativeLazyObjects((bool) $_ENV['USE_NATIVE_LAZY_OBJECTS']); + $config->setUseLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECTS']); + $config->setUseNativeLazyObject((bool) $_ENV['USE_NATIVE_LAZY_OBJECTS']); - if ($config->isNativeLazyObjectsEnabled()) { + if ($config->isNativeLazyObjectEnabled()) { NativeLazyObjectFactory::enableTracking(); } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php index 88df0329a..2ffeb1ee7 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php @@ -17,7 +17,7 @@ protected function setUp(): void { parent::setUp(); - if ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) { + if ($this->dm->getConfiguration()->isNativeLazyObjectEnabled()) { return; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php index f7ad51830..8017b89f3 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencePrimerTest.php @@ -94,6 +94,7 @@ public function testPrimeReferencesWithDBRefObjects(): void ->field('groups')->prime(true); foreach ($qb->getQuery() as $user) { + self::assertTrue(self::isLazyObject($user->getAccount())); self::assertFalse($this->uow->isUninitializedObject($user->getAccount())); self::assertCount(2, $user->getGroups()); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php index 304d9a74c..d30aeeb96 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php @@ -97,6 +97,8 @@ public function testGetAllClassNamesIsIdempotent(): void { $annotationDriver = $this->loadDriverForCMSDocuments(); $original = $annotationDriver->getAllClassNames(); + + $annotationDriver = $this->loadDriverForCMSDocuments(); $afterTestReset = $annotationDriver->getAllClassNames(); self::assertEquals($original, $afterTestReset); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php index a2e73dab1..a25575f1b 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php @@ -89,7 +89,7 @@ public function testCreateProxyForDocumentWithUnmappedProperties(): void $proxy->__setInitialized(true); } elseif ($proxy instanceof GhostObjectInterface) { $proxy->setProxyInitializer(null); - } elseif ($this->dm->getConfiguration()->isNativeLazyObjectsEnabled()) { + } elseif ($this->dm->getConfiguration()->isNativeLazyObjectEnabled()) { $this->dm->getClassMetadata($proxy::class)->getReflectionClass()->markLazyObjectAsInitialized($proxy); } From 1b808da1e3021783a850be7f0e88b1e994507252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 21 Oct 2025 23:04:33 +0200 Subject: [PATCH 28/31] Small refacto using match --- .../ObjectCastPropertyAccessor.php | 13 +++++++++++-- .../PropertyAccessors/PropertyAccessorFactory.php | 1 + .../PropertyAccessors/RawValuePropertyAccessor.php | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php index bdc87c92d..dced04aed 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php @@ -18,7 +18,11 @@ public static function fromNames(string $class, string $name): self { $reflectionProperty = new ReflectionProperty($class, $name); - $key = $reflectionProperty->isPrivate() ? "\0" . ltrim($class, '\\') . "\0" . $name : ($reflectionProperty->isProtected() ? "\0*\0" . $name : $name); + $key = match (true) { + $reflectionProperty->isPrivate() => "\0" . ltrim($class, '\\') . "\0" . $name, + $reflectionProperty->isProtected() => "\0*\0" . $name, + default => $name, + }; return new self($reflectionProperty, $key); } @@ -26,7 +30,12 @@ public static function fromNames(string $class, string $name): self public static function fromReflectionProperty(ReflectionProperty $reflectionProperty): self { $name = $reflectionProperty->getName(); - $key = $reflectionProperty->isPrivate() ? "\0" . ltrim($reflectionProperty->getDeclaringClass()->getName(), '\\') . "\0" . $name : ($reflectionProperty->isProtected() ? "\0*\0" . $name : $name); + + $key = match (true) { + $reflectionProperty->isPrivate() => "\0" . ltrim($reflectionProperty->getDeclaringClass()->getName(), '\\') . "\0" . $name, + $reflectionProperty->isProtected() => "\0*\0" . $name, + default => $name, + }; return new self($reflectionProperty, $key); } diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php index 97e5135f6..5b7aef171 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessorFactory.php @@ -8,6 +8,7 @@ use const PHP_VERSION_ID; +/** @internal */ class PropertyAccessorFactory { /** @phpstan-param class-string $className */ diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php index 54fee78b6..d84beb852 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php @@ -27,7 +27,12 @@ class RawValuePropertyAccessor implements PropertyAccessor public static function fromReflectionProperty(ReflectionProperty $reflectionProperty): self { $name = $reflectionProperty->getName(); - $key = $reflectionProperty->isPrivate() ? "\0" . ltrim($reflectionProperty->getDeclaringClass()->getName(), '\\') . "\0" . $name : ($reflectionProperty->isProtected() ? "\0*\0" . $name : $name); + + $key = match (true) { + $reflectionProperty->isPrivate() => "\0" . ltrim($reflectionProperty->getDeclaringClass()->getName(), '\\') . "\0" . $name, + $reflectionProperty->isProtected() => "\0*\0" . $name, + default => $name, + }; return new self($reflectionProperty, $key); } From 4269bb7974d0458252cf8630f0e36fd79b50a599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 22 Oct 2025 12:06:36 +0200 Subject: [PATCH 29/31] Rename test env var to remove S --- .github/workflows/atlas-ci.yml | 2 +- .github/workflows/continuous-integration.yml | 4 ++-- lib/Doctrine/ODM/MongoDB/Configuration.php | 12 ++++++------ lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php | 2 +- phpunit.xml.dist | 2 +- tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/atlas-ci.yml b/.github/workflows/atlas-ci.yml index e113452fd..cefe969e1 100644 --- a/.github/workflows/atlas-ci.yml +++ b/.github/workflows/atlas-ci.yml @@ -96,4 +96,4 @@ jobs: run: "vendor/bin/phpunit --group atlas" env: DOCTRINE_MONGODB_SERVER: "mongodb://127.0.0.1:27017/?directConnection=true" - USE_LAZY_GHOST_OBJECTS: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }} + USE_LAZY_GHOST_OBJECT: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }} diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index a3e3360a9..d239d6a0b 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -169,6 +169,6 @@ jobs: run: "vendor/bin/phpunit --exclude-group=atlas" env: DOCTRINE_MONGODB_SERVER: ${{ steps.setup-mongodb.outputs.cluster-uri }} - USE_LAZY_GHOST_OBJECTS: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }}" - USE_NATIVE_LAZY_OBJECTS: ${{ matrix.proxy == 'native' && '1' || '0' }}" + USE_LAZY_GHOST_OBJECT: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }}" + USE_NATIVE_LAZY_OBJECT: ${{ matrix.proxy == 'native' && '1' || '0' }}" CRYPT_SHARED_LIB_PATH: ${{ steps.setup-mongodb.outputs.crypt-shared-lib-path }} diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 145aa0dbb..d4241161a 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -147,8 +147,8 @@ class Configuration private bool $useTransactionalFlush = false; - private bool $lazyGhostObject = false; - private bool $nativeLazyObjects = false; + private bool $lazyGhostObject = false; + private bool $nativeLazyObject = false; private static string $version; @@ -691,7 +691,7 @@ public function isTransactionalFlushEnabled(): bool */ public function setUseLazyGhostObject(bool $flag): void { - if ($this->nativeLazyObjects) { + if ($this->nativeLazyObject) { throw new LogicException('Cannot enable or disable LazyGhostObject when native lazy objects are enabled.'); } @@ -725,13 +725,13 @@ public function setUseNativeLazyObject(bool $nativeLazyObject): void throw new LogicException('Native lazy objects require PHP 8.4 or higher.'); } - $this->nativeLazyObjects = $nativeLazyObject; - $this->lazyGhostObject = ! $nativeLazyObject || $this->lazyGhostObject; + $this->nativeLazyObject = $nativeLazyObject; + $this->lazyGhostObject = ! $nativeLazyObject || $this->lazyGhostObject; } public function isNativeLazyObjectEnabled(): bool { - return $this->nativeLazyObjects; + return $this->nativeLazyObject; } /** diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 8b351816b..04ca7c112 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -1992,7 +1992,7 @@ public function setFieldValue(object $document, string $field, $value): void $document->__load(); } elseif ($document instanceof GhostObjectInterface && ! $document->isProxyInitialized()) { $document->initializeProxy(); - } elseif (PHP_VERSION_ID >= 80400 && $this->reflClass->isUninitializedLazyObject($document)) { + } elseif (PHP_VERSION_ID >= 80400) { $this->reflClass->initializeLazyObject($document); } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3860390bd..f2bb19eb9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -29,6 +29,6 @@ - + diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 57ec11f97..97b6be2f8 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -105,8 +105,8 @@ protected static function getConfiguration(): Configuration $config->setPersistentCollectionNamespace('PersistentCollections'); $config->setDefaultDB(DOCTRINE_MONGODB_DATABASE); $config->setMetadataDriverImpl(static::createMetadataDriverImpl()); - $config->setUseLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECTS']); - $config->setUseNativeLazyObject((bool) $_ENV['USE_NATIVE_LAZY_OBJECTS']); + $config->setUseLazyGhostObject((bool) $_ENV['USE_LAZY_GHOST_OBJECT']); + $config->setUseNativeLazyObject((bool) $_ENV['USE_NATIVE_LAZY_OBJECT']); if ($config->isNativeLazyObjectEnabled()) { NativeLazyObjectFactory::enableTracking(); @@ -140,7 +140,7 @@ public static function assertArraySubset(array $subset, array $array, bool $chec public static function isLazyObject(object $document): bool { - if ($_ENV['USE_NATIVE_LAZY_OBJECTS']) { + if ($_ENV['USE_NATIVE_LAZY_OBJECT']) { return NativeLazyObjectFactory::isLazyObject($document); } From 27f345ef66858c0fba56eb337f0c5ab5615d866d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 22 Oct 2025 12:44:25 +0200 Subject: [PATCH 30/31] Fix missing ReflectionReadonlyProperty --- .../Mapping/LegacyReflectionFields.php | 2 +- .../ReflectionReadonlyProperty.php | 49 +++++++++++++++++++ .../Mapping/LegacyReflectionFieldsTest.php | 21 ++++++++ tests/Documents/Tag.php | 8 ++- 4 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ReflectionReadonlyProperty.php diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php index bbea05d07..138f6fa68 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php @@ -6,7 +6,7 @@ use ArrayAccess; use Countable; -use Doctrine\ORM\Mapping\ReflectionReadonlyProperty; +use Doctrine\ODM\MongoDB\Mapping\PropertyAccessors\ReflectionReadonlyProperty; use Doctrine\Persistence\Mapping\ReflectionService; use Doctrine\Persistence\Reflection\EnumReflectionProperty; use Generator; diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ReflectionReadonlyProperty.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ReflectionReadonlyProperty.php new file mode 100644 index 000000000..e49b9f876 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ReflectionReadonlyProperty.php @@ -0,0 +1,49 @@ +isReadOnly()) { + throw new InvalidArgumentException('Given property is not readonly.'); + } + + parent::__construct($wrappedProperty->class, $wrappedProperty->name); + } + + public function getValue(object|null $object = null): mixed + { + return $this->wrappedProperty->getValue(...func_get_args()); + } + + public function setValue(mixed $objectOrValue, mixed $value = null): void + { + if (func_num_args() < 2 || $objectOrValue === null || ! $this->isInitialized($objectOrValue)) { + $this->wrappedProperty->setValue(...func_get_args()); + + return; + } + + assert(is_object($objectOrValue)); + + if (parent::getValue($objectOrValue) !== $value) { + throw new LogicException(sprintf('Attempting to change readonly property %s::$%s.', $this->class, $this->name)); + } + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php index 15afe5600..860f95313 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php @@ -7,7 +7,9 @@ use Doctrine\ODM\MongoDB\Mapping\LegacyReflectionFields; use Doctrine\ODM\MongoDB\Tests\BaseTestCase; use Documents\Address; +use Documents\Tag; use Documents\User; +use LogicException; use PHPUnit\Framework\Attributes\IgnoreDeprecations; #[IgnoreDeprecations] @@ -51,4 +53,23 @@ public function testGetSet(): void self::assertArrayHasKey('username', $class->reflFields); self::assertArrayNotHasKey('nonExistentField', $class->reflFields); } + + public function testGetSetReadonly(): void + { + $class = $this->dm->getClassMetadata(Tag::class); + self::assertInstanceOf(LegacyReflectionFields::class, $class->reflFields); + + $tag = new Tag('Important'); + $this->dm->persist($tag); + $this->dm->flush(); + + $tag = $this->dm->find(Tag::class, $tag->id); + + // Accessing the readonly property through reflection + self::assertEquals('Important', $class->getReflectionProperty('name')->getValue($tag)); + + self::expectException(LogicException::class); + self::expectExceptionMessage('Attempting to change readonly property Documents\Tag::$name'); + $class->getReflectionProperty('name')->setValue($tag, 'Very Important'); + } } diff --git a/tests/Documents/Tag.php b/tests/Documents/Tag.php index 4814a2afa..5461d1d7e 100644 --- a/tests/Documents/Tag.php +++ b/tests/Documents/Tag.php @@ -10,13 +10,11 @@ #[ODM\Document] class Tag { - /** @var string|null */ #[ODM\Id] - public $id; + public ?string $id; - /** @var string */ - #[ODM\Field(type: 'string')] - public $name; + #[ODM\Field] + public readonly string $name; /** @var Collection */ #[ODM\ReferenceMany(targetDocument: BlogPost::class, mappedBy: 'tags')] From 62ef638a98361f92685fc39b66046afbd6dc1580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 22 Oct 2025 14:28:08 +0200 Subject: [PATCH 31/31] Always trigger the deprecation when native lazy objects are not configured --- lib/Doctrine/ODM/MongoDB/Configuration.php | 8 ++-- .../ODM/MongoDB/Tests/ConfigurationTest.php | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index d4241161a..14f0b5d63 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -717,10 +717,6 @@ public function isLazyGhostObjectEnabled(): bool public function setUseNativeLazyObject(bool $nativeLazyObject): void { - if (PHP_VERSION_ID >= 80400 && ! $nativeLazyObject) { - trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Disabling native lazy objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); - } - if (PHP_VERSION_ID < 80400 && $nativeLazyObject) { throw new LogicException('Native lazy objects require PHP 8.4 or higher.'); } @@ -731,6 +727,10 @@ public function setUseNativeLazyObject(bool $nativeLazyObject): void public function isNativeLazyObjectEnabled(): bool { + if (PHP_VERSION_ID >= 80400 && ! $this->nativeLazyObject) { + trigger_deprecation('doctrine/mongodb-odm', '2.14', 'Not using native lazy objects is deprecated and will be impossible in Doctrine MongoDB ODM 3.0.'); + } + return $this->nativeLazyObject; } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php index 643249415..2bf323cbb 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/ConfigurationTest.php @@ -8,7 +8,10 @@ use Doctrine\ODM\MongoDB\ConfigurationException; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionFactory; use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionGenerator; +use LogicException; use MongoDB\Driver\Manager; +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use stdClass; @@ -17,6 +20,49 @@ class ConfigurationTest extends TestCase { + #[RequiresPhp('< 8.4')] + public function testUseNativeLazyObjectBeforePHP84(): void + { + $c = new Configuration(); + + self::expectException(LogicException::class); + self::expectExceptionMessage('Native lazy objects require PHP 8.4 or higher.'); + + $c->setUseNativeLazyObject(true); + } + + public function testUseLazyGhostObject(): void + { + $c = new Configuration(); + + self::assertFalse($c->isLazyGhostObjectEnabled()); + $c->setUseLazyGhostObject(true); + self::assertTrue($c->isLazyGhostObjectEnabled()); + $c->setUseLazyGhostObject(false); + self::assertFalse($c->isLazyGhostObjectEnabled()); + } + + public function testNativeLazyObjectDeprecatedByDefault(): void + { + $c = new Configuration(); + + self::assertFalse($c->isNativeLazyObjectEnabled()); + } + + #[RequiresPhp('>= 8.4')] + #[TestWith([true])] + #[TestWith([false])] + public function testConflictingLazyObjectSettings(bool $flag): void + { + $c = new Configuration(); + $c->setUseNativeLazyObject(true); + + self::expectException(LogicException::class); + self::expectExceptionMessage('Cannot enable or disable LazyGhostObject when native lazy objects are enabled.'); + + $c->setUseLazyGhostObject($flag); + } + public function testDefaultPersistentCollectionFactory(): void { $c = new Configuration();