diff --git a/NEWS b/NEWS index 8427273c9d9a5..738054cb95921 100644 --- a/NEWS +++ b/NEWS @@ -53,6 +53,7 @@ PHP NEWS evaluation) and GH-18464 (Recursion protection for deprecation constants not released on bailout). (DanielEScherzer and ilutov) . Fixed AST printing for immediately invoked Closure. (Dmitrii Derepko) + . Property hooks are now allowed on backed readonly properties. (Crell, NickSdot and iluuu1994) - Curl: . Added curl_multi_get_handles(). (timwolla) diff --git a/UPGRADING b/UPGRADING index 8f8b7e7685e2a..ed9ece336939b 100644 --- a/UPGRADING +++ b/UPGRADING @@ -144,6 +144,8 @@ PHP 8.5 UPGRADE NOTES RFC: https://wiki.php.net/rfc/attributes-on-constants . The #[\Deprecated] attribute can now be used on constants. RFC: https://wiki.php.net/rfc/attributes-on-constants + . Property hooks are now allowed on backed readonly properties. + RFC: https://wiki.php.net/rfc/readonly_hooks - Curl: . Added support for share handles that are persisted across multiple PHP diff --git a/Zend/tests/property_hooks/gh15419_1.phpt b/Zend/tests/property_hooks/gh15419_1.phpt deleted file mode 100644 index 41a45154f1fde..0000000000000 --- a/Zend/tests/property_hooks/gh15419_1.phpt +++ /dev/null @@ -1,12 +0,0 @@ ---TEST-- -GH-15419: Readonly classes may not declare properties with hooks ---FILE-- - $value; } -} - -?> ---EXPECTF-- -Fatal error: Hooked properties cannot be readonly in %s on line %d diff --git a/Zend/tests/property_hooks/gh15419_2.phpt b/Zend/tests/property_hooks/gh15419_2.phpt deleted file mode 100644 index dfa6490fdc0cd..0000000000000 --- a/Zend/tests/property_hooks/gh15419_2.phpt +++ /dev/null @@ -1,14 +0,0 @@ ---TEST-- -GH-15419: Readonly classes may not declare promoted properties with hooks ---FILE-- - $value; }, - ) {} -} - -?> ---EXPECTF-- -Fatal error: Hooked properties cannot be readonly in %s on line %d diff --git a/Zend/tests/property_hooks/readonly.phpt b/Zend/tests/property_hooks/readonly.phpt deleted file mode 100644 index be68bc800576e..0000000000000 --- a/Zend/tests/property_hooks/readonly.phpt +++ /dev/null @@ -1,12 +0,0 @@ ---TEST-- -Hooked properties cannot be readonly ---FILE-- - ---EXPECTF-- -Fatal error: Hooked properties cannot be readonly in %s on line %d diff --git a/Zend/tests/property_hooks/readonly_class_property_backed.phpt b/Zend/tests/property_hooks/readonly_class_property_backed.phpt new file mode 100644 index 0000000000000..eab7079eec470 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_class_property_backed.phpt @@ -0,0 +1,41 @@ +--TEST-- +Backed property in readonly class may have hooks +--FILE-- + $this->prop; + set => $value; + } + + public function __construct(int $v) { + $this->prop = $v; + } + + public function set($v) + { + $this->prop = $v; + } +} + +$t = new Test(42); +var_dump($t->prop); +try { + $t->set(43); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +try { + $t->prop = 43; +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +var_dump($t->prop); +?> +--EXPECT-- +int(42) +Error: Cannot modify readonly property Test::$prop +Error: Cannot modify protected(set) readonly property Test::$prop from global scope +int(42) diff --git a/Zend/tests/property_hooks/readonly_class_property_backed_promoted.phpt b/Zend/tests/property_hooks/readonly_class_property_backed_promoted.phpt new file mode 100644 index 0000000000000..54a2529128571 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_class_property_backed_promoted.phpt @@ -0,0 +1,39 @@ +--TEST-- +Backed promoted property in readonly class may have hooks +--FILE-- + $this->prop; + set => $value; + } + ) {} + + public function set($v) + { + $this->prop = $v; + } +} + +$t = new Test(42); +var_dump($t->prop); +try { + $t->set(43); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +try { + $t->prop = 43; +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +var_dump($t->prop); +?> +--EXPECT-- +int(42) +Error: Cannot modify readonly property Test::$prop +Error: Cannot modify protected(set) readonly property Test::$prop from global scope +int(42) diff --git a/Zend/tests/property_hooks/readonly_class_property_virtual_promoted.phpt b/Zend/tests/property_hooks/readonly_class_property_virtual_promoted.phpt new file mode 100644 index 0000000000000..cc67d6fc12d6c --- /dev/null +++ b/Zend/tests/property_hooks/readonly_class_property_virtual_promoted.phpt @@ -0,0 +1,16 @@ +--TEST-- +Virtual promoted property in readonly class cannot have hooks +--FILE-- + 42; + } + ) {} +} + +?> +--EXPECTF-- +Fatal error: Hooked virtual properties may not be declared readonly in %s on line %d diff --git a/Zend/tests/property_hooks/readonly_property_backed.phpt b/Zend/tests/property_hooks/readonly_property_backed.phpt new file mode 100644 index 0000000000000..9ea8a955a0892 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_property_backed.phpt @@ -0,0 +1,108 @@ +--TEST-- +Backed readonly property may have hooks +--FILE-- + $this->prop; + set => $value; + } + + public function __construct(int $v) { + $this->prop = $v; + } + + public function set($v) + { + $this->prop = $v; + } +} + +$t = new Test(42); +var_dump($t->prop); +try { + $t->set(43); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +try { + $t->prop = 43; +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +var_dump($t->prop); + +// class readonly +final readonly class Foo +{ + public function __construct( + public array $values { + set(array $value) => array_map(strtoupper(...), $value); + }, + ) {} +} + +// property readonly +final class Foo2 +{ + public function __construct( + public readonly array $values { + set(array $value) => array_map(strtoupper(...), $value); + }, + ) {} +} + +// redundant readonly +final readonly class Foo3 +{ + public function __construct( + public readonly array $values { + set(array $value) => array_map(strtoupper(...), $value); + get => $this->makeNicer($this->values); + }, + ) {} + + public function makeNicer(array $entries): array + { + return array_map( + fn($i, $entry) => $entry . strtoupper(['', 'r', 'st'][$i]), array_keys($entries), + $entries + ); + } +} + +\var_dump(new Foo(['yo,', 'you', 'can'])->values); +\var_dump(new Foo2(['just', 'do', 'things'])->values); +\var_dump(new Foo3(['nice', 'nice', 'nice'])->values); +?> +--EXPECT-- +int(42) +Error: Cannot modify readonly property Test::$prop +Error: Cannot modify protected(set) readonly property Test::$prop from global scope +int(42) +array(3) { + [0]=> + string(3) "YO," + [1]=> + string(3) "YOU" + [2]=> + string(3) "CAN" +} +array(3) { + [0]=> + string(4) "JUST" + [1]=> + string(2) "DO" + [2]=> + string(6) "THINGS" +} +array(3) { + [0]=> + string(4) "NICE" + [1]=> + string(5) "NICER" + [2]=> + string(6) "NICEST" +} diff --git a/Zend/tests/property_hooks/readonly_property_backed_inheritance_1.phpt b/Zend/tests/property_hooks/readonly_property_backed_inheritance_1.phpt new file mode 100644 index 0000000000000..915c93656bbbf --- /dev/null +++ b/Zend/tests/property_hooks/readonly_property_backed_inheritance_1.phpt @@ -0,0 +1,21 @@ +--TEST-- +Backed property cannot redeclare readonly as non-readonly property +--FILE-- + $this->prop; + set => $value; + } + ) {} +} + +?> +--EXPECTF-- +Fatal error: Cannot redeclare readonly property ParentClass::$prop as non-readonly Test::$prop %s on line %d diff --git a/Zend/tests/property_hooks/readonly_property_backed_inheritance_2.phpt b/Zend/tests/property_hooks/readonly_property_backed_inheritance_2.phpt new file mode 100644 index 0000000000000..2c45f88056331 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_property_backed_inheritance_2.phpt @@ -0,0 +1,21 @@ +--TEST-- +Backed property cannot redeclare non-readonly as readonly property +--FILE-- + $this->prop; + set => $value; + } + ) {} +} + +?> +--EXPECTF-- +Fatal error: Cannot redeclare non-readonly property ParentClass::$prop as readonly Test::$prop in %s on line %d diff --git a/Zend/tests/property_hooks/readonly_property_backed_inheritance_3.phpt b/Zend/tests/property_hooks/readonly_property_backed_inheritance_3.phpt new file mode 100644 index 0000000000000..4959b202f669f --- /dev/null +++ b/Zend/tests/property_hooks/readonly_property_backed_inheritance_3.phpt @@ -0,0 +1,97 @@ +--TEST-- +Backed readonly property get() in child class behaves as expected +--FILE-- +prop}\n"; + var_dump($this); + return $this->prop; + } +} + +class ChildClass extends ParentClass { + + public readonly int $prop { + get { + echo 'In ChildClass::$prop::get():' . "\n"; + echo ' parent::$prop::get(): ' . parent::$prop::get() . "\n"; + echo ' $this->prop: ' . $this->prop . "\n"; + echo ' $this->prop * 2: ' . $this->prop * 2 . "\n"; + return $this->prop * 2; + } + set => $value; + } + + public function setAgain() { + $this->prop = 42; + } +} + +$t = new ChildClass(911); + +echo "\nFirst call:\n"; +$t->prop; + +echo "\nFirst call didn't change state:\n"; +$t->prop; + +echo "\nUnderlying value never touched:\n"; +var_dump($t); + +echo "\nCalling scope is child, hitting child get() and child state expected:\n"; +$t->getParentValue(); + +try { + $t->setAgain(); // cannot write, readonly +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +try { + $t->prop = 43; // cannot write, visibility +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +First call: +In ChildClass::$prop::get(): + parent::$prop::get(): 911 + $this->prop: 911 + $this->prop * 2: 1822 + +First call didn't change state: +In ChildClass::$prop::get(): + parent::$prop::get(): 911 + $this->prop: 911 + $this->prop * 2: 1822 + +Underlying value never touched: +object(ChildClass)#1 (1) { + ["prop"]=> + int(911) +} + +Calling scope is child, hitting child get() and child state expected: +In ChildClass::$prop::get(): + parent::$prop::get(): 911 + $this->prop: 911 + $this->prop * 2: 1822 +ParentClass::getParentValue(): 1822 +object(ChildClass)#1 (1) { + ["prop"]=> + int(911) +} +In ChildClass::$prop::get(): + parent::$prop::get(): 911 + $this->prop: 911 + $this->prop * 2: 1822 +Error: Cannot modify readonly property ChildClass::$prop +Error: Cannot modify protected(set) readonly property ChildClass::$prop from global scope diff --git a/Zend/tests/property_hooks/readonly_property_backed_promoted.phpt b/Zend/tests/property_hooks/readonly_property_backed_promoted.phpt new file mode 100644 index 0000000000000..8ec3a3f2fc9e9 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_property_backed_promoted.phpt @@ -0,0 +1,39 @@ +--TEST-- +Backed promoted readonly property may have hooks +--FILE-- + $this->prop; + set => $value; + } + ) {} + + public function set($v) + { + $this->prop = $v; + } +} + +$t = new Test(42); +var_dump($t->prop); +try { + $t->set(43); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +try { + $t->prop = 43; +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +var_dump($t->prop); +?> +--EXPECT-- +int(42) +Error: Cannot modify readonly property Test::$prop +Error: Cannot modify protected(set) readonly property Test::$prop from global scope +int(42) diff --git a/Zend/tests/property_hooks/readonly_property_virtual_in_abstract.phpt b/Zend/tests/property_hooks/readonly_property_virtual_in_abstract.phpt new file mode 100644 index 0000000000000..bbb4d52299d0b --- /dev/null +++ b/Zend/tests/property_hooks/readonly_property_virtual_in_abstract.phpt @@ -0,0 +1,11 @@ +--TEST-- +Hooked properties in abstract classes cannot be readonly +--FILE-- + +--EXPECTF-- +Fatal error: Hooked properties in abstract classes may not be declared readonly in %s on line %d diff --git a/Zend/tests/property_hooks/readonly_property_virtual_in_class.phpt b/Zend/tests/property_hooks/readonly_property_virtual_in_class.phpt new file mode 100644 index 0000000000000..5fe63a4875566 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_property_virtual_in_class.phpt @@ -0,0 +1,13 @@ +--TEST-- +Virtual readonly property in class throws +--FILE-- + 42; + } +} +?> +--EXPECTF-- +Fatal error: Hooked virtual properties may not be declared readonly in %s on line %d diff --git a/Zend/tests/property_hooks/readonly_property_virtual_in_interface.phpt b/Zend/tests/property_hooks/readonly_property_virtual_in_interface.phpt new file mode 100644 index 0000000000000..3380f25a32af9 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_property_virtual_in_interface.phpt @@ -0,0 +1,11 @@ +--TEST-- +Interface properties cannot be readonly +--FILE-- + +--EXPECTF-- +Fatal error: Interface properties may not be declared readonly in %s on line %d diff --git a/Zend/tests/property_hooks/readonly_rfc_example_lazy_product.phpt b/Zend/tests/property_hooks/readonly_rfc_example_lazy_product.phpt new file mode 100644 index 0000000000000..4bc1c66806348 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_rfc_example_lazy_product.phpt @@ -0,0 +1,105 @@ +--TEST-- +Readonly classes can be constructed via reflection by ORM +--FILE-- +category ??= $this->dbApi->loadCategory($this->categoryId); + } + } +} + +$reflect = new ReflectionClass(LazyProduct::class); +$product = $reflect->newInstanceWithoutConstructor(); + +$nameProperty = $reflect->getProperty('name'); +$nameProperty->setAccessible(true); +$nameProperty->setValue($product, 'Iced Chocolate'); + +$priceProperty = $reflect->getProperty('price'); +$priceProperty->setAccessible(true); +$priceProperty->setValue($product, 1.99); + +$db = $reflect->getProperty('dbApi'); +$db->setAccessible(true); +$db->setValue($product, new MockDbConnection()); + +$categoryId = $reflect->getProperty('categoryId'); +$categoryId->setAccessible(true); +$categoryId->setValue($product, '42'); + +// lazy loading, hit db +$category1 = $product->category; +echo $category1->name . "\n"; + +// cached category returned +$category2 = $product->category; +echo $category2->name . "\n"; + +// same category instance returned +var_dump($category1 === $category2); + +// can't be wrong, huh? +var_dump($product); + +// cannot set twice +try { + $categoryId->setValue($product, '420'); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +hit database +Category 42 +Category 42 +bool(true) +object(LazyProduct)#2 (5) { + ["name"]=> + string(14) "Iced Chocolate" + ["price"]=> + float(1.99) + ["category"]=> + object(Category)#8 (1) { + ["name"]=> + string(11) "Category 42" + } + ["dbApi":"LazyProduct":private]=> + object(MockDbConnection)#6 (0) { + } + ["categoryId":"LazyProduct":private]=> + string(2) "42" +} +Error: Cannot modify readonly property LazyProduct::$categoryId diff --git a/Zend/tests/property_hooks/readonly_rfc_example_validation.phpt b/Zend/tests/property_hooks/readonly_rfc_example_validation.phpt new file mode 100644 index 0000000000000..2f0f95c8d34e3 --- /dev/null +++ b/Zend/tests/property_hooks/readonly_rfc_example_validation.phpt @@ -0,0 +1,32 @@ +--TEST-- +Readonly property hook validation +--FILE-- + $value > 0 ? $value : throw new \Error('Value must be greater 0'); }, + public int $y { set => $value > 0 ? $value : throw new \Error('Value must be greater 0'); }, + ) {} +} + +$one = new PositivePoint(1,1); +var_dump($one); + +try { + $two = new PositivePoint(0,1); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + + +?> +--EXPECTF-- +object(PositivePoint)#1 (2) { + ["x"]=> + int(1) + ["y"]=> + int(1) +} +Error: Value must be greater 0 diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 0669d106f15e9..bf5a461fb5fda 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -8497,8 +8497,18 @@ static void zend_compile_property_hooks( { zend_class_entry *ce = CG(active_class_entry); - if (prop_info->flags & ZEND_ACC_READONLY) { - zend_error_noreturn(E_COMPILE_ERROR, "Hooked properties cannot be readonly"); + /* Allow hooks on backed readonly properties only. */ + if ((prop_info->flags & (ZEND_ACC_READONLY|ZEND_ACC_VIRTUAL)) == (ZEND_ACC_READONLY|ZEND_ACC_VIRTUAL)) { + + if (ce->ce_flags & ZEND_ACC_INTERFACE) { + zend_error_noreturn(E_COMPILE_ERROR, "Interface properties may not be declared readonly"); + } + + if (ce->ce_flags & (ZEND_ACC_IMPLICIT_ABSTRACT_CLASS|ZEND_ACC_EXPLICIT_ABSTRACT_CLASS)) { + zend_error_noreturn(E_COMPILE_ERROR, "Hooked properties in abstract classes may not be declared readonly"); + } + + zend_error_noreturn(E_COMPILE_ERROR, "Hooked virtual properties may not be declared readonly"); } if (hooks->children == 0) {