From bd11fa393a966de604dec7fdef20b0d1abc684b0 Mon Sep 17 00:00:00 2001 From: Andrey Shelamkoff Date: Mon, 23 Feb 2026 19:14:45 +0200 Subject: [PATCH 1/8] Update class property visibility handling Enhance visibility check for properties to account for restricted setters. --- .../Hydrator/ClassPropertiesExtractor.php | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Mapper/Proxy/Hydrator/ClassPropertiesExtractor.php b/src/Mapper/Proxy/Hydrator/ClassPropertiesExtractor.php index 9ecf74bf..74846cfb 100644 --- a/src/Mapper/Proxy/Hydrator/ClassPropertiesExtractor.php +++ b/src/Mapper/Proxy/Hydrator/ClassPropertiesExtractor.php @@ -32,7 +32,10 @@ public function extract(string|object $objectOrClass, array $relations): array $className = $property->getDeclaringClass()->getName(); $propertyName = $property->getName(); - $class = $property->isPublic() ? PropertyMap::PUBLIC_CLASS : $className; + $class = $property->isPublic() && !$this->hasRestrictedSet($property) + ? PropertyMap::PUBLIC_CLASS + : $className; + if (\in_array($propertyName, $relations, true)) { $relationProperties[$class][$propertyName] = $propertyName; } else { @@ -46,6 +49,23 @@ public function extract(string|object $objectOrClass, array $relations): array ]; } + /** + * Check if a property has restricted (private or protected) set visibility. + * + * PHP 8.4 asymmetric visibility: `private(set)` properties return true for + * isPublic() (read visibility) but cannot be assigned from outside the class. + * ClosureHydrator skips PUBLIC_CLASS properties and the fallback `@$entity->$prop = $value` + * silently fails because set visibility is private. + */ + private function hasRestrictedSet(\ReflectionProperty $property): bool + { + if (\PHP_VERSION_ID < 80400) { + return false; + } + + return $property->isPrivateSet() || $property->isProtectedSet(); + } + /** * Find all class properties recursively using class hierarchy without * removing name redefinitions From fed7449bc604344c30ca104971abf72edaaa0955 Mon Sep 17 00:00:00 2001 From: Andrey Shelamkoff Date: Mon, 23 Feb 2026 19:15:56 +0200 Subject: [PATCH 2/8] Add LazyGhostEntityFactory for lazy object handling Implement LazyGhostEntityFactory for PHP 8.4 compatibility. --- .../LazyGhost/LazyGhostEntityFactory.php | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 src/Mapper/LazyGhost/LazyGhostEntityFactory.php diff --git a/src/Mapper/LazyGhost/LazyGhostEntityFactory.php b/src/Mapper/LazyGhost/LazyGhostEntityFactory.php new file mode 100644 index 00000000..bd0bd882 --- /dev/null +++ b/src/Mapper/LazyGhost/LazyGhostEntityFactory.php @@ -0,0 +1,313 @@ +> + */ + protected \WeakMap $pendingRefs; + + /** @var array */ + protected array $reflectionCache = []; + + /** + * Cached property lookups: false means "no usable property". + * + * @var array> + */ + protected array $propertyCache = []; + + /** + * Cached list of extractable (non-static, non-virtual) properties per class. + * + * @var array> + */ + protected array $extractableProperties = []; + + public function __construct() + { + $this->pendingRefs = new \WeakMap(); + } + + /** + * Create an empty entity instance (without calling its constructor). + * + * The returned ghost object resolves pending relation references + * on first access to an uninitialized property. + */ + public function create(RelationMap $relMap, string $sourceClass): object + { + $reflection = $this->getReflection($sourceClass); + $ghost = $reflection->newLazyGhost($this->createInitializer($sourceClass)); + $this->pendingRefs[$ghost] = []; + + return $ghost; + } + + /** + * Hydrate an entity with column values and relation data. + * + * Two-phase approach is critical for BelongsTo relations: + * Cycle ORM passes both inner key (e.g. `role_id`) and the relation + * (e.g. `role` as ReferenceInterface) in $data. If scalar properties + * are set first, a missing ReflectionProperty for `role_id` triggers + * the fallback path (`@$entity->$prop = $value`), which accesses the + * ghost and fires the initializer BEFORE the relation ref is registered + * in pendingRefs — leaving the relation property uninitialized forever. + * + * Phase 1: Register ALL relation references in pendingRefs. + * Phase 2: Set column/scalar properties (safe to trigger initializer now). + * + * On already-initialized entities (re-hydration), relation references + * are resolved immediately via ReflectionProperty::setValue(). + * + * @return object Hydrated entity + */ + public function upgrade(RelationMap $relMap, object $entity, array $data): object + { + $reflection = $this->getReflection($entity::class); + $relations = $relMap->getRelations(); + $hasPendingRefs = false; + $isLazyUninitialized = $reflection->isUninitializedLazyObject($entity); + + // Phase 1: Register pending relation references BEFORE touching any properties. + // This ensures the ghost initializer has all refs available if triggered + // by a fallback property write (e.g. role_id triggering ghost init). + foreach ($data as $property => $value) { + $relation = $relations[$property] ?? null; + + if ($relation === null || !$value instanceof ReferenceInterface) { + continue; + } + + if ($isLazyUninitialized) { + $pending = $this->pendingRefs[$entity] ?? []; + $pending[$property] = [ + 'ref' => $value, + 'relation' => $relation, + ]; + $this->pendingRefs[$entity] = $pending; + $hasPendingRefs = true; + } else { + // Re-hydration on already initialized entity — resolve immediately + $resolved = $relation->collect($relation->resolve($value, true)); + $prop = $this->getProperty($reflection, $property); + + if ($prop !== null && !($prop->isReadOnly() && $prop->isInitialized($entity))) { + $prop->setValue($entity, $resolved); + } + } + } + + // Phase 2: Set column/scalar properties + foreach ($data as $property => $value) { + if (isset($relations[$property])) { + continue; + } + + $prop = $this->getProperty($reflection, $property); + + if ($prop !== null) { + if ($prop->isReadOnly() && $prop->isInitialized($entity)) { + continue; + } + + $prop->setRawValueWithoutLazyInitialization($entity, $value); + } else { + // Dynamic property or virtual — fallback (matches ClosureHydrator behavior) + try { + @$entity->{$property} = $value; + } catch (\Throwable) { + } + } + } + + // If ghost is still uninitialized and no pending refs, mark as initialized + if ($isLazyUninitialized && !$hasPendingRefs) { + $reflection->markLazyObjectAsInitialized($entity); + } + + return $entity; + } + + /** + * Extract all non-relation property values. + * + * @return array + */ + public function extractData(RelationMap $relMap, object $entity): array + { + $relations = $relMap->getRelations(); + $result = []; + + foreach ($this->getExtractableProperties($entity::class) as $prop) { + $name = $prop->getName(); + + if (isset($relations[$name])) { + continue; + } + + if (!$prop->isInitialized($entity)) { + continue; + } + + $result[$name] = $prop->getValue($entity); + } + + return $result; + } + + /** + * Extract relation values. + * + * For pending (unresolved) references, returns the ReferenceInterface + * without triggering resolution (preserves laziness for ORM internals). + * + * @return array + */ + public function extractRelations(RelationMap $relMap, object $entity): array + { + $result = []; + $pending = $this->pendingRefs[$entity] ?? []; + $reflection = $this->getReflection($entity::class); + + foreach (\array_keys($relMap->getRelations()) as $name) { + if (isset($pending[$name])) { + $result[$name] = $pending[$name]['ref']; + continue; + } + + $prop = $this->getProperty($reflection, $name); + + if ($prop !== null && $prop->isInitialized($entity)) { + $result[$name] = $prop->getValue($entity); + } + } + + return $result; + } + + /** + * @return array + */ + public function extractAll(RelationMap $relMap, object $entity): array + { + return $this->extractData($relMap, $entity) + $this->extractRelations($relMap, $entity); + } + + /** + * Create the ghost initializer closure for a given class. + * + * When any uninitialized property is accessed, the initializer: + * 1. Resolves ALL pending relation references for the entity + * 2. Sets resolved values via setRawValueWithoutLazyInitialization() + * 3. Clears pending refs (WeakMap entry) + */ + protected function createInitializer(string $class): \Closure + { + return function (object $entity) use ($class): void { + $refs = $this->pendingRefs[$entity] ?? []; + $reflection = $this->getReflection($class); + + foreach ($refs as $name => $info) { + $resolved = $info['relation']->collect( + $info['relation']->resolve($info['ref'], true), + ); + + $prop = $this->getProperty($reflection, $name); + $prop?->setRawValueWithoutLazyInitialization($entity, $resolved); + } + + unset($this->pendingRefs[$entity]); + }; + } + + protected function getReflection(string $class): \ReflectionClass + { + return $this->reflectionCache[$class] ??= new \ReflectionClass($class); + } + + /** + * Get a usable ReflectionProperty for hydration/extraction. + * + * Returns null for non-existent, static, or virtual (hook-only) properties. + * Results are cached per class+property name. + */ + protected function getProperty(\ReflectionClass $reflection, string $name): ?\ReflectionProperty + { + $className = $reflection->getName(); + + if (!\array_key_exists($name, $this->propertyCache[$className] ?? [])) { + $this->propertyCache[$className][$name] = $this->resolveProperty($reflection, $name); + } + + $cached = $this->propertyCache[$className][$name]; + + return $cached === false ? null : $cached; + } + + /** + * Get cached list of extractable properties (non-static, non-virtual). + * + * @return list<\ReflectionProperty> + */ + protected function getExtractableProperties(string $class): array + { + if (!isset($this->extractableProperties[$class])) { + $reflection = $this->getReflection($class); + $properties = []; + + foreach ($reflection->getProperties() as $prop) { + if ($prop->isStatic() || $prop->isVirtual()) { + continue; + } + + $properties[] = $prop; + } + + $this->extractableProperties[$class] = $properties; + } + + return $this->extractableProperties[$class]; + } + + protected function resolveProperty(\ReflectionClass $reflection, string $name): \ReflectionProperty|false + { + if (!$reflection->hasProperty($name)) { + return false; + } + + $prop = $reflection->getProperty($name); + + if ($prop->isStatic() || $prop->isVirtual()) { + return false; + } + + return $prop; + } +} From bce03d120d737e02b890f914823773bc3f2dff3c Mon Sep 17 00:00:00 2001 From: Andrey Shelamkoff Date: Mon, 23 Feb 2026 19:16:51 +0200 Subject: [PATCH 3/8] Implement LazyGhostMapper for lazy ghost entities This class serves as a drop-in replacement for the Mapper, utilizing PHP 8.4 lazy ghost objects for entity creation and hydration. --- src/Mapper/LazyGhostMapper.php | 78 ++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/Mapper/LazyGhostMapper.php diff --git a/src/Mapper/LazyGhostMapper.php b/src/Mapper/LazyGhostMapper.php new file mode 100644 index 00000000..b18a8a23 --- /dev/null +++ b/src/Mapper/LazyGhostMapper.php @@ -0,0 +1,78 @@ +schema = $orm->getSchema(); + $this->entity = $this->schema->define($role, SchemaInterface::ENTITY); + $this->children = $this->schema->define($role, SchemaInterface::CHILDREN) ?? []; + $this->discriminator = $this->schema->define($role, SchemaInterface::DISCRIMINATOR) + ?? $this->discriminator; + } + + public function init(array $data, ?string $role = null): object + { + $class = $this->resolveClass($data, $role); + + return $this->entityFactory->create($this->relationMap, $class); + } + + public function hydrate(object $entity, array $data): object + { + $this->entityFactory->upgrade($this->relationMap, $entity, $data); + + return $entity; + } + + public function extract(object $entity): array + { + return $this->entityFactory->extractData($this->relationMap, $entity) + + $this->entityFactory->extractRelations($this->relationMap, $entity); + } + + public function fetchFields(object $entity): array + { + $values = \array_intersect_key( + $this->entityFactory->extractData($this->relationMap, $entity), + $this->columns + $this->parentColumns, + ); + + return $values + $this->getDiscriminatorValues($entity); + } + + public function fetchRelations(object $entity): array + { + return $this->entityFactory->extractRelations($this->relationMap, $entity); + } +} From ee7636cef718b21c988fec66f58bab92603f95eb Mon Sep 17 00:00:00 2001 From: Andrey Shelamkoff Date: Mon, 23 Feb 2026 19:18:11 +0200 Subject: [PATCH 4/8] Add files via upload --- .../ORM/Fixtures/AsymmetricVisibilityEntity.php | 14 ++++++++++++++ tests/ORM/Fixtures/FinalEntity.php | 16 ++++++++++++++++ tests/ORM/Fixtures/SimpleEntity.php | 14 ++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 tests/ORM/Fixtures/AsymmetricVisibilityEntity.php create mode 100644 tests/ORM/Fixtures/FinalEntity.php create mode 100644 tests/ORM/Fixtures/SimpleEntity.php diff --git a/tests/ORM/Fixtures/AsymmetricVisibilityEntity.php b/tests/ORM/Fixtures/AsymmetricVisibilityEntity.php new file mode 100644 index 00000000..7a44eb58 --- /dev/null +++ b/tests/ORM/Fixtures/AsymmetricVisibilityEntity.php @@ -0,0 +1,14 @@ + Date: Mon, 23 Feb 2026 19:20:06 +0200 Subject: [PATCH 5/8] Add LazyGhostEntityFactoryTest for lazy ghost functionality --- .../LazyGhost/LazyGhostEntityFactoryTest.php | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 tests/ORM/Unit/Mapper/Proxy/LazyGhost/LazyGhostEntityFactoryTest.php diff --git a/tests/ORM/Unit/Mapper/Proxy/LazyGhost/LazyGhostEntityFactoryTest.php b/tests/ORM/Unit/Mapper/Proxy/LazyGhost/LazyGhostEntityFactoryTest.php new file mode 100644 index 00000000..e3f1d600 --- /dev/null +++ b/tests/ORM/Unit/Mapper/Proxy/LazyGhost/LazyGhostEntityFactoryTest.php @@ -0,0 +1,324 @@ +factory = new LazyGhostEntityFactory(); + } + + public function testCreateReturnsInstanceOfRequestedClass(): void + { + $entity = $this->factory->create($this->emptyRelationMap(), SimpleEntity::class); + + $this->assertInstanceOf(SimpleEntity::class, $entity); + } + + public function testCreateWorksWithFinalClass(): void + { + $entity = $this->factory->create($this->emptyRelationMap(), FinalEntity::class); + + $this->assertInstanceOf(FinalEntity::class, $entity); + } + + public function testCreateReturnsUninitializedLazyGhost(): void + { + $entity = $this->factory->create($this->emptyRelationMap(), SimpleEntity::class); + + $reflection = new \ReflectionClass($entity); + + $this->assertTrue($reflection->isUninitializedLazyObject($entity)); + } + + public function testCreateDoesNotCallConstructor(): void + { + $entity = $this->factory->create($this->emptyRelationMap(), FinalEntity::class); + + $reflection = new \ReflectionClass($entity); + + // uuid is set in constructor with default ''; lazy ghost should skip it + $this->assertTrue($reflection->isUninitializedLazyObject($entity)); + $this->assertFalse($reflection->getProperty('uuid')->isInitialized($entity)); + } + + public function testUpgradeHydratesScalarProperties(): void + { + $relMap = $this->emptyRelationMap(); + $entity = $this->factory->create($relMap, SimpleEntity::class); + + $entity = $this->factory->upgrade($relMap, $entity, [ + 'id' => 42, + 'name' => 'Test', + 'description' => 'A description', + 'sortOrder' => 5, + ]); + + $this->assertSame(42, $entity->id); + $this->assertSame('Test', $entity->name); + $this->assertSame('A description', $entity->description); + $this->assertSame(5, $entity->sortOrder); + } + + public function testUpgradeHydratesFinalEntityWithPrivateSetProperties(): void + { + $relMap = $this->emptyRelationMap(); + $entity = $this->factory->create($relMap, FinalEntity::class); + + $this->factory->upgrade($relMap, $entity, [ + 'id' => 7, + 'title' => 'Hello', + 'slug' => 'hello-world', + ]); + + $this->assertSame(7, $entity->id); + $this->assertSame('Hello', $entity->title); + $this->assertSame('hello-world', $entity->slug); + } + + public function testUpgradeSkipsInitializedReadonlyProperty(): void + { + $relMap = $this->emptyRelationMap(); + $entity = $this->factory->create($relMap, FinalEntity::class); + + // First hydration sets readonly uuid + $this->factory->upgrade($relMap, $entity, ['uuid' => 'first']); + + // Second hydration must not overwrite it + $this->factory->upgrade($relMap, $entity, ['uuid' => 'second']); + + $this->assertSame('first', $entity->uuid); + } + + public function testUpgradeStoresPendingRelationReference(): void + { + $ref = $this->createMock(ReferenceInterface::class); + $relation = $this->createMock(ActiveRelationInterface::class); + + $relMap = $this->buildRelationMap(['role' => $relation]); + $entity = $this->factory->create($relMap, SimpleEntity::class); + + // Upgrade with a ReferenceInterface value for a known relation + $this->factory->upgrade($relMap, $entity, [ + 'name' => 'Test', + 'role' => $ref, + ]); + + // Entity is still lazy (has pending refs) + $reflection = new \ReflectionClass($entity); + $this->assertTrue($reflection->isUninitializedLazyObject($entity)); + } + + public function testUpgradeMarksAsInitializedWhenNoPendingRefs(): void + { + $relMap = $this->emptyRelationMap(); + $entity = $this->factory->create($relMap, SimpleEntity::class); + + $this->factory->upgrade($relMap, $entity, ['name' => 'Test']); + + $reflection = new \ReflectionClass($entity); + $this->assertFalse($reflection->isUninitializedLazyObject($entity)); + } + + public function testExtractDataReturnsScalarValues(): void + { + $relMap = $this->emptyRelationMap(); + $entity = $this->factory->create($relMap, SimpleEntity::class); + + $this->factory->upgrade($relMap, $entity, [ + 'id' => 1, + 'name' => 'Alice', + 'description' => null, + 'sortOrder' => 3, + ]); + + $data = $this->factory->extractData($relMap, $entity); + + $this->assertSame(1, $data['id']); + $this->assertSame('Alice', $data['name']); + $this->assertNull($data['description']); + $this->assertSame(3, $data['sortOrder']); + } + + public function testExtractDataSkipsUninitializedProperties(): void + { + $relMap = $this->emptyRelationMap(); + $entity = $this->factory->create($relMap, FinalEntity::class); + + // Only hydrate id and title, leave uuid and slug uninitialized + $this->factory->upgrade($relMap, $entity, [ + 'id' => 10, + 'title' => 'Partial', + ]); + + $data = $this->factory->extractData($relMap, $entity); + + $this->assertSame(10, $data['id']); + $this->assertSame('Partial', $data['title']); + $this->assertArrayNotHasKey('uuid', $data); + $this->assertArrayNotHasKey('slug', $data); + } + + public function testExtractDataExcludesRelationProperties(): void + { + $relation = $this->createMock(ActiveRelationInterface::class); + $relMap = $this->buildRelationMap(['description' => $relation]); + + $entity = new SimpleEntity(); + $entity->id = 1; + $entity->name = 'Bob'; + $entity->description = 'should be excluded'; + + $data = $this->factory->extractData($relMap, $entity); + + $this->assertArrayHasKey('name', $data); + $this->assertArrayNotHasKey('description', $data); + } + + public function testExtractRelationsReturnsPendingReference(): void + { + $ref = $this->createMock(ReferenceInterface::class); + $relation = $this->createMock(ActiveRelationInterface::class); + + $relMap = $this->buildRelationMap(['role' => $relation]); + $entity = $this->factory->create($relMap, SimpleEntity::class); + + $this->factory->upgrade($relMap, $entity, ['role' => $ref]); + + $relations = $this->factory->extractRelations($relMap, $entity); + + $this->assertSame($ref, $relations['role']); + } + + public function testExtractRelationsReturnsResolvedValue(): void + { + $relation = $this->createMock(ActiveRelationInterface::class); + $relMap = $this->buildRelationMap(['description' => $relation]); + + $entity = new SimpleEntity(); + $entity->description = 'resolved value'; + + $relations = $this->factory->extractRelations($relMap, $entity); + + $this->assertSame('resolved value', $relations['description']); + } + + public function testExtractAllCombinesDataAndRelations(): void + { + $relation = $this->createMock(ActiveRelationInterface::class); + $relMap = $this->buildRelationMap(['description' => $relation]); + + // Use a regular (non-lazy) entity to test the merge of data + relations + $entity = new SimpleEntity(); + $entity->id = 5; + $entity->name = 'Combined'; + $entity->description = 'relation-value'; + + $all = $this->factory->extractAll($relMap, $entity); + + $this->assertSame(5, $all['id']); + $this->assertSame('Combined', $all['name']); + // description is in the relation map → comes from extractRelations, not extractData + $this->assertSame('relation-value', $all['description']); + // sortOrder is scalar data + $this->assertSame(0, $all['sortOrder']); + } + + public function testPropertyAccessTriggersResolutionOfPendingRefs(): void + { + $resolvedParent = new \stdClass(); + $resolvedParent->name = 'admin'; + + $ref = $this->createMock(ReferenceInterface::class); + $relation = $this->createMock(ActiveRelationInterface::class); + $relation->method('resolve')->with($ref, true)->willReturn($resolvedParent); + $relation->method('collect')->with($resolvedParent)->willReturn($resolvedParent); + + // Use 'parent' (?object) as a pseudo-relation on SimpleEntity + $relMap = $this->buildRelationMap(['parent' => $relation]); + $entity = $this->factory->create($relMap, SimpleEntity::class); + + // Only pass the relation ref — no scalar data + $this->factory->upgrade($relMap, $entity, [ + 'parent' => $ref, + ]); + + $reflection = new \ReflectionClass($entity); + $this->assertTrue($reflection->isUninitializedLazyObject($entity)); + + // Access a property NOT set via setRawValueWithoutLazyInitialization + // to trigger the lazy ghost initializer + $sort = $entity->sortOrder; + + $this->assertFalse($reflection->isUninitializedLazyObject($entity)); + $this->assertSame(0, $sort); + $this->assertSame($resolvedParent, $entity->parent); + } + + private function emptyRelationMap(): RelationMap + { + return $this->buildRelationMap(); + } + + /** + * @param array $relations + */ + private function buildRelationMap(array $relations = []): RelationMap + { + $schemaRelations = []; + foreach (\array_keys($relations) as $name) { + $schemaRelations[$name] = [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => 'entity', + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::INNER_KEY => $name . '_id', + Relation::OUTER_KEY => 'id', + ], + ]; + } + + $ormFactory = $this->createMock(FactoryInterface::class); + $ormFactory->method('relation') + ->willReturnCallback(static fn($orm, $schema, $role, $name) => $relations[$name]); + + $orm = new ORM( + $ormFactory, + new Schema([ + 'entity' => [ + SchemaInterface::ENTITY => SimpleEntity::class, + SchemaInterface::MAPPER => Mapper::class, + SchemaInterface::DATABASE => 'default', + SchemaInterface::TABLE => 'entity', + SchemaInterface::PRIMARY_KEY => 'id', + SchemaInterface::COLUMNS => ['id'], + SchemaInterface::SCHEMA => [], + SchemaInterface::RELATIONS => $schemaRelations, + ], + ]), + ); + + return RelationMap::build($orm, 'entity'); + } +} From d8b06d49b4972dae67a903eb74db4f3aba6852d2 Mon Sep 17 00:00:00 2001 From: Andrey Shelamkoff Date: Mon, 23 Feb 2026 19:21:36 +0200 Subject: [PATCH 6/8] Add unit tests for ClassPropertiesExtractor --- .../Hydrator/ClassPropertiesExtractorTest.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/ORM/Unit/Mapper/Proxy/Hydrator/ClassPropertiesExtractorTest.php diff --git a/tests/ORM/Unit/Mapper/Proxy/Hydrator/ClassPropertiesExtractorTest.php b/tests/ORM/Unit/Mapper/Proxy/Hydrator/ClassPropertiesExtractorTest.php new file mode 100644 index 00000000..56a2288f --- /dev/null +++ b/tests/ORM/Unit/Mapper/Proxy/Hydrator/ClassPropertiesExtractorTest.php @@ -0,0 +1,89 @@ +extractor = new ClassPropertiesExtractor(); + } + + public function testPublicPropertyClassifiedAsPublic(): void + { + $result = $this->extractor->extract(AsymmetricVisibilityEntity::class, []); + $fieldMap = $result[ClassPropertiesExtractor::KEY_FIELDS]; + + $this->assertTrue($fieldMap->isPublicProperty('id')); + $this->assertTrue($fieldMap->isPublicProperty('name')); + } + + public function testPrivateSetPropertyClassifiedAsNonPublic(): void + { + $result = $this->extractor->extract(AsymmetricVisibilityEntity::class, []); + $fieldMap = $result[ClassPropertiesExtractor::KEY_FIELDS]; + + // private(set) has public read but private write — must NOT be PUBLIC_CLASS + $this->assertFalse($fieldMap->isPublicProperty('login')); + $this->assertSame( + AsymmetricVisibilityEntity::class, + $fieldMap->getPropertyClass('login'), + ); + } + + public function testProtectedSetPropertyClassifiedAsNonPublic(): void + { + $result = $this->extractor->extract(AsymmetricVisibilityEntity::class, []); + $fieldMap = $result[ClassPropertiesExtractor::KEY_FIELDS]; + + // protected(set) has public read but protected write — must NOT be PUBLIC_CLASS + $this->assertFalse($fieldMap->isPublicProperty('email')); + $this->assertSame( + AsymmetricVisibilityEntity::class, + $fieldMap->getPropertyClass('email'), + ); + } + + public function testPrivatePropertyClassifiedAsNonPublic(): void + { + $result = $this->extractor->extract(AsymmetricVisibilityEntity::class, []); + $fieldMap = $result[ClassPropertiesExtractor::KEY_FIELDS]; + + $this->assertFalse($fieldMap->isPublicProperty('password')); + $this->assertSame( + AsymmetricVisibilityEntity::class, + $fieldMap->getPropertyClass('password'), + ); + } + + public function testRelationPropertiesRespectAsymmetricVisibility(): void + { + $result = $this->extractor->extract( + AsymmetricVisibilityEntity::class, + ['login'], + ); + + $fieldMap = $result[ClassPropertiesExtractor::KEY_FIELDS]; + $relationMap = $result[ClassPropertiesExtractor::KEY_RELATIONS]; + + // login is a relation — not in fields + $this->assertNull($fieldMap->getPropertyClass('login')); + + // login (private(set)) in relations — must be class-scoped, not PUBLIC_CLASS + $this->assertFalse($relationMap->isPublicProperty('login')); + $this->assertSame( + AsymmetricVisibilityEntity::class, + $relationMap->getPropertyClass('login'), + ); + } +} From 341f694b622a8257dd69a10dcda5120034197cb0 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 23 Feb 2026 22:35:37 +0400 Subject: [PATCH 7/8] Try to fix the main pipeline --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3312ad3c..2320a3b7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,6 +28,8 @@ jobs: - name: Install ODBC driver. run: | sudo curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt-get clean + sudo apt-get update sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 - name: 📦 Checkout From e2e8a0ea7adceaefd2b246cbc16b292e826a59a4 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 23 Feb 2026 22:51:52 +0400 Subject: [PATCH 8/8] Fix deprecations in tests --- tests/ORM/Functional/Driver/Common/BaseTest.php | 4 +--- .../ProxyEntityMapper/EntityWithRelationCreationTest.php | 2 +- tests/ORM/Unit/Collection/LoophpCollectionFactoryTest.php | 2 +- tests/ORM/Unit/Command/InsertCommandTest.php | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/ORM/Functional/Driver/Common/BaseTest.php b/tests/ORM/Functional/Driver/Common/BaseTest.php index 2b93116f..ce3e3422 100644 --- a/tests/ORM/Functional/Driver/Common/BaseTest.php +++ b/tests/ORM/Functional/Driver/Common/BaseTest.php @@ -256,9 +256,7 @@ protected function assertClearState(ORM $orm): void $r = new \ReflectionClass(Node::class); $rel = $r->getProperty('relations'); - if (PHP_VERSION_ID < 80100) { - $rel->setAccessible(true); - } + PHP_VERSION_ID < 80100 and $rel->setAccessible(true); $heap = $orm->getHeap(); foreach ($heap as $entity) { diff --git a/tests/ORM/Functional/Driver/Common/Mapper/ProxyEntityMapper/EntityWithRelationCreationTest.php b/tests/ORM/Functional/Driver/Common/Mapper/ProxyEntityMapper/EntityWithRelationCreationTest.php index bc1c7b30..fbca92d8 100644 --- a/tests/ORM/Functional/Driver/Common/Mapper/ProxyEntityMapper/EntityWithRelationCreationTest.php +++ b/tests/ORM/Functional/Driver/Common/Mapper/ProxyEntityMapper/EntityWithRelationCreationTest.php @@ -22,7 +22,7 @@ public function testProxyEntityRelationPropertiesShouldBeUnsetAfterCreation(): v $refl = new \ReflectionClass(EntityWithRelationCreationAbstractUser::class); $profileProperty = $refl->getProperty('profile'); - $profileProperty->setAccessible(true); + PHP_VERSION_ID < 80100 and $profileProperty->setAccessible(true); $this->assertFalse($profileProperty->isInitialized($emptyObject)); $this->assertEquals(123, $emptyObject->id); diff --git a/tests/ORM/Unit/Collection/LoophpCollectionFactoryTest.php b/tests/ORM/Unit/Collection/LoophpCollectionFactoryTest.php index 360442f9..bd90bdf8 100644 --- a/tests/ORM/Unit/Collection/LoophpCollectionFactoryTest.php +++ b/tests/ORM/Unit/Collection/LoophpCollectionFactoryTest.php @@ -122,7 +122,7 @@ public function testCollectPivotStorageDecoratorIsNotExists(): void { $factory = $this->getFactory(); $ref = new \ReflectionProperty($factory, 'decoratorExists'); - $ref->setAccessible(true); + PHP_VERSION_ID < 80100 and $ref->setAccessible(true); $ref->setValue($factory, false); $collection = $factory->collect(new PivotedStorage($array = [ diff --git a/tests/ORM/Unit/Command/InsertCommandTest.php b/tests/ORM/Unit/Command/InsertCommandTest.php index 144cbfb4..37c9b7d7 100644 --- a/tests/ORM/Unit/Command/InsertCommandTest.php +++ b/tests/ORM/Unit/Command/InsertCommandTest.php @@ -80,7 +80,7 @@ public function testCommandWithReturningInterfaceWithoutPkColumnShouldNotUseIt() { $class = new \ReflectionClass($this->cmd); $property = $class->getProperty('pkColumn'); - $property->setAccessible(true); + PHP_VERSION_ID < 80100 and $property->setAccessible(true); $property->setValue($this->cmd, null); $table = 'table';