diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 16407bfd99..a3e3360a97 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/composer.json b/composer.json index 0f9d2fd142..84c07eb1c0 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/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 6934bd60fe..145aa0dbb7 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; @@ -688,24 +691,47 @@ public function isTransactionalFlushEnabled(): bool */ public function setUseLazyGhostObject(bool $flag): void { + if ($this->nativeLazyObjects) { + throw new LogicException('Cannot enable or disable LazyGhostObject when native lazy objects are enabled.'); + } + if ($flag === false) { 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.14', '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 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.'); + } + + $this->nativeLazyObjects = $nativeLazyObject; + $this->lazyGhostObject = ! $nativeLazyObject || $this->lazyGhostObject; + } + + public function isNativeLazyObjectEnabled(): bool + { + return $this->nativeLazyObjects; } /** diff --git a/lib/Doctrine/ODM/MongoDB/DocumentManager.php b/lib/Doctrine/ODM/MongoDB/DocumentManager.php index b9f440aefb..7ed9e3e73b 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->isNativeLazyObjectEnabled() => new NativeLazyObjectFactory($this), + $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,9 +626,8 @@ public function getReference(string $documentName, $identifier): object return $document; } - /** @var T&GhostObjectInterface $document */ $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/Hydrator/HydratorFactory.php b/lib/Doctrine/ODM/MongoDB/Hydrator/HydratorFactory.php index e9542f5b46..5a821677fb 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 @@ -187,7 +188,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 +211,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 +240,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 +257,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 +281,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 +308,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 +346,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; } @@ -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); diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 3088a770e4..8b351816b9 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; @@ -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. @@ -638,10 +642,15 @@ /** * The ReflectionProperty instances of the mapped class. * - * @var ReflectionProperty[] + * @deprecated Since 2.13, use $propertyAccessors instead. + * + * @var LegacyReflectionFields|array */ public $reflFields = []; + /** @var array */ + public array $propertyAccessors = []; + /** * READ-ONLY: The inheritance mapping type used by the class. * @@ -870,6 +879,7 @@ public function __construct(string $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(); } @@ -1498,21 +1508,40 @@ public function isChangeTrackingNotify(): bool /** * Gets the ReflectionProperties of the mapped class. * - * @return ReflectionProperty[] + * @deprecated Since 2.13, use getPropertyAccessors() instead. + * + * @return array|LegacyReflectionFields */ - 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. + * + * @deprecated Since 2.13, use getPropertyAccessor() instead. */ 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 +1944,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 +1954,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); } /** @@ -1963,9 +1992,11 @@ 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)) { + $this->reflClass->initializeLazyObject($document); } - $this->reflFields[$field]->setValue($document, $value); + $this->propertyAccessors[$field]->setValue($document, $value); } /** @@ -1979,9 +2010,11 @@ public function getFieldValue(object $document, string $field) $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->reflFields[$field]->getValue($document); + return $this->propertyAccessors[$field]->getValue($document); } /** @@ -2567,8 +2600,11 @@ 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 (PHP_VERSION_ID >= 80400 && $accessor->getUnderlyingReflector()->isVirtual()) { + throw MappingException::mappingVirtualPropertyNotAllowed($this->name, $mapping['fieldName']); + } if (isset($mapping['enumType'])) { if (! enum_exists($mapping['enumType'])) { @@ -2580,10 +2616,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; } @@ -2595,9 +2631,10 @@ 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) * * @return array The names of all the fields that should be serialized. */ @@ -2699,24 +2736,24 @@ public function __sleep() } /** - * Restores some state that can not be serialized/unserialized. + * Restores some state that cannot be serialized/unserialized. */ - public function __wakeup() + public function __wakeup(): void { // Restore ReflectionClass and properties - $this->reflectionService = new RuntimeReflectionService(); $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) { - $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 87b073d753..6f02bde635 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadataFactory.php @@ -31,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 @@ -117,6 +119,17 @@ protected function getDriver(): MappingDriver protected function wakeupReflection(ClassMetadataInterface $class, ReflectionService $reflService): void { + 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 @@ -241,12 +254,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 +361,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 0000000000..bbea05d07f --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/LegacyReflectionFields.php @@ -0,0 +1,136 @@ + + * @template-implements IteratorAggregate + */ +class LegacyReflectionFields implements ArrayAccess, IteratorAggregate, Countable +{ + /** @var array */ + private array $reflFields = []; + + public function __construct(private ClassMetadata $classMetadata, private ReflectionService $reflectionService) + { + } + + /** @param string $offset */ + public function offsetExists($offset): bool // phpcs:ignore + { + 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]); + } + + /** + * @param string $field + * + * @psalm-suppress LessSpecificImplementedReturnType + */ + public function offsetGet($field): mixed // phpcs:ignore + { + if (isset($this->reflFields[$field])) { + return $this->reflFields[$field]; + } + + 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); + } + + $className = $this->classMetadata->fieldMappings[$field]['inherited'] + ?? $this->classMetadata->fieldMappings[$field]['declared'] + ?? $this->classMetadata->associationMappings[$field]['declared'] + ?? $this->classMetadata->name; + + $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'], + ); + } + } + + return $this->reflFields[$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 + { + 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); + + foreach ($keys as $key) { + yield $key => $this->offsetGet($key); + } + } + + public function count(): int + { + 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); + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php b/lib/Doctrine/ODM/MongoDB/Mapping/MappingException.php index abd19c1535..d91b95e5b0 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/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EnumPropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/EnumPropertyAccessor.php new file mode 100644 index 0000000000..eba80a3be7 --- /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/ObjectCastPropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php new file mode 100644 index 0000000000..bdc87c92d5 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/ObjectCastPropertyAccessor.php @@ -0,0 +1,63 @@ +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(); + $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()) { + $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); + } + } + + 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/PropertyAccessor.php b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessor.php new file mode 100644 index 0000000000..da46932dfa --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/PropertyAccessor.php @@ -0,0 +1,27 @@ += 80400 + ? RawValuePropertyAccessor::fromReflectionProperty($reflectionProperty) + : ObjectCastPropertyAccessor::fromReflectionProperty($reflectionProperty); + + if ($reflectionProperty->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 0000000000..54fee78b69 --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Mapping/PropertyAccessors/RawValuePropertyAccessor.php @@ -0,0 +1,67 @@ +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) + { + 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()) { + $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); + } + } + + 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 0000000000..4e255aae17 --- /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 0000000000..027c95644f --- /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; + } +} diff --git a/lib/Doctrine/ODM/MongoDB/Persisters/CollectionPersister.php b/lib/Doctrine/ODM/MongoDB/Persisters/CollectionPersister.php index 8b99e0ccbf..e65b500a87 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 1bf370fc02..c74c668fe5 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 beb53ee47f..130269412c 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/Proxy/Factory/NativeLazyObjectFactory.php b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php new file mode 100644 index 0000000000..35552cfb6d --- /dev/null +++ b/lib/Doctrine/ODM/MongoDB/Proxy/Factory/NativeLazyObjectFactory.php @@ -0,0 +1,97 @@ +|null */ + private static ?WeakMap $lazyObjects = null; + + private readonly UnitOfWork $unitOfWork; + private readonly LifecycleEventManager $lifecycleEventManager; + + public function __construct( + 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 + { + // Nothing to generate, that's the point of native lazy objects + + return count($classes); + } + + public function getProxy(ClassMetadata $metadata, $identifier): object + { + $proxy = $metadata->reflClass->newLazyGhost(function (object $object) use ( + $identifier, + $metadata, + ): void { + $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); + + $metadata->propertyAccessors[$metadata->identifier]->setValue($proxy, $identifier); + + if (isset(self::$lazyObjects)) { + self::$lazyObjects[$proxy] = true; + } + + return $proxy; + } + + /** @internal Only for tests */ + public static function enableTracking(bool $enabled = true): void + { + if ($enabled) { + self::$lazyObjects ??= new WeakMap(); + } else { + self::$lazyObjects = null; + } + } + + /** @internal Only for 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 f6b9b28c25..47fb4233ab 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 @@ -616,7 +618,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 +639,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 +865,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 +1276,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 +1940,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) { @@ -1990,7 +1992,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]); } } @@ -2054,12 +2056,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 +2182,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 +2211,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 +2242,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 +2274,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 +2332,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 +2367,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 +2485,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); @@ -2783,12 +2785,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()->isNativeLazyObjectEnabled()) { + $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; @@ -3076,6 +3080,8 @@ public function initializeObject(object $obj): void $obj->initializeProxy(); } elseif ($obj instanceof PersistentCollectionInterface) { $obj->initialize(); + } elseif (PHP_VERSION_ID >= 80400) { + $this->dm->getClassMetadata($obj::class)->reflClass->initializeLazyObject($obj); } } @@ -3087,9 +3093,10 @@ 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()->isNativeLazyObjectEnabled() => $this->dm->getClassMetadata($obj::class)->reflClass->isUninitializedLazyObject($obj), default => false }; } diff --git a/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php b/lib/Doctrine/ODM/MongoDB/Utility/LifecycleEventManager.php index ca7ef6691d..4214c01568 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/phpstan-baseline.neon b/phpstan-baseline.neon index b123bec670..3674e98f4c 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 diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index 665f4ef964..57ec11f971 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; @@ -105,6 +106,11 @@ protected static function getConfiguration(): Configuration $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']); + + if ($config->isNativeLazyObjectEnabled()) { + NativeLazyObjectFactory::enableTracking(); + } $config->addFilter('testFilter', Filter::class); $config->addFilter('testFilter2', Filter::class); @@ -134,6 +140,10 @@ public static function assertArraySubset(array $subset, array $array, bool $chec public static function isLazyObject(object $document): bool { + if ($_ENV['USE_NATIVE_LAZY_OBJECTS']) { + return NativeLazyObjectFactory::isLazyObject($document); + } + 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 new file mode 100644 index 0000000000..2ffeb1ee74 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/PropertyHooksTest.php @@ -0,0 +1,100 @@ += 8.4.0')] +class PropertyHooksTest extends BaseTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + if ($this->dm->getConfiguration()->isNativeLazyObjectEnabled()) { + 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 + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage('Mapping virtual property "fullName" on document "Documents84\PropertyHooks\MappingVirtualProperty" is not allowed.'); + + $this->dm->getClassMetadata(MappingVirtualProperty::class); + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencesTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/ReferencesTest.php index 88cc979f03..88cfad35ff 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 diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH852Test.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/Ticket/GH852Test.php index 3895f43250..14e4545154 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')] diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractAnnotationDriverTestCase.php index be73863dcc..d30aeeb960 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 { @@ -118,6 +119,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/AbstractMappingDriverTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/AbstractMappingDriverTestCase.php index c4a8be3546..ab7d1c1a1e 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 832a208feb..2cabb36484 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 5c8a780206..ceda14e4b6 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 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 0000000000..15afe56002 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/LegacyReflectionFieldsTest.php @@ -0,0 +1,54 @@ +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)); + + // ArrayAccess and Countable interfaces + self::assertCount(32, $class->reflFields); + self::assertArrayHasKey('username', $class->reflFields); + self::assertArrayNotHasKey('nonExistentField', $class->reflFields); + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/EnumPropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/EnumPropertyAccessorTest.php new file mode 100644 index 0000000000..1353ac57d6 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/EnumPropertyAccessorTest.php @@ -0,0 +1,66 @@ +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; + + /** @var EnumType[] */ + 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 0000000000..62563b76a7 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ObjectCastPropertyAccessorTest.php @@ -0,0 +1,85 @@ +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; + } +} + +/** @implements InternalProxy */ +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/RawValuePropertyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/RawValuePropertyAccessorTest.php new file mode 100644 index 0000000000..bf116c16ef --- /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/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php new file mode 100644 index 0000000000..2bdbd681b6 --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Mapping/PropertyAccessors/ReadOnlyAccessorTest.php @@ -0,0 +1,44 @@ +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 +{ + // @phpstan-ignore property.uninitializedReadonly + 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 0000000000..8138c7be8e --- /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/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Proxy/Factory/ProxyFactoryTest.php index 7941ff50bb..a25575f1bb 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()->isNativeLazyObjectEnabled()) { + $this->dm->getClassMetadata($proxy::class)->getReflectionClass()->markLazyObjectAsInitialized($proxy); } self::assertSame('bar', $proxy->foo); diff --git a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTest.php index 55d61fedba..5f7209e4cd 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/BaseDocument.php b/tests/Documents/BaseDocument.php index b107762f35..0f9d335bf8 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 { diff --git a/tests/Documents/ForumAvatar.php b/tests/Documents/ForumAvatar.php index 1dc18f24f2..c0187a6c6b 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 0000000000..df3fb2ad43 --- /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; + } } diff --git a/tests/Documents84/PropertyHooks/MappingVirtualProperty.php b/tests/Documents84/PropertyHooks/MappingVirtualProperty.php new file mode 100644 index 0000000000..7a7a2b6dbb --- /dev/null +++ b/tests/Documents84/PropertyHooks/MappingVirtualProperty.php @@ -0,0 +1,28 @@ + $this->first . " " . $this->last; + set { + [$this->first, $this->last] = explode(' ', $value, 2); + } + } +} diff --git a/tests/Documents84/PropertyHooks/User.php b/tests/Documents84/PropertyHooks/User.php new file mode 100644 index 0000000000..cc251be3c9 --- /dev/null +++ b/tests/Documents84/PropertyHooks/User.php @@ -0,0 +1,53 @@ +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