Skip to content
80 changes: 80 additions & 0 deletions benchmarks/OptionalLensBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php declare(strict_types=1);

namespace VeeWee\Reflecta\Benchmarks;

use Generator;
use PhpBench\Attributes\BeforeMethods;
use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\ParamProviders;
use PhpBench\Attributes\Revs;
use PhpBench\Attributes\Warmup;
use VeeWee\Reflecta\Benchmarks\Fixtures\Typed5;
use VeeWee\Reflecta\Lens\LensInterface;
use function VeeWee\Reflecta\Lens\index;
use function VeeWee\Reflecta\Lens\optional;
use function VeeWee\Reflecta\Lens\optional_index;
use function VeeWee\Reflecta\Lens\optional_property;
use function VeeWee\Reflecta\Lens\property;

#[Revs(1000)]
#[Iterations(10)]
#[Warmup(2)]
#[BeforeMethods('setUp')]
final class OptionalLensBench
{
private LensInterface $lens;

private mixed $subject;

/**
* @param array{lens: string, scenario: string} $params
*/
public function setUp(array $params): void
{
[$subject, $name] = match ($params['scenario']) {
'array_hit' => [['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];
}
}
}
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions docs/lens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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()`.

```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).
Expand Down
27 changes: 27 additions & 0 deletions src/Lens/optional_index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php declare(strict_types=1);

namespace VeeWee\Reflecta\Lens;

use function is_array;

/**
* @param array-key $index
* @return Lens<array|null, array|null, mixed, mixed>
* @psalm-pure
*/
function optional_index(string|int $index): Lens
{
$optional = optional(index($index));

/** @return Lens<array|null, array|null, mixed, mixed> */
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),
);
}
69 changes: 69 additions & 0 deletions src/Lens/optional_property.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php declare(strict_types=1);

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;

/**
* @template S of object
* @return Lens<S|null, S|null, mixed, mixed>
* @psalm-pure
*/
function optional_property(string $propertyName): Lens
{
/** @var Lens<S|null, S|null, mixed, mixed> $optional */
$optional = optional(property($propertyName));

return new Lens(
/**
* @param S|null $subject
*/
static function (mixed $subject) use ($propertyName): mixed {
if (!is_object($subject)) {
return null;
}

try {
// 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};
}

// 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;
}

// 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;
}
},
/**
* @param S|null $subject
* @return S|null
*/
static fn (mixed $subject, mixed $value): ?object => $optional->set($subject, $value),
);
}
10 changes: 10 additions & 0 deletions tests/fixtures/Optional/AbstractSecretParent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);

namespace VeeWee\Reflecta\TestFixtures\Optional;

abstract class AbstractSecretParent
{
protected string $inherited = 'parent-protected';
private string $secret = 'parent-private';
}
11 changes: 11 additions & 0 deletions tests/fixtures/Optional/DynamicSecretSubject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);

namespace VeeWee\Reflecta\TestFixtures\Optional;

use AllowDynamicProperties;

#[AllowDynamicProperties]
final class DynamicSecretSubject extends AbstractSecretParent
{
}
12 changes: 12 additions & 0 deletions tests/fixtures/Optional/DynamicSubject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);

namespace VeeWee\Reflecta\TestFixtures\Optional;

use AllowDynamicProperties;

#[AllowDynamicProperties]
final class DynamicSubject
{
public string $declared = 'declared';
}
21 changes: 21 additions & 0 deletions tests/fixtures/Optional/HookedSubject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);

namespace VeeWee\Reflecta\TestFixtures\Optional;

use RuntimeException;

final class HookedSubject
{
public string $hooked = 'raw' {
get => 'hooked:' . $this->hooked;
}

public string $virtual {
get => 'virtual';
}

public string $throwing {
get => throw new RuntimeException('Hook failed');
}
}
20 changes: 20 additions & 0 deletions tests/fixtures/Optional/MagicSubject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);

namespace VeeWee\Reflecta\TestFixtures\Optional;

final class MagicSubject
{
public string $unset = 'unset';
public string $uninitialized;

public function __construct()
{
unset($this->unset);
}

public function __get(string $name): string
{
return 'magic:' . $name;
}
}
24 changes: 24 additions & 0 deletions tests/fixtures/Optional/TypedSubject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);

namespace VeeWee\Reflecta\TestFixtures\Optional;

final class TypedSubject extends AbstractSecretParent
{
public static string $static = 'static';

public string $public = 'public';
protected string $protected = 'protected';
private string $private = 'private';
public readonly string $readonly;
public ?string $nullable = null;
public string $uninitialized;
public string $unset = 'unset';
public $untypedUnset = 'untyped';

public function __construct()
{
$this->readonly = 'readonly';
unset($this->unset, $this->untypedUnset);
}
}
Loading
Loading