Skip to content

Commit 36c7a35

Browse files
committed
:octocat: property hooks support (PHP 8.4+)
1 parent 21a6af2 commit 36c7a35

File tree

6 files changed

+301
-73
lines changed

6 files changed

+301
-73
lines changed

README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,14 +139,46 @@ $container->what = 'some value';
139139
var_dump($container->what); // -> hash: 5946210c9e93ae37891dfe96c3e39614 (custom getter added "hash: ")
140140
```
141141

142+
#### A note on property hooks (PHP 8.4+)
143+
144+
Property hooks are called whenever a property is accessed (except from within the hook itself of course), which means that the custom get/set methods this library allows would conflict when a custom method is defined for a property that also has a hook defined.
145+
To prevent double method calls, the internal methods `hasSetHook()` and `hasGetHook()` have been introduced, and are called whenever the magic get/set methods are called: when both, a custom method and a property hook exist, only the property hook will be called.
146+
<br/>Public properties will never call the magic get/set, however, their hooks *will* be called. (un)serializing a `SettingsContainerInterface` instance will bypass magic get/set and existing property hooks, while JSON de/encode as will call magic get/set or existing hooks explicitly via the `toArray()` and `fromIterable()` methods.
147+
148+
```php
149+
class PropertyHooksContainer extends SettingsContainerAbstract{
150+
151+
protected string $someValue{
152+
set => doStuff($value);
153+
}
154+
155+
// this method will be ignored in magic calls as a "set" hook on the property exists
156+
protected function set_someValue(string $value):void{
157+
$this->someValue = doOtherStuff($value);
158+
}
159+
160+
// this custom method will be called as the property has no "get" hook
161+
protected function get_someValue():string{
162+
return doWhatever($this->someValue);
163+
}
164+
165+
// this property will never trigger the magic get/set and associated methods
166+
public string $otherValue{
167+
set => doStuff($value);
168+
get => $this->otherValue;
169+
}
170+
171+
}
172+
```
173+
174+
142175
### API
143176

144177
#### [`SettingsContainerAbstract`](https://github.com/chillerlan/php-settings-container/blob/main/src/SettingsContainerAbstract.php)
145178

146179
| method | return | info |
147180
|--------------------------------------------|------------------------------|---------------------------------------------------------------------------------------------------------------------|
148181
| `__construct(iterable $properties = null)` | - | calls `construct()` internally after the properties have been set |
149-
| (protected) `construct()` | void | calls a method with trait name as replacement constructor for each used trait |
150182
| `__get(string $property)` | mixed | calls `$this->{'get_'.$property}()` if such a method exists |
151183
| `__set(string $property, $value)` | void | calls `$this->{'set_'.$property}($value)` if such a method exists |
152184
| `__isset(string $property)` | bool | |
@@ -162,6 +194,17 @@ var_dump($container->what); // -> hash: 5946210c9e93ae37891dfe96c3e39614 (custom
162194
| `__serialize()` | array | implements the [`Serializable`](https://www.php.net/manual/en/language.oop5.magic.php#object.serialize) interface |
163195
| `__unserialize(array $data)` | void | implements the [`Serializable`](https://www.php.net/manual/en/language.oop5.magic.php#object.unserialize) interface |
164196

197+
198+
#### Internal (protected) methods
199+
200+
| method | return | info |
201+
|--------------------------------------|--------|-------------------------------------------------------------------------------|
202+
| `construct()` | void | calls a method with trait name as replacement constructor for each used trait |
203+
| `isPrivate(string $property)` | bool | private properties are excluded from magic calls |
204+
| `hasSetHook(string $property)` | bool | |
205+
| `hasGetHook(string $property)` | bool | |
206+
207+
165208
## Disclaimer
166209
This might be either an absolutely brilliant or completely stupid idea - you decide. (in hindsight it was a great idea I guess - property hooks made their way into PHP 8.4)
167210
Also, this is not a dependency injection container. Stop using DI containers FFS.

phpstan-baseline.neon

Lines changed: 15 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,31 @@
11
parameters:
22
ignoreErrors:
33
-
4-
rawMessage: Access to an undefined property chillerlan\SettingsTest\TestContainer::$foo.
5-
identifier: property.notFound
4+
rawMessage: Access to constant Get on an unknown class PropertyHookType.
5+
identifier: class.notFound
66
count: 1
7-
path: tests/ContainerTest.php
7+
path: src/SettingsContainerAbstract.php
88

99
-
10-
rawMessage: Access to private property chillerlan\SettingsTest\TestContainer::$test3.
11-
identifier: property.private
12-
count: 5
13-
path: tests/ContainerTest.php
14-
15-
-
16-
rawMessage: 'Call to static method PHPUnit\Framework\Assert::assertFalse() with true will always evaluate to false.'
17-
identifier: staticMethod.impossibleType
18-
count: 1
19-
path: tests/ContainerTest.php
20-
21-
-
22-
rawMessage: 'Call to static method PHPUnit\Framework\Assert::assertFalse() with true will always evaluate to true.'
23-
identifier: staticMethod.alreadyNarrowedType
10+
rawMessage: Access to constant Set on an unknown class PropertyHookType.
11+
identifier: class.notFound
2412
count: 1
25-
path: tests/ContainerTest.php
13+
path: src/SettingsContainerAbstract.php
2614

2715
-
28-
rawMessage: 'Call to static method PHPUnit\Framework\Assert::assertNull() with string will always evaluate to false.'
29-
identifier: staticMethod.impossibleType
16+
rawMessage: 'Call to an undefined method ReflectionProperty::getRawValue().'
17+
identifier: method.notFound
3018
count: 2
31-
path: tests/ContainerTest.php
19+
path: src/SettingsContainerAbstract.php
3220

3321
-
34-
rawMessage: 'Call to static method PHPUnit\Framework\Assert::assertTrue() with true will always evaluate to true.'
35-
identifier: staticMethod.alreadyNarrowedType
36-
count: 1
37-
path: tests/ContainerTest.php
38-
39-
-
40-
rawMessage: 'Property chillerlan\SettingsTest\TestContainer::$test1 (string) in isset() is not nullable.'
41-
identifier: isset.property
22+
rawMessage: 'Call to an undefined method ReflectionProperty::hasHook().'
23+
identifier: method.notFound
4224
count: 2
43-
path: tests/ContainerTest.php
44-
45-
-
46-
rawMessage: 'Property chillerlan\SettingsTest\TestContainer::$test3 (string) in isset() is not nullable.'
47-
identifier: isset.property
48-
count: 1
49-
path: tests/ContainerTest.php
25+
path: src/SettingsContainerAbstract.php
5026

5127
-
52-
rawMessage: 'Property chillerlan\SettingsTest\TestContainer::$test3 is never read, only written.'
53-
identifier: property.onlyWritten
28+
rawMessage: 'Call to an undefined method ReflectionProperty::setRawValue().'
29+
identifier: method.notFound
5430
count: 1
55-
path: tests/TestContainer.php
31+
path: src/SettingsContainerAbstract.php

src/SettingsContainerAbstract.php

Lines changed: 83 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111

1212
namespace chillerlan\Settings;
1313

14-
use InvalidArgumentException, JsonException, ReflectionClass, ReflectionProperty;
15-
use function array_keys, get_object_vars, is_object, json_decode, json_encode,
16-
json_last_error_msg, method_exists, property_exists, serialize, unserialize;
14+
use InvalidArgumentException, JsonException, ReflectionException, ReflectionObject, ReflectionProperty;
15+
use function is_object, json_decode, json_encode, json_last_error_msg, method_exists, property_exists, serialize, unserialize;
1716
use const JSON_THROW_ON_ERROR;
17+
use const PHP_VERSION_ID;
1818

1919
abstract class SettingsContainerAbstract implements SettingsContainerInterface{
2020

@@ -40,7 +40,7 @@ public function __construct(iterable|null $properties = null){
4040
* (remember pre-php5 classname constructors? yeah, basically this.)
4141
*/
4242
protected function construct():void{
43-
$traits = (new ReflectionClass($this))->getTraits();
43+
$traits = (new ReflectionObject($this))->getTraits();
4444

4545
foreach($traits as $trait){
4646
$method = $trait->getShortName();
@@ -53,15 +53,15 @@ protected function construct():void{
5353
}
5454

5555
public function __get(string $property):mixed{
56-
56+
// back out if the property is inaccessible
5757
if(!property_exists($this, $property) || $this->isPrivate($property)){
5858
return null;
5959
}
60-
61-
if(method_exists($this, static::GET_PREFIX.$property)){
60+
// call an existing custom method, skip if the property has a hook
61+
if(method_exists($this, static::GET_PREFIX.$property) && !$this->hasGetHook($property)){
6262
return $this->{static::GET_PREFIX.$property}();
6363
}
64-
64+
// retrieve the value (triggers an existing property hook)
6565
return $this->{$property};
6666
}
6767

@@ -71,7 +71,7 @@ public function __set(string $property, mixed $value):void{
7171
return;
7272
}
7373

74-
if(method_exists($this, static::SET_PREFIX.$property)){
74+
if(method_exists($this, static::SET_PREFIX.$property) && !$this->hasSetHook($property)){
7575
$this->{static::SET_PREFIX.$property}($value);
7676

7777
return;
@@ -91,6 +91,30 @@ protected function isPrivate(string $property):bool{
9191
return (new ReflectionProperty($this, $property))->isPrivate();
9292
}
9393

94+
/**
95+
* @internal Checks if a property has a "set" hook
96+
*/
97+
protected function hasSetHook(string $property):bool{
98+
99+
if(PHP_VERSION_ID < 80400){
100+
return false;
101+
}
102+
/** @phan-suppress-next-line PhanUndeclaredMethod, PhanUndeclaredClassConstant */
103+
return (new ReflectionProperty($this, $property))->hasHook(\PropertyHookType::Set);
104+
}
105+
106+
/**
107+
* @internal Checks if a property has a "get" hook
108+
*/
109+
protected function hasGetHook(string $property):bool{
110+
111+
if(PHP_VERSION_ID < 80400){
112+
return false;
113+
}
114+
/** @phan-suppress-next-line PhanUndeclaredMethod, PhanUndeclaredClassConstant */
115+
return (new ReflectionProperty($this, $property))->hasHook(\PropertyHookType::Get);
116+
}
117+
94118
public function __unset(string $property):void{
95119

96120
if($this->__isset($property)){
@@ -104,13 +128,19 @@ public function __toString():string{
104128
}
105129

106130
public function toArray():array{
107-
$properties = [];
108131

109-
foreach(array_keys(get_object_vars($this)) as $key){
110-
$properties[$key] = $this->__get($key);
132+
$properties = (new ReflectionObject($this))
133+
->getProperties(~(ReflectionProperty::IS_STATIC | ReflectionProperty::IS_READONLY | ReflectionProperty::IS_PRIVATE))
134+
;
135+
136+
$data = [];
137+
138+
foreach($properties as $reflectionProperty){
139+
// the magic getter is called intentionally here, so that any existing hook methods are called on export
140+
$data[$reflectionProperty->name] = $this->__get($reflectionProperty->name);
111141
}
112142

113-
return $properties;
143+
return $data;
114144
}
115145

116146
/**
@@ -129,7 +159,7 @@ public function toJSON(int|null $jsonOptions = null):string{
129159
$json = json_encode($this, ($jsonOptions ?? 0));
130160

131161
if($json === false){
132-
throw new JsonException(json_last_error_msg());
162+
throw new JsonException(json_last_error_msg()); // @codeCoverageIgnore
133163
}
134164

135165
return $json;
@@ -151,8 +181,6 @@ public function jsonSerialize():array{
151181

152182
/**
153183
* Returns a serialized string representation of the object in its current state (except static/readonly properties)
154-
*
155-
* @see \chillerlan\Settings\SettingsContainerInterface::toArray()
156184
*/
157185
public function serialize():string{
158186
return serialize($this);
@@ -161,62 +189,88 @@ public function serialize():string{
161189
/**
162190
* Restores the data (except static/readonly properties) from the given serialized object to the current instance
163191
*
164-
* @see \chillerlan\Settings\SettingsContainerInterface::fromIterable()
165-
*
166192
* @throws \InvalidArgumentException
167193
*/
168194
public function unserialize(string $data):void{
169195
$obj = unserialize($data);
170196

171-
if($obj === false || !is_object($obj)){
197+
if(!is_object($obj)){
172198
throw new InvalidArgumentException('The given serialized string is invalid');
173199
}
174200

175-
$reflection = new ReflectionClass($obj);
201+
$reflection = new ReflectionObject($obj);
176202

177203
if(!$reflection->isInstance($this)){
178204
throw new InvalidArgumentException('The unserialized object does not match the class of this container');
179205
}
180206

181207
$properties = $reflection->getProperties(~(ReflectionProperty::IS_STATIC | ReflectionProperty::IS_READONLY));
208+
$data = [];
182209

183210
foreach($properties as $reflectionProperty){
184-
$this->{$reflectionProperty->name} = $reflectionProperty->getValue($obj);
211+
$data[$reflectionProperty->name] = (PHP_VERSION_ID < 80400)
212+
? $reflectionProperty->getValue($obj)
213+
/** @phan-suppress-next-line PhanUndeclaredMethod */
214+
: $reflectionProperty->getRawValue($obj);
185215
}
186216

217+
$this->__unserialize($data);
187218
}
188219

189220
/**
190-
* Returns a serialized string representation of the object in its current state (except static/readonly properties)
221+
* Returns a serialized array representation of the object in its current state (except static/readonly properties),
222+
* bypassing custom getters and property hooks
191223
*
192-
* @see \chillerlan\Settings\SettingsContainerInterface::toArray()
224+
* @return array<string, mixed>
193225
*/
194226
public function __serialize():array{
195227

196-
$properties = (new ReflectionClass($this))
228+
$properties = (new ReflectionObject($this))
197229
->getProperties(~(ReflectionProperty::IS_STATIC | ReflectionProperty::IS_READONLY))
198230
;
199231

200232
$data = [];
201233

202234
foreach($properties as $reflectionProperty){
203-
$data[$reflectionProperty->name] = $reflectionProperty->getValue($this);
235+
// bypass existing property hooks for PHP >= 8.4
236+
$data[$reflectionProperty->name] = (PHP_VERSION_ID < 80400)
237+
? $reflectionProperty->getValue($this)
238+
/** @phan-suppress-next-line PhanUndeclaredMethod */
239+
: $reflectionProperty->getRawValue($this);
204240
}
205241

206242
return $data;
207243
}
208244

209245
/**
210-
* Restores the data from the given array to the current instance
211-
*
212-
* @see \chillerlan\Settings\SettingsContainerInterface::fromIterable()
246+
* Restores the data from the given array to the current instance,
247+
* bypassing custom setters and property hooks
213248
*
214249
* @param array<string, mixed> $data
215250
*/
216251
public function __unserialize(array $data):void{
252+
$reflection = new ReflectionObject($this);
217253

218254
foreach($data as $key => $value){
219-
$this->{$key} = $value;
255+
try{
256+
$reflectionProperty = $reflection->getProperty($key);
257+
258+
if($reflectionProperty->isStatic() || $reflectionProperty->isReadOnly()){
259+
continue; // @codeCoverageIgnore
260+
}
261+
262+
(PHP_VERSION_ID < 80400)
263+
? $reflectionProperty->setValue($this, $value)
264+
/** @phan-suppress-next-line PhanUndeclaredMethod */
265+
: $reflectionProperty->setRawValue($this, $value);
266+
267+
}
268+
// @codeCoverageIgnoreStart
269+
catch(ReflectionException){
270+
// attempt to assign a non-existent property, skip
271+
continue;
272+
}
273+
// @codeCoverageIgnoreEnd
220274
}
221275

222276
}

tests/ContainerTest.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,18 +130,25 @@ public function testSerializable():void{
130130
'testConstruct' => 'success',
131131
]);
132132

133-
/** @var \chillerlan\SettingsTest\TestContainer $container */
134-
$container = unserialize(serialize($container)); // object should remain in the same state
135-
136133
// serialize will return the object in its current state including private properties
137134
$expected = 'O:37:"chillerlan\SettingsTest\TestContainer":7:{s:5:"test3";s:4:"what";s:5:"test1";s:2:"no";'.
138135
's:5:"test2";b:1;s:13:"testConstruct";s:7:"success";s:5:"test4";N;s:5:"test5";s:0:"";s:5:"test6";N;}';
139136

137+
$serialized = serialize($container);
138+
139+
$this::assertSame($expected, $serialized);
140+
141+
/** @var \chillerlan\SettingsTest\TestContainer $container */
142+
$container = unserialize($serialized); // object should remain in the same state
143+
140144
$this::assertSame($expected, $container->serialize());
141-
$this::assertSame($expected, serialize($container));
142145

146+
$container = (new TestContainer);
143147
$container->unserialize($expected);
144148

149+
$this::assertSame('no', $container->test1);
150+
$this::assertSame(true, $container->test2);
151+
$this::assertNull($container->test3);
145152
$this::assertSame('', $container->test5);
146153
}
147154

0 commit comments

Comments
 (0)