From fa45df4e146f7a37e111bc08d7edc4f9e88030b9 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 18:48:46 +0200 Subject: [PATCH 1/9] Add optional_index and optional_property lenses optional(index()) and optional(property()) throw and catch an exception on every miss. These lenses keep the same contract, but their get resolves a miss to null without throwing. Set still goes through the optional composition. --- composer.json | 2 + docs/lens.md | 45 +++++++ src/Lens/optional_index.php | 27 ++++ src/Lens/optional_property.php | 69 ++++++++++ .../Optional/AbstractSecretParent.php | 9 ++ tests/fixtures/Optional/DynamicSubject.php | 12 ++ tests/fixtures/Optional/HookedSubject.php | 21 ++++ tests/fixtures/Optional/MagicSubject.php | 20 +++ tests/fixtures/Optional/TypedSubject.php | 24 ++++ tests/unit/Lens/OptionalIndexTest.php | 56 +++++++++ tests/unit/Lens/OptionalPropertyTest.php | 119 ++++++++++++++++++ 11 files changed, 404 insertions(+) create mode 100644 src/Lens/optional_index.php create mode 100644 src/Lens/optional_property.php create mode 100644 tests/fixtures/Optional/AbstractSecretParent.php create mode 100644 tests/fixtures/Optional/DynamicSubject.php create mode 100644 tests/fixtures/Optional/HookedSubject.php create mode 100644 tests/fixtures/Optional/MagicSubject.php create mode 100644 tests/fixtures/Optional/TypedSubject.php create mode 100644 tests/unit/Lens/OptionalIndexTest.php create mode 100644 tests/unit/Lens/OptionalPropertyTest.php diff --git a/composer.json b/composer.json index 8e62f45..f5d8bfe 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,8 @@ "src/ArrayAccess/index_get.php", "src/ArrayAccess/index_set.php", "src/Lens/optional.php", + "src/Lens/optional_index.php", + "src/Lens/optional_property.php", "src/Lens/compose.php", "src/Lens/index.php", "src/Lens/properties.php", diff --git a/docs/lens.md b/docs/lens.md index cb27f2b..53c8728 100644 --- a/docs/lens.md +++ b/docs/lens.md @@ -181,6 +181,51 @@ Since this is a very common lens, a shortcut got added to any `Lens` instance in $optionalLens = $lens->optional(); ``` +#### optional_index + +This function returns the same lens as `optional(index($index))`, with a `get` that never throws internally. +Use it when misses are common, for example when reading many keys that may be absent: the optional composition pays for an exception on every miss. + +* **Get** will return the array item by key, or `null` if the key does not exist or the subject is not an array. +* **Set** behaves like `optional(index($index))->set()`. + +```php +use function VeeWee\Reflecta\Lens\optional_index; + +$lens = optional_index('b'); + +$lens->get(['a' => 'Apple', 'b' => 'Blueberry']); +// > "Blueberry" +$lens->get(['a' => 'Apple']); +// > null +$lens->get(null); +// > null +``` + +#### optional_property + +This function returns the same lens as `optional(property($propertyName))`, with a `get` that does not throw internally on a hit or a miss. +The property is resolved once per class and cached inside the lens. Dynamic properties are looked up on the object itself, so one instance never influences another. + +* **Get** will return the value of the property (with any visibility), or `null` if the property does not exist, is not initialized, its get hook throws, or the subject is not an object. +* **Set** behaves like `optional(property($propertyName))->set()`. + +```php +use function VeeWee\Reflecta\Lens\optional_property; + +class Item { + public string $value = 'hello'; + public string $uninitialized; +} + +optional_property('value')->get(new Item()); +// > "hello" +optional_property('uninitialized')->get(new Item()); +// > null +optional_property('unknown')->get(new Item()); +// > null +``` + #### properties This function will create a lens that can zoom in on an object's properties (with any visibility). diff --git a/src/Lens/optional_index.php b/src/Lens/optional_index.php new file mode 100644 index 0000000..00361f2 --- /dev/null +++ b/src/Lens/optional_index.php @@ -0,0 +1,27 @@ + + * @psalm-pure + */ +function optional_index($index): Lens +{ + $optional = optional(index($index)); + + /** @return Lens */ + return new Lens( + /** + * @param array|null $subject + */ + static fn (mixed $subject): mixed => is_array($subject) ? ($subject[$index] ?? null) : null, + /** + * @param array|null $subject + */ + static fn (mixed $subject, mixed $value): ?array => $optional->set($subject, $value), + ); +} diff --git a/src/Lens/optional_property.php b/src/Lens/optional_property.php new file mode 100644 index 0000000..2f94dba --- /dev/null +++ b/src/Lens/optional_property.php @@ -0,0 +1,69 @@ + + * @psalm-pure + */ +function optional_property(string $propertyName): Lens +{ + /** @var Lens $optional */ + $optional = optional(property($propertyName)); + /** @var array $declared */ + $declared = []; + $resolve = + /** + * @param class-string $className + */ + static function (string $className) use ($propertyName): ReflectionProperty|false { + /** @var ReflectionProperty|null $property */ + $property = ReflectedClass::fromFullyQualifiedClassName($className) + ->findProperty($propertyName) + ?->apply(static fn (ReflectionProperty $property): ReflectionProperty => $property); + + return $property ?? false; + }; + + return new Lens( + /** + * @param S|null $subject + */ + static function (mixed $subject) use ($propertyName, $resolve, &$declared): mixed { + if (!is_object($subject)) { + return null; + } + + // Resolved from the class, not the object, so one instance's dynamic properties never enter the cache. + $property = $declared[$subject::class] ??= $resolve($subject::class); + + if ($property === false) { + return property_exists($subject, $propertyName) ? $subject->{$propertyName} : null; + } + + // An unset property falls back to __get, so only skip the read when there is none. + if (!$property->isInitialized($subject) && !method_exists($subject, '__get')) { + return null; + } + + try { + return $property->getValue($subject); + } catch (Throwable) { + return null; + } + }, + /** + * @param S|null $subject + * @return S|null + */ + static fn (mixed $subject, mixed $value): ?object => $optional->set($subject, $value), + ); +} diff --git a/tests/fixtures/Optional/AbstractSecretParent.php b/tests/fixtures/Optional/AbstractSecretParent.php new file mode 100644 index 0000000..1fc42c4 --- /dev/null +++ b/tests/fixtures/Optional/AbstractSecretParent.php @@ -0,0 +1,9 @@ + 'hooked:' . $this->hooked; + } + + public string $virtual { + get => 'virtual'; + } + + public string $throwing { + get => throw new RuntimeException('Hook failed'); + } +} diff --git a/tests/fixtures/Optional/MagicSubject.php b/tests/fixtures/Optional/MagicSubject.php new file mode 100644 index 0000000..6237883 --- /dev/null +++ b/tests/fixtures/Optional/MagicSubject.php @@ -0,0 +1,20 @@ +unset); + } + + public function __get(string $name): string + { + return 'magic:' . $name; + } +} diff --git a/tests/fixtures/Optional/TypedSubject.php b/tests/fixtures/Optional/TypedSubject.php new file mode 100644 index 0000000..03ba554 --- /dev/null +++ b/tests/fixtures/Optional/TypedSubject.php @@ -0,0 +1,24 @@ +readonly = 'readonly'; + unset($this->unset, $this->untypedUnset); + } +} diff --git a/tests/unit/Lens/OptionalIndexTest.php b/tests/unit/Lens/OptionalIndexTest.php new file mode 100644 index 0000000..f1e40e5 --- /dev/null +++ b/tests/unit/Lens/OptionalIndexTest.php @@ -0,0 +1,56 @@ +get($subject); + + static::assertSame($expected, $actual); + static::assertSame(optional(index($index))->get($subject), $actual); + } + + #[DataProvider('provideSetSubjects')] + public function test_it_sets_like_the_optional_index_composition(mixed $subject, string|int $index, mixed $expected): void + { + $actual = optional_index($index)->set($subject, 'new'); + + static::assertSame($expected, $actual); + static::assertSame(optional(index($index))->set($subject, 'new'), $actual); + } + + public static function provideSubjects(): iterable + { + $dict = ['a' => 'A', 'n' => null, 5 => 'five']; + + yield 'present' => [$dict, 'a', 'A']; + yield 'present null' => [$dict, 'n', null]; + yield 'absent' => [$dict, 'zz', null]; + yield 'int key' => [$dict, 5, 'five']; + yield 'numeric string key' => [$dict, '5', 'five']; + yield 'list index' => [['x', 'y'], 1, 'y']; + yield 'empty array' => [[], 'a', null]; + yield 'null subject' => [null, 'a', null]; + yield 'string subject' => ['abc', 0, null]; + yield 'array access subject' => [new ArrayObject(['a' => 'A']), 'a', null]; + } + + public static function provideSetSubjects(): iterable + { + yield 'present' => [['a' => 'A'], 'a', ['a' => 'new']]; + yield 'absent' => [[], 'a', ['a' => 'new']]; + yield 'int key' => [['x'], 1, ['x', 'new']]; + yield 'null subject' => [null, 'a', null]; + } +} diff --git a/tests/unit/Lens/OptionalPropertyTest.php b/tests/unit/Lens/OptionalPropertyTest.php new file mode 100644 index 0000000..66b5d9d --- /dev/null +++ b/tests/unit/Lens/OptionalPropertyTest.php @@ -0,0 +1,119 @@ +get($subject); + + static::assertSame($expected, $actual); + static::assertSame(@optional(property($name))->get($subject), $actual); + } + + public function test_it_reuses_one_lens_over_all_subjects(): void + { + $lenses = []; + foreach (self::provideSubjects() as $label => [$subject, $name, $expected]) { + $lens = $lenses[$name] ??= optional_property($name); + + static::assertSame($expected, $lens->get($subject), $label); + } + } + + public function test_it_does_not_share_dynamic_properties_between_instances(): void + { + $lens = optional_property('a'); + + static::assertSame('A', $lens->get((object) ['a' => 'A'])); + static::assertNull($lens->get((object) ['b' => 'B'])); + static::assertSame('A2', $lens->get((object) ['a' => 'A2'])); + + $dynamic = new DynamicSubject(); + $dynamic->{'a'} = 'dynamic'; + static::assertSame('dynamic', $lens->get($dynamic)); + static::assertNull($lens->get(new DynamicSubject())); + } + + #[DataProvider('provideSetSubjects')] + public function test_it_sets_like_the_optional_property_composition(mixed $subject, string $name, mixed $expected): void + { + $actual = optional_property($name)->set($subject, 'new'); + + static::assertEquals($expected, $actual); + static::assertEquals(optional(property($name))->set($subject, 'new'), $actual); + if ($actual !== null) { + static::assertNotSame($subject, $actual); + } + } + + public static function provideSubjects(): iterable + { + $typed = new TypedSubject(); + $std = (object) ['a' => 'A', 'n' => null]; + $cast = (object) [5 => 'five', 'k' => 'v']; + $dynamic = new DynamicSubject(); + $dynamic->{'extra'} = 'dynamic'; + $hooked = new HookedSubject(); + $magic = new MagicSubject(); + + yield 'typed public' => [$typed, 'public', 'public']; + yield 'typed protected' => [$typed, 'protected', 'protected']; + yield 'typed private' => [$typed, 'private', 'private']; + yield 'private in parent' => [$typed, 'secret', 'parent-private']; + yield 'readonly' => [$typed, 'readonly', 'readonly']; + yield 'nullable null' => [$typed, 'nullable', null]; + yield 'uninitialized' => [$typed, 'uninitialized', null]; + yield 'unset' => [$typed, 'unset', null]; + yield 'untyped unset' => [$typed, 'untypedUnset', null]; + yield 'static' => [$typed, 'static', 'static']; + yield 'undeclared' => [$typed, 'nope', null]; + yield 'stdClass present' => [$std, 'a', 'A']; + yield 'stdClass null' => [$std, 'n', null]; + yield 'stdClass absent' => [$std, 'zz', null]; + yield 'array cast int key' => [$cast, '5', 'five']; + yield 'array cast string key' => [$cast, 'k', 'v']; + yield 'array cast absent' => [$cast, 'q', null]; + yield 'dynamic class declared' => [$dynamic, 'declared', 'declared']; + yield 'dynamic class dynamic' => [$dynamic, 'extra', 'dynamic']; + yield 'dynamic class absent' => [$dynamic, 'nope', null]; + yield 'get hook' => [$hooked, 'hooked', 'hooked:raw']; + yield 'virtual hook' => [$hooked, 'virtual', 'virtual']; + yield 'throwing hook' => [$hooked, 'throwing', null]; + yield 'magic undeclared' => [$magic, 'x', null]; + yield 'magic unset' => [$magic, 'unset', 'magic:unset']; + yield 'magic uninitialized' => [$magic, 'uninitialized', null]; + yield 'other stdClass absent' => [(object) ['b' => 1], 'a', null]; + yield 'null subject' => [null, 'a', null]; + yield 'array subject' => [['a' => 'A'], 'a', null]; + } + + public static function provideSetSubjects(): iterable + { + $typed = new TypedSubject(); + $std = new stdClass(); + $expectedStd = new stdClass(); + $expectedStd->a = 'new'; + $expectedTyped = new TypedSubject(); + $expectedTyped->public = 'new'; + + yield 'typed public' => [$typed, 'public', $expectedTyped]; + yield 'readonly' => [$typed, 'readonly', null]; + yield 'undeclared on stdClass' => [$std, 'a', $expectedStd]; + yield 'null subject' => [null, 'a', null]; + } +} From 29c97c451b6959081bf70e658777ab5e2e464cf1 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 18:48:46 +0200 Subject: [PATCH 2/9] Benchmark the optional lenses against the optional composition --- benchmarks/OptionalLensBench.php | 80 ++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 benchmarks/OptionalLensBench.php diff --git a/benchmarks/OptionalLensBench.php b/benchmarks/OptionalLensBench.php new file mode 100644 index 0000000..07a9986 --- /dev/null +++ b/benchmarks/OptionalLensBench.php @@ -0,0 +1,80 @@ + [['p0' => 1, 'p1' => 1], 'p0'], + 'array_miss' => [['p0' => 1, 'p1' => 1], 'missing'], + 'typed_hit' => [new Typed5(), 'p0'], + 'typed_miss' => [new Typed5(), 'missing'], + 'stdclass_hit' => [(object) ['p0' => 1, 'p1' => 1], 'p0'], + 'stdclass_miss' => [(object) ['p0' => 1, 'p1' => 1], 'missing'], + }; + + $this->subject = $subject; + $this->lens = match ($params['lens']) { + 'optional_index' => optional(index($name)), + 'optional_index_fn' => optional_index($name), + 'optional_property' => optional(property($name)), + 'optional_property_fn' => optional_property($name), + }; + $this->lens->get($this->subject); + } + + #[ParamProviders(['provideIndexCases'])] + public function bench_index_get(): void + { + $this->lens->get($this->subject); + } + + #[ParamProviders(['providePropertyCases'])] + public function bench_property_get(): void + { + $this->lens->get($this->subject); + } + + public function provideIndexCases(): Generator + { + foreach (['array_hit', 'array_miss'] as $scenario) { + yield $scenario . ' optional(index())' => ['lens' => 'optional_index', 'scenario' => $scenario]; + yield $scenario . ' optional_index()' => ['lens' => 'optional_index_fn', 'scenario' => $scenario]; + } + } + + public function providePropertyCases(): Generator + { + foreach (['typed_hit', 'typed_miss', 'stdclass_hit', 'stdclass_miss'] as $scenario) { + yield $scenario . ' optional(property())' => ['lens' => 'optional_property', 'scenario' => $scenario]; + yield $scenario . ' optional_property()' => ['lens' => 'optional_property_fn', 'scenario' => $scenario]; + } + } +} From 4b95d333d4c414a72ea2c1102a5c7b7491b6d11e Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 14 Oct 2026 18:51:07 +0200 Subject: [PATCH 3/9] Return null when a lazy object fails to initialise in optional_property isInitialized() runs the initialiser of a lazy ghost, so it has to sit inside the try like getValue(). The index parameter is typed string|int, matching index_get(). --- src/Lens/optional_index.php | 2 +- src/Lens/optional_property.php | 10 +++++----- tests/unit/Lens/OptionalPropertyTest.php | 7 +++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Lens/optional_index.php b/src/Lens/optional_index.php index 00361f2..adb13d6 100644 --- a/src/Lens/optional_index.php +++ b/src/Lens/optional_index.php @@ -9,7 +9,7 @@ * @return Lens * @psalm-pure */ -function optional_index($index): Lens +function optional_index(string|int $index): Lens { $optional = optional(index($index)); diff --git a/src/Lens/optional_property.php b/src/Lens/optional_property.php index 2f94dba..2ca7287 100644 --- a/src/Lens/optional_property.php +++ b/src/Lens/optional_property.php @@ -49,12 +49,12 @@ static function (mixed $subject) use ($propertyName, $resolve, &$declared): mixe return property_exists($subject, $propertyName) ? $subject->{$propertyName} : null; } - // An unset property falls back to __get, so only skip the read when there is none. - if (!$property->isInitialized($subject) && !method_exists($subject, '__get')) { - return null; - } - try { + // An unset property falls back to __get, so only skip the read when there is none. + if (!$property->isInitialized($subject) && !method_exists($subject, '__get')) { + return null; + } + return $property->getValue($subject); } catch (Throwable) { return null; diff --git a/tests/unit/Lens/OptionalPropertyTest.php b/tests/unit/Lens/OptionalPropertyTest.php index 66b5d9d..f5aa46f 100644 --- a/tests/unit/Lens/OptionalPropertyTest.php +++ b/tests/unit/Lens/OptionalPropertyTest.php @@ -5,6 +5,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionClass; +use RuntimeException; use stdClass; use VeeWee\Reflecta\TestFixtures\Optional\DynamicSubject; use VeeWee\Reflecta\TestFixtures\Optional\HookedSubject; @@ -98,6 +100,11 @@ public static function provideSubjects(): iterable yield 'magic unset' => [$magic, 'unset', 'magic:unset']; yield 'magic uninitialized' => [$magic, 'uninitialized', null]; yield 'other stdClass absent' => [(object) ['b' => 1], 'a', null]; + yield 'lazy ghost with failing initializer' => [ + (new ReflectionClass(TypedSubject::class))->newLazyGhost(static fn () => throw new RuntimeException('Init failed')), + 'public', + null, + ]; yield 'null subject' => [null, 'a', null]; yield 'array subject' => [['a' => 'A'], 'a', null]; } From 4703efd5a8bd51023710a9fb9877eff57050562c Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 07:24:39 +0200 Subject: [PATCH 4/9] Make optional_property stateless Lenses in reflecta keep no state, so the lens now resolves the property from the object on every get instead of caching it per class. The lookup moved inside the catch, because a lazy ghost runs its initialiser while looking up a name the class does not declare. --- src/Lens/optional_property.php | 40 +++++++----------------- tests/unit/Lens/OptionalPropertyTest.php | 23 ++++++++++++++ 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/Lens/optional_property.php b/src/Lens/optional_property.php index 2ca7287..7dd108d 100644 --- a/src/Lens/optional_property.php +++ b/src/Lens/optional_property.php @@ -7,7 +7,6 @@ use VeeWee\Reflecta\Reflect\Type\ReflectedClass; use function is_object; use function method_exists; -use function property_exists; /** * @template S of object @@ -18,44 +17,27 @@ function optional_property(string $propertyName): Lens { /** @var Lens $optional */ $optional = optional(property($propertyName)); - /** @var array $declared */ - $declared = []; - $resolve = - /** - * @param class-string $className - */ - static function (string $className) use ($propertyName): ReflectionProperty|false { - /** @var ReflectionProperty|null $property */ - $property = ReflectedClass::fromFullyQualifiedClassName($className) - ->findProperty($propertyName) - ?->apply(static fn (ReflectionProperty $property): ReflectionProperty => $property); - - return $property ?? false; - }; return new Lens( /** * @param S|null $subject */ - static function (mixed $subject) use ($propertyName, $resolve, &$declared): mixed { + static function (mixed $subject) use ($propertyName): mixed { if (!is_object($subject)) { return null; } - // Resolved from the class, not the object, so one instance's dynamic properties never enter the cache. - $property = $declared[$subject::class] ??= $resolve($subject::class); - - if ($property === false) { - return property_exists($subject, $propertyName) ? $subject->{$propertyName} : null; - } - try { - // An unset property falls back to __get, so only skip the read when there is none. - if (!$property->isInitialized($subject) && !method_exists($subject, '__get')) { - return null; - } - - return $property->getValue($subject); + return ReflectedClass::fromObject($subject)->findProperty($propertyName)?->apply( + static function (ReflectionProperty $property) use ($subject): mixed { + // An unset property falls back to __get, so only skip the read when there is none. + if (!$property->isInitialized($subject) && !method_exists($subject, '__get')) { + return null; + } + + return $property->getValue($subject); + } + ); } catch (Throwable) { return null; } diff --git a/tests/unit/Lens/OptionalPropertyTest.php b/tests/unit/Lens/OptionalPropertyTest.php index f5aa46f..1042954 100644 --- a/tests/unit/Lens/OptionalPropertyTest.php +++ b/tests/unit/Lens/OptionalPropertyTest.php @@ -51,6 +51,24 @@ public function test_it_does_not_share_dynamic_properties_between_instances(): v static::assertNull($lens->get(new DynamicSubject())); } + public function test_it_resolves_every_subject_on_its_own(): void + { + $declared = optional_property('declared'); + static::assertSame('declared', $declared->get(new DynamicSubject())); + static::assertNull($declared->get(new TypedSubject())); + static::assertSame('declared', $declared->get(new DynamicSubject())); + + $unset = optional_property('unset'); + static::assertNull($unset->get(new TypedSubject())); + static::assertSame('magic:unset', $unset->get(new MagicSubject())); + static::assertNull($unset->get(new TypedSubject())); + + $b = optional_property('b'); + static::assertNull($b->get((object) ['a' => 'A'])); + static::assertSame('B', $b->get((object) ['b' => 'B'])); + static::assertNull($b->get((object) ['a' => 'A'])); + } + #[DataProvider('provideSetSubjects')] public function test_it_sets_like_the_optional_property_composition(mixed $subject, string $name, mixed $expected): void { @@ -105,6 +123,11 @@ public static function provideSubjects(): iterable 'public', null, ]; + yield 'lazy ghost absent with failing initializer' => [ + (new ReflectionClass(TypedSubject::class))->newLazyGhost(static fn () => throw new RuntimeException('Init failed')), + 'nope', + null, + ]; yield 'null subject' => [null, 'a', null]; yield 'array subject' => [['a' => 'A'], 'a', null]; } From 6bdf4a7dc2d3be5d2e9afa529ee0c5b78f1b197c Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 07:51:21 +0200 Subject: [PATCH 5/9] Resolve optional_property without a ReflectionObject Building a ReflectionObject on every read made a property hit cost about 0.38us. object_declared_property() asks property_exists() on the class and its parents instead, so the lens builds one ReflectionProperty for a declared property and reads dynamic properties directly. A parent's private property still wins over a dynamic property with the same name, as in optional(property()). --- composer.json | 1 + docs/lens.md | 2 +- docs/reflect.md | 16 +++++ src/Lens/optional_property.php | 24 +++---- src/Reflect/object_declared_property.php | 21 ++++++ .../Optional/AbstractSecretParent.php | 1 + .../Optional/DynamicSecretSubject.php | 11 ++++ tests/unit/Lens/OptionalPropertyTest.php | 4 ++ .../Reflect/ObjectDeclaredPropertyTest.php | 65 +++++++++++++++++++ 9 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 src/Reflect/object_declared_property.php create mode 100644 tests/fixtures/Optional/DynamicSecretSubject.php create mode 100644 tests/unit/Reflect/ObjectDeclaredPropertyTest.php diff --git a/composer.json b/composer.json index f5d8bfe..460dbe5 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "src/Reflect/class_is_dynamic.php", "src/Reflect/instantiate.php", "src/Reflect/object_attributes.php", + "src/Reflect/object_declared_property.php", "src/Reflect/object_has_attribute.php", "src/Reflect/object_is_dynamic.php", "src/Reflect/properties_get.php", diff --git a/docs/lens.md b/docs/lens.md index 53c8728..624940d 100644 --- a/docs/lens.md +++ b/docs/lens.md @@ -205,7 +205,7 @@ $lens->get(null); #### optional_property This function returns the same lens as `optional(property($propertyName))`, with a `get` that does not throw internally on a hit or a miss. -The property is resolved once per class and cached inside the lens. Dynamic properties are looked up on the object itself, so one instance never influences another. +The lens is stateless: every `get` resolves the property on the given object through `object_declared_property()`, and falls back to the dynamic properties of that object. * **Get** will return the value of the property (with any visibility), or `null` if the property does not exist, is not initialized, its get hook throws, or the subject is not an object. * **Set** behaves like `optional(property($propertyName))->set()`. diff --git a/docs/reflect.md b/docs/reflect.md index 4253037..b30be00 100644 --- a/docs/reflect.md +++ b/docs/reflect.md @@ -94,6 +94,22 @@ try { } ``` +#### object_declared_property + +Finds the `ReflectionProperty` that the class of the given object, or the nearest parent, declares under the given name. +A private property of a parent class is found as well, also when the object carries a dynamic property with the same name. +Dynamic properties are not declared, so the result is `null` for those and for names that no class declares. +This function never throws. + +```php +use function VeeWee\Reflecta\Reflect\object_declared_property; + +$property = object_declared_property($yourObject, $theProperty); +if ($property !== null && $property->isInitialized($yourObject)) { + $value = $property->getValue($yourObject); +} +``` + #### object_has_attribute Checks if the object contains an attribute of given type (or super-type). diff --git a/src/Lens/optional_property.php b/src/Lens/optional_property.php index 7dd108d..2e3cb08 100644 --- a/src/Lens/optional_property.php +++ b/src/Lens/optional_property.php @@ -2,11 +2,11 @@ namespace VeeWee\Reflecta\Lens; -use ReflectionProperty; use Throwable; -use VeeWee\Reflecta\Reflect\Type\ReflectedClass; use function is_object; use function method_exists; +use function property_exists; +use function VeeWee\Reflecta\Reflect\object_declared_property; /** * @template S of object @@ -28,16 +28,18 @@ static function (mixed $subject) use ($propertyName): mixed { } try { - return ReflectedClass::fromObject($subject)->findProperty($propertyName)?->apply( - static function (ReflectionProperty $property) use ($subject): mixed { - // An unset property falls back to __get, so only skip the read when there is none. - if (!$property->isInitialized($subject) && !method_exists($subject, '__get')) { - return null; - } + $property = object_declared_property($subject, $propertyName); + if ($property === null) { + // Dynamic properties are public, unhooked and always initialised. + return property_exists($subject, $propertyName) ? $subject->{$propertyName} : null; + } - return $property->getValue($subject); - } - ); + // An unset property falls back to __get, so only skip the read when there is none. + if (!$property->isInitialized($subject) && !method_exists($subject, '__get')) { + return null; + } + + return $property->getValue($subject); } catch (Throwable) { return null; } diff --git a/src/Reflect/object_declared_property.php b/src/Reflect/object_declared_property.php new file mode 100644 index 0000000..210cba5 --- /dev/null +++ b/src/Reflect/object_declared_property.php @@ -0,0 +1,21 @@ +{'extra'} = 'dynamic'; $hooked = new HookedSubject(); $magic = new MagicSubject(); + $shadowed = new DynamicSecretSubject(); + $shadowed->{'secret'} = 'dynamic'; yield 'typed public' => [$typed, 'public', 'public']; yield 'typed protected' => [$typed, 'protected', 'protected']; yield 'typed private' => [$typed, 'private', 'private']; yield 'private in parent' => [$typed, 'secret', 'parent-private']; + yield 'private in parent shadowed by a dynamic property' => [$shadowed, 'secret', 'parent-private']; yield 'readonly' => [$typed, 'readonly', 'readonly']; yield 'nullable null' => [$typed, 'nullable', null]; yield 'uninitialized' => [$typed, 'uninitialized', null]; diff --git a/tests/unit/Reflect/ObjectDeclaredPropertyTest.php b/tests/unit/Reflect/ObjectDeclaredPropertyTest.php new file mode 100644 index 0000000..455edc4 --- /dev/null +++ b/tests/unit/Reflect/ObjectDeclaredPropertyTest.php @@ -0,0 +1,65 @@ +getName()); + static::assertSame($declaringClass, $property->getDeclaringClass()->getName()); + static::assertSame($value, $property->getValue($object)); + } + + #[DataProvider('provideUndeclaredProperties')] + public function test_it_returns_null_when_no_class_declares_the_property(object $object, string $name): void + { + static::assertNull(object_declared_property($object, $name)); + } + + public function test_it_finds_static_properties(): void + { + $property = object_declared_property(new TypedSubject(), 'static'); + + static::assertNotNull($property); + static::assertTrue($property->isStatic()); + static::assertSame('static', $property->getValue()); + } + + public static function provideDeclaredProperties(): iterable + { + $shadowed = new DynamicSecretSubject(); + $shadowed->{'secret'} = 'dynamic'; + + yield 'own public' => [new TypedSubject(), 'public', TypedSubject::class, 'public']; + yield 'own private' => [new TypedSubject(), 'private', TypedSubject::class, 'private']; + yield 'inherited protected' => [new TypedSubject(), 'inherited', AbstractSecretParent::class, 'parent-protected']; + yield 'private in parent' => [new TypedSubject(), 'secret', AbstractSecretParent::class, 'parent-private']; + yield 'private in parent shadowed by a dynamic property' => [$shadowed, 'secret', AbstractSecretParent::class, 'parent-private']; + yield 'declared on a dynamic class' => [new DynamicSubject(), 'declared', DynamicSubject::class, 'declared']; + } + + public static function provideUndeclaredProperties(): iterable + { + $dynamic = new DynamicSubject(); + $dynamic->{'extra'} = 'dynamic'; + + yield 'undeclared' => [new TypedSubject(), 'nope']; + yield 'undeclared with a parent' => [new DynamicSecretSubject(), 'nope']; + yield 'dynamic' => [$dynamic, 'extra']; + yield 'stdClass' => [(object) ['a' => 'A'], 'a']; + } +} From 13dbaa08d1f6b907e94c9aedac51c2393d5369ab Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 08:10:37 +0200 Subject: [PATCH 6/9] Let a dynamic property shadow a private parent property in optional_property Plain property access returns the dynamic value. ReflectionObject resolves this case differently between PHP patch releases (8.4.13 against 8.4.26), so the test compares against plain access instead of optional(property()). --- src/Lens/optional_property.php | 9 +++++++-- tests/unit/Lens/OptionalPropertyTest.php | 14 ++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Lens/optional_property.php b/src/Lens/optional_property.php index 2e3cb08..1087812 100644 --- a/src/Lens/optional_property.php +++ b/src/Lens/optional_property.php @@ -28,10 +28,15 @@ static function (mixed $subject) use ($propertyName): mixed { } try { + // Dynamic properties are public, unhooked and always initialised. They are checked before the + // parents, because a dynamic property shadows a private parent property of the same name. + if (!property_exists($subject::class, $propertyName) && property_exists($subject, $propertyName)) { + return $subject->{$propertyName}; + } + $property = object_declared_property($subject, $propertyName); if ($property === null) { - // Dynamic properties are public, unhooked and always initialised. - return property_exists($subject, $propertyName) ? $subject->{$propertyName} : null; + return null; } // An unset property falls back to __get, so only skip the read when there is none. diff --git a/tests/unit/Lens/OptionalPropertyTest.php b/tests/unit/Lens/OptionalPropertyTest.php index 8419f2d..a4b16cb 100644 --- a/tests/unit/Lens/OptionalPropertyTest.php +++ b/tests/unit/Lens/OptionalPropertyTest.php @@ -28,6 +28,16 @@ public function test_it_gets_like_the_optional_property_composition(mixed $subje static::assertSame(@optional(property($name))->get($subject), $actual); } + public function test_it_reads_a_dynamic_property_that_shadows_a_private_parent_property(): void + { + // Compared to plain PHP property access instead of optional(property()): + // ReflectionObject resolves this case differently between PHP patch releases. + $subject = new DynamicSecretSubject(); + $subject->{'secret'} = 'dynamic'; + + static::assertSame($subject->{'secret'}, optional_property('secret')->get($subject)); + } + public function test_it_reuses_one_lens_over_all_subjects(): void { $lenses = []; @@ -91,14 +101,10 @@ public static function provideSubjects(): iterable $dynamic->{'extra'} = 'dynamic'; $hooked = new HookedSubject(); $magic = new MagicSubject(); - $shadowed = new DynamicSecretSubject(); - $shadowed->{'secret'} = 'dynamic'; - yield 'typed public' => [$typed, 'public', 'public']; yield 'typed protected' => [$typed, 'protected', 'protected']; yield 'typed private' => [$typed, 'private', 'private']; yield 'private in parent' => [$typed, 'secret', 'parent-private']; - yield 'private in parent shadowed by a dynamic property' => [$shadowed, 'secret', 'parent-private']; yield 'readonly' => [$typed, 'readonly', 'readonly']; yield 'nullable null' => [$typed, 'nullable', null]; yield 'uninitialized' => [$typed, 'uninitialized', null]; From 245e598833597331e936bdca024ea2bd86144e6e Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 08:31:05 +0200 Subject: [PATCH 7/9] Rename object_declared_property to class_find_property It only ever looked at the class chain, so it now takes a class name and follows the find convention: null instead of an exception. optional_property keeps PHP's own order for $object->{$name} in one place: the class itself, then a dynamic property, then the parents. --- composer.json | 2 +- docs/lens.md | 2 +- docs/reflect.md | 32 ++++----- src/Lens/optional_property.php | 11 ++-- ...d_property.php => class_find_property.php} | 8 ++- tests/unit/Reflect/ClassFindPropertyTest.php | 60 +++++++++++++++++ .../Reflect/ObjectDeclaredPropertyTest.php | 65 ------------------- 7 files changed, 89 insertions(+), 91 deletions(-) rename src/Reflect/{object_declared_property.php => class_find_property.php} (68%) create mode 100644 tests/unit/Reflect/ClassFindPropertyTest.php delete mode 100644 tests/unit/Reflect/ObjectDeclaredPropertyTest.php diff --git a/composer.json b/composer.json index 460dbe5..1f0e0a4 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "src/Reflect/class_is_dynamic.php", "src/Reflect/instantiate.php", "src/Reflect/object_attributes.php", - "src/Reflect/object_declared_property.php", + "src/Reflect/class_find_property.php", "src/Reflect/object_has_attribute.php", "src/Reflect/object_is_dynamic.php", "src/Reflect/properties_get.php", diff --git a/docs/lens.md b/docs/lens.md index 624940d..d71c9b5 100644 --- a/docs/lens.md +++ b/docs/lens.md @@ -205,7 +205,7 @@ $lens->get(null); #### optional_property This function returns the same lens as `optional(property($propertyName))`, with a `get` that does not throw internally on a hit or a miss. -The lens is stateless: every `get` resolves the property on the given object through `object_declared_property()`, and falls back to the dynamic properties of that object. +The lens is stateless. Every `get` resolves the name the way PHP does for `$object->{$name}`: a property the class itself declares, then a dynamic property of that object, then a property declared by a parent class (found through `class_find_property()`). * **Get** will return the value of the property (with any visibility), or `null` if the property does not exist, is not initialized, its get hook throws, or the subject is not an object. * **Set** behaves like `optional(property($propertyName))->set()`. diff --git a/docs/reflect.md b/docs/reflect.md index b30be00..df01b00 100644 --- a/docs/reflect.md +++ b/docs/reflect.md @@ -27,6 +27,22 @@ try { } ``` +#### class_find_property + +Finds the `ReflectionProperty` that the given class, or its nearest parent, declares under the given name. +A private property of a parent class is found as well. +It works on the class only, so dynamic properties of an instance are never returned. +The result is `null` when no class in the chain declares the name, and the function never throws. + +```php +use function VeeWee\Reflecta\Reflect\class_find_property; + +$property = class_find_property(YourClass::class, $theProperty); +if ($property !== null && $property->isInitialized($yourObject)) { + $value = $property->getValue($yourObject); +} +``` + #### class_has_attribute Checks if the class contains an attribute of given type (or super-type). @@ -94,22 +110,6 @@ try { } ``` -#### object_declared_property - -Finds the `ReflectionProperty` that the class of the given object, or the nearest parent, declares under the given name. -A private property of a parent class is found as well, also when the object carries a dynamic property with the same name. -Dynamic properties are not declared, so the result is `null` for those and for names that no class declares. -This function never throws. - -```php -use function VeeWee\Reflecta\Reflect\object_declared_property; - -$property = object_declared_property($yourObject, $theProperty); -if ($property !== null && $property->isInitialized($yourObject)) { - $value = $property->getValue($yourObject); -} -``` - #### object_has_attribute Checks if the object contains an attribute of given type (or super-type). diff --git a/src/Lens/optional_property.php b/src/Lens/optional_property.php index 1087812..26eafb8 100644 --- a/src/Lens/optional_property.php +++ b/src/Lens/optional_property.php @@ -6,7 +6,7 @@ use function is_object; use function method_exists; use function property_exists; -use function VeeWee\Reflecta\Reflect\object_declared_property; +use function VeeWee\Reflecta\Reflect\class_find_property; /** * @template S of object @@ -28,13 +28,14 @@ static function (mixed $subject) use ($propertyName): mixed { } try { - // Dynamic properties are public, unhooked and always initialised. They are checked before the - // parents, because a dynamic property shadows a private parent property of the same name. - if (!property_exists($subject::class, $propertyName) && property_exists($subject, $propertyName)) { + // PHP resolves $subject->{$name} as: the class itself, then a dynamic property, then the parents. + // Dynamic properties are public, unhooked and always initialised, so they are read directly. + $className = $subject::class; + if (!property_exists($className, $propertyName) && property_exists($subject, $propertyName)) { return $subject->{$propertyName}; } - $property = object_declared_property($subject, $propertyName); + $property = class_find_property($className, $propertyName); if ($property === null) { return null; } diff --git a/src/Reflect/object_declared_property.php b/src/Reflect/class_find_property.php similarity index 68% rename from src/Reflect/object_declared_property.php rename to src/Reflect/class_find_property.php index 210cba5..128456f 100644 --- a/src/Reflect/object_declared_property.php +++ b/src/Reflect/class_find_property.php @@ -6,11 +6,13 @@ use function get_parent_class; use function property_exists; -function object_declared_property(object $object, string $name): ?ReflectionProperty +/** + * @param class-string $className + */ +function class_find_property(string $className, string $name): ?ReflectionProperty { - $class = $object::class; + $class = $className; do { - // property_exists on a class name skips the dynamic properties of the instance. // Asking every parent finds the private properties that the child cannot see. if (property_exists($class, $name)) { return new ReflectionProperty($class, $name); diff --git a/tests/unit/Reflect/ClassFindPropertyTest.php b/tests/unit/Reflect/ClassFindPropertyTest.php new file mode 100644 index 0000000..cbd7f67 --- /dev/null +++ b/tests/unit/Reflect/ClassFindPropertyTest.php @@ -0,0 +1,60 @@ +getName()); + static::assertSame($declaringClass, $property->getDeclaringClass()->getName()); + static::assertSame($value, $property->getValue($object)); + } + + #[DataProvider('provideUndeclaredProperties')] + public function test_it_returns_null_when_no_class_declares_the_property(string $className, string $name): void + { + static::assertNull(class_find_property($className, $name)); + } + + public function test_it_finds_static_properties(): void + { + $property = class_find_property(TypedSubject::class, 'static'); + + static::assertNotNull($property); + static::assertTrue($property->isStatic()); + static::assertSame('static', $property->getValue()); + } + + public static function provideDeclaredProperties(): iterable + { + yield 'own public' => [TypedSubject::class, 'public', TypedSubject::class, new TypedSubject(), 'public']; + yield 'own private' => [TypedSubject::class, 'private', TypedSubject::class, new TypedSubject(), 'private']; + yield 'inherited protected' => [TypedSubject::class, 'inherited', AbstractSecretParent::class, new TypedSubject(), 'parent-protected']; + yield 'private in parent' => [TypedSubject::class, 'secret', AbstractSecretParent::class, new TypedSubject(), 'parent-private']; + yield 'private in parent of a dynamic class' => [DynamicSecretSubject::class, 'secret', AbstractSecretParent::class, new DynamicSecretSubject(), 'parent-private']; + yield 'declared on a dynamic class' => [DynamicSubject::class, 'declared', DynamicSubject::class, new DynamicSubject(), 'declared']; + } + + public static function provideUndeclaredProperties(): iterable + { + yield 'undeclared' => [TypedSubject::class, 'nope']; + yield 'undeclared with a parent' => [DynamicSecretSubject::class, 'nope']; + yield 'undeclared on a dynamic class' => [DynamicSubject::class, 'extra']; + yield 'stdClass' => [stdClass::class, 'a']; + } +} diff --git a/tests/unit/Reflect/ObjectDeclaredPropertyTest.php b/tests/unit/Reflect/ObjectDeclaredPropertyTest.php deleted file mode 100644 index 455edc4..0000000 --- a/tests/unit/Reflect/ObjectDeclaredPropertyTest.php +++ /dev/null @@ -1,65 +0,0 @@ -getName()); - static::assertSame($declaringClass, $property->getDeclaringClass()->getName()); - static::assertSame($value, $property->getValue($object)); - } - - #[DataProvider('provideUndeclaredProperties')] - public function test_it_returns_null_when_no_class_declares_the_property(object $object, string $name): void - { - static::assertNull(object_declared_property($object, $name)); - } - - public function test_it_finds_static_properties(): void - { - $property = object_declared_property(new TypedSubject(), 'static'); - - static::assertNotNull($property); - static::assertTrue($property->isStatic()); - static::assertSame('static', $property->getValue()); - } - - public static function provideDeclaredProperties(): iterable - { - $shadowed = new DynamicSecretSubject(); - $shadowed->{'secret'} = 'dynamic'; - - yield 'own public' => [new TypedSubject(), 'public', TypedSubject::class, 'public']; - yield 'own private' => [new TypedSubject(), 'private', TypedSubject::class, 'private']; - yield 'inherited protected' => [new TypedSubject(), 'inherited', AbstractSecretParent::class, 'parent-protected']; - yield 'private in parent' => [new TypedSubject(), 'secret', AbstractSecretParent::class, 'parent-private']; - yield 'private in parent shadowed by a dynamic property' => [$shadowed, 'secret', AbstractSecretParent::class, 'parent-private']; - yield 'declared on a dynamic class' => [new DynamicSubject(), 'declared', DynamicSubject::class, 'declared']; - } - - public static function provideUndeclaredProperties(): iterable - { - $dynamic = new DynamicSubject(); - $dynamic->{'extra'} = 'dynamic'; - - yield 'undeclared' => [new TypedSubject(), 'nope']; - yield 'undeclared with a parent' => [new DynamicSecretSubject(), 'nope']; - yield 'dynamic' => [$dynamic, 'extra']; - yield 'stdClass' => [(object) ['a' => 'A'], 'a']; - } -} From 4e77fb8bf0f1f801ae05c06759e4d14b0b2f451c Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 08:33:36 +0200 Subject: [PATCH 8/9] Keep the autoload files list in order --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 1f0e0a4..95ac709 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ "src/Iso/compose.php", "src/Iso/object_data.php", "src/Reflect/class_attributes.php", + "src/Reflect/class_find_property.php", "src/Reflect/class_has_attribute.php", "src/Reflect/class_is_dynamic.php", "src/Reflect/instantiate.php", "src/Reflect/object_attributes.php", - "src/Reflect/class_find_property.php", "src/Reflect/object_has_attribute.php", "src/Reflect/object_is_dynamic.php", "src/Reflect/properties_get.php", From b4682d671d6b61389e36b120ad99b167977e2036 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 08:39:32 +0200 Subject: [PATCH 9/9] Keep the property lookup inside optional_property ReflectedClass::findProperty() is the public way to find a property and returns a ReflectedProperty. A second public function returning a native ReflectionProperty would give users two lookups with two return types, and wrapping the native property on this hot path costs 60 to 120 ns per read. --- composer.json | 1 - docs/lens.md | 2 +- docs/reflect.md | 16 ------ src/Lens/optional_property.php | 14 ++++- src/Reflect/class_find_property.php | 23 -------- tests/unit/Reflect/ClassFindPropertyTest.php | 60 -------------------- 6 files changed, 13 insertions(+), 103 deletions(-) delete mode 100644 src/Reflect/class_find_property.php delete mode 100644 tests/unit/Reflect/ClassFindPropertyTest.php diff --git a/composer.json b/composer.json index 95ac709..f5d8bfe 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,6 @@ "src/Iso/compose.php", "src/Iso/object_data.php", "src/Reflect/class_attributes.php", - "src/Reflect/class_find_property.php", "src/Reflect/class_has_attribute.php", "src/Reflect/class_is_dynamic.php", "src/Reflect/instantiate.php", diff --git a/docs/lens.md b/docs/lens.md index d71c9b5..8e791e2 100644 --- a/docs/lens.md +++ b/docs/lens.md @@ -205,7 +205,7 @@ $lens->get(null); #### optional_property This function returns the same lens as `optional(property($propertyName))`, with a `get` that does not throw internally on a hit or a miss. -The lens is stateless. Every `get` resolves the name the way PHP does for `$object->{$name}`: a property the class itself declares, then a dynamic property of that object, then a property declared by a parent class (found through `class_find_property()`). +The lens is stateless. Every `get` resolves the name the way PHP does for `$object->{$name}`: a property the class itself declares, then a dynamic property of that object, then a property declared by a parent class. * **Get** will return the value of the property (with any visibility), or `null` if the property does not exist, is not initialized, its get hook throws, or the subject is not an object. * **Set** behaves like `optional(property($propertyName))->set()`. diff --git a/docs/reflect.md b/docs/reflect.md index df01b00..4253037 100644 --- a/docs/reflect.md +++ b/docs/reflect.md @@ -27,22 +27,6 @@ try { } ``` -#### class_find_property - -Finds the `ReflectionProperty` that the given class, or its nearest parent, declares under the given name. -A private property of a parent class is found as well. -It works on the class only, so dynamic properties of an instance are never returned. -The result is `null` when no class in the chain declares the name, and the function never throws. - -```php -use function VeeWee\Reflecta\Reflect\class_find_property; - -$property = class_find_property(YourClass::class, $theProperty); -if ($property !== null && $property->isInitialized($yourObject)) { - $value = $property->getValue($yourObject); -} -``` - #### class_has_attribute Checks if the class contains an attribute of given type (or super-type). diff --git a/src/Lens/optional_property.php b/src/Lens/optional_property.php index 26eafb8..718d6b9 100644 --- a/src/Lens/optional_property.php +++ b/src/Lens/optional_property.php @@ -2,11 +2,12 @@ namespace VeeWee\Reflecta\Lens; +use ReflectionProperty; use Throwable; +use function get_parent_class; use function is_object; use function method_exists; use function property_exists; -use function VeeWee\Reflecta\Reflect\class_find_property; /** * @template S of object @@ -35,7 +36,16 @@ static function (mixed $subject) use ($propertyName): mixed { return $subject->{$propertyName}; } - $property = class_find_property($className, $propertyName); + // Walking the parents finds the private properties that the child class cannot see. + $property = null; + $class = $className; + do { + if (property_exists($class, $propertyName)) { + $property = new ReflectionProperty($class, $propertyName); + break; + } + } while ($class = get_parent_class($class)); + if ($property === null) { return null; } diff --git a/src/Reflect/class_find_property.php b/src/Reflect/class_find_property.php deleted file mode 100644 index 128456f..0000000 --- a/src/Reflect/class_find_property.php +++ /dev/null @@ -1,23 +0,0 @@ -getName()); - static::assertSame($declaringClass, $property->getDeclaringClass()->getName()); - static::assertSame($value, $property->getValue($object)); - } - - #[DataProvider('provideUndeclaredProperties')] - public function test_it_returns_null_when_no_class_declares_the_property(string $className, string $name): void - { - static::assertNull(class_find_property($className, $name)); - } - - public function test_it_finds_static_properties(): void - { - $property = class_find_property(TypedSubject::class, 'static'); - - static::assertNotNull($property); - static::assertTrue($property->isStatic()); - static::assertSame('static', $property->getValue()); - } - - public static function provideDeclaredProperties(): iterable - { - yield 'own public' => [TypedSubject::class, 'public', TypedSubject::class, new TypedSubject(), 'public']; - yield 'own private' => [TypedSubject::class, 'private', TypedSubject::class, new TypedSubject(), 'private']; - yield 'inherited protected' => [TypedSubject::class, 'inherited', AbstractSecretParent::class, new TypedSubject(), 'parent-protected']; - yield 'private in parent' => [TypedSubject::class, 'secret', AbstractSecretParent::class, new TypedSubject(), 'parent-private']; - yield 'private in parent of a dynamic class' => [DynamicSecretSubject::class, 'secret', AbstractSecretParent::class, new DynamicSecretSubject(), 'parent-private']; - yield 'declared on a dynamic class' => [DynamicSubject::class, 'declared', DynamicSubject::class, new DynamicSubject(), 'declared']; - } - - public static function provideUndeclaredProperties(): iterable - { - yield 'undeclared' => [TypedSubject::class, 'nope']; - yield 'undeclared with a parent' => [DynamicSecretSubject::class, 'nope']; - yield 'undeclared on a dynamic class' => [DynamicSubject::class, 'extra']; - yield 'stdClass' => [stdClass::class, 'a']; - } -}