From 70aaed79d6ad3c474349e32ed4611a0ca1b8e78b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 29 May 2026 15:08:20 +0200 Subject: [PATCH 1/2] Fix GH-12695: skip __get() when __isset() materialised the property After __isset() returns true under ?? or empty(), re-check the property table before calling __get(). When __isset() materialised the property (a pattern used by lazy proxies), its value is returned directly. isset() itself is unchanged. Closes GH-12695. --- NEWS | 2 + UPGRADING | 5 ++ Zend/tests/magic_methods/gh12695.phpt | 65 +++++++++++++++++++ Zend/tests/magic_methods/gh12695_empty.phpt | 55 ++++++++++++++++ .../gh12695_isset_unchanged.phpt | 26 ++++++++ .../gh12695_no_materialization.phpt | 65 +++++++++++++++++++ .../gh12695_object_released_in_isset.phpt | 28 ++++++++ Zend/zend_object_handlers.c | 44 ++++++++++++- 8 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 Zend/tests/magic_methods/gh12695.phpt create mode 100644 Zend/tests/magic_methods/gh12695_empty.phpt create mode 100644 Zend/tests/magic_methods/gh12695_isset_unchanged.phpt create mode 100644 Zend/tests/magic_methods/gh12695_no_materialization.phpt create mode 100644 Zend/tests/magic_methods/gh12695_object_released_in_isset.phpt diff --git a/NEWS b/NEWS index 23212414d361..a63ee2c83dc9 100644 --- a/NEWS +++ b/NEWS @@ -21,6 +21,8 @@ PHP NEWS . Deprecate specifying a nullable return type for __debugInfo(). (timwolla) . Fixed bug GH-22142 (Assertion failure in zendi_try_get_long() on IS_UNDEF). (David Carlier) + . Fixed GH-12695 (Wrong magic methods sequence with ?? operator). + (nicolas-grekas) - BCMath: . Added NUL-byte validation to BCMath functions. (jorgsowa) diff --git a/UPGRADING b/UPGRADING index 3d6c89e90e05..fd8de8eb45dc 100644 --- a/UPGRADING +++ b/UPGRADING @@ -19,6 +19,11 @@ PHP 8.6 UPGRADE NOTES 1. Backward Incompatible Changes ======================================== +- Core: + . ??/empty() on a magic property no longer call __get() when __isset() + has materialised the property by writing into the property table. + The freshly-written value is returned directly. isset() is unaffected. + - DOM: . Properties previously documented as @readonly (e.g. DOMNode::$nodeType, DOMDocument::$xmlEncoding, DOMEntity::$actualEncoding, ::$encoding, diff --git a/Zend/tests/magic_methods/gh12695.phpt b/Zend/tests/magic_methods/gh12695.phpt new file mode 100644 index 000000000000..6a4f9d94ee71 --- /dev/null +++ b/Zend/tests/magic_methods/gh12695.phpt @@ -0,0 +1,65 @@ +--TEST-- +GH-12695: __get() is not called under `??` when __isset() materialised the property +--FILE-- +$n = 123; + return true; + } +} + +echo "Dynamic property materialised in __isset, then `??`:\n"; +$a = new A; +var_dump($a->foo ?? 'fallback'); + +echo "\nSame on a declared (unset) property:\n"; +class B { + public int $x = 99; + public function __get($n) { + throw new Exception("__get must not be called when __isset materialised the property"); + } + public function __isset($n) { + echo " __isset($n)\n"; + $this->$n = 7; + return true; + } +} +$b = new B; +unset($b->x); +var_dump($b->x ?? 'fallback'); + +echo "\nWhen __isset() materialises the property to null, `??` falls back:\n"; +#[AllowDynamicProperties] +class D { + public function __get($n) { + throw new Exception("__get must not be called when __isset materialised the property"); + } + public function __isset($n) { + echo " __isset($n)\n"; + $this->$n = null; + return true; + } +} +$d = new D; +var_dump($d->foo ?? 'fallback'); + +?> +--EXPECT-- +Dynamic property materialised in __isset, then `??`: + __isset(foo) +int(123) + +Same on a declared (unset) property: + __isset(x) +int(7) + +When __isset() materialises the property to null, `??` falls back: + __isset(foo) +string(8) "fallback" diff --git a/Zend/tests/magic_methods/gh12695_empty.phpt b/Zend/tests/magic_methods/gh12695_empty.phpt new file mode 100644 index 000000000000..064ea02e20e8 --- /dev/null +++ b/Zend/tests/magic_methods/gh12695_empty.phpt @@ -0,0 +1,55 @@ +--TEST-- +GH-12695: empty() also benefits from the post-__isset() materialization re-check +--FILE-- +$n = $GLOBALS['next_value']; + return true; + } +} + +echo "1) empty() when __isset materialised a truthy value: __get is not called, empty=false:\n"; +$GLOBALS['next_value'] = 7; +$a = new A; +var_dump(empty($a->foo)); + +echo "\n2) empty() when __isset materialised a falsy value: __get is not called, empty=true:\n"; +$GLOBALS['next_value'] = 0; +$a = new A; +var_dump(empty($a->bar)); + +echo "\n3) empty() with no materialization: __get is still called (legacy path preserved):\n"; +class B { + public function __get($n) { + echo " __get($n)\n"; + return 'value'; + } + public function __isset($n) { + echo " __isset($n)\n"; + return true; + } +} +$b = new B; +var_dump(empty($b->any)); + +?> +--EXPECT-- +1) empty() when __isset materialised a truthy value: __get is not called, empty=false: + __isset(foo) +bool(false) + +2) empty() when __isset materialised a falsy value: __get is not called, empty=true: + __isset(bar) +bool(true) + +3) empty() with no materialization: __get is still called (legacy path preserved): + __isset(any) + __get(any) +bool(false) diff --git a/Zend/tests/magic_methods/gh12695_isset_unchanged.phpt b/Zend/tests/magic_methods/gh12695_isset_unchanged.phpt new file mode 100644 index 000000000000..41b933d5a74a --- /dev/null +++ b/Zend/tests/magic_methods/gh12695_isset_unchanged.phpt @@ -0,0 +1,26 @@ +--TEST-- +GH-12695: isset() itself is unchanged (still does not consult __get) +--FILE-- +any)); + +?> +--EXPECT-- + __isset(any) +bool(true) diff --git a/Zend/tests/magic_methods/gh12695_no_materialization.phpt b/Zend/tests/magic_methods/gh12695_no_materialization.phpt new file mode 100644 index 000000000000..0cd12b5cac87 --- /dev/null +++ b/Zend/tests/magic_methods/gh12695_no_materialization.phpt @@ -0,0 +1,65 @@ +--TEST-- +GH-12695: when __isset() does not materialise the property, __get() is still called +--FILE-- +any ?? 'fallback'); + +echo "\n2) `??` when __isset=true and __get returns null: __get is called and fallback is used:\n"; +class D { + public function __get($n) { + echo " __get($n)\n"; + return null; + } + public function __isset($n) { + echo " __isset($n)\n"; + return true; + } +} +$d = new D; +var_dump($d->any ?? 'fallback'); + +echo "\n3) `??` when __isset returns false: __get is not called:\n"; +class E { + public function __get($n) { + throw new Exception("__get must not be called when __isset returned false"); + } + public function __isset($n) { + echo " __isset($n)\n"; + return false; + } +} +$e = new E; +var_dump($e->any ?? 'fallback'); + +?> +--EXPECT-- +1) `??` when __isset=true and __get returns a value: __get is called: + __isset(any) + __get(any) +string(8) "from-get" + +2) `??` when __isset=true and __get returns null: __get is called and fallback is used: + __isset(any) + __get(any) +string(8) "fallback" + +3) `??` when __isset returns false: __get is not called: + __isset(any) +string(8) "fallback" diff --git a/Zend/tests/magic_methods/gh12695_object_released_in_isset.phpt b/Zend/tests/magic_methods/gh12695_object_released_in_isset.phpt new file mode 100644 index 000000000000..ed5bc1a39aa3 --- /dev/null +++ b/Zend/tests/magic_methods/gh12695_object_released_in_isset.phpt @@ -0,0 +1,28 @@ +--TEST-- +GH-12695: object freed by __isset() during materialization +--FILE-- +prop = 'materialised'; + return true; + } +} + +$obj = new C(); +unset($obj->prop); +var_dump($obj->prop ?? 'fb'); + +?> +--EXPECT-- +string(12) "materialised" diff --git a/Zend/zend_object_handlers.c b/Zend/zend_object_handlers.c index e7ddd466a51a..373a93315cc5 100644 --- a/Zend/zend_object_handlers.c +++ b/Zend/zend_object_handlers.c @@ -934,6 +934,35 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int } zval_ptr_dtor(&tmp_result); + + /* __isset() may have materialised the property by writing into + * the property table. Re-check it before deferring to __get(), + * so the freshly-written value is returned directly without a + * redundant __get() call (GH-12695). The value is copied into + * `rv` because the property table can be freed by the OBJ_RELEASE + * below (e.g. when __isset() drops the last external reference + * to the object). */ + if (IS_VALID_PROPERTY_OFFSET(property_offset)) { + retval = OBJ_PROP(zobj, property_offset); + if (Z_TYPE_P(retval) != IS_UNDEF) { + ZVAL_COPY(rv, retval); + retval = rv; + OBJ_RELEASE(zobj); + goto exit; + } + } else if (IS_DYNAMIC_PROPERTY_OFFSET(property_offset)) { + if (zobj->properties != NULL) { + retval = zend_hash_find(zobj->properties, name); + if (retval) { + ZVAL_COPY(rv, retval); + retval = rv; + OBJ_RELEASE(zobj); + goto exit; + } + } + } + retval = &EG(uninitialized_zval); + if (zobj->ce->__get && !((*guard) & IN_GET)) { goto call_getter; } @@ -2487,7 +2516,20 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has result = zend_is_true(&rv); zval_ptr_dtor(&rv); if (has_set_exists == ZEND_PROPERTY_NOT_EMPTY && result) { - if (EXPECTED(!EG(exception)) && zobj->ce->__get && !((*guard) & IN_GET)) { + /* GH-12695, see above. */ + zval *prop = NULL; + if (IS_VALID_PROPERTY_OFFSET(property_offset)) { + prop = OBJ_PROP(zobj, property_offset); + if (Z_TYPE_P(prop) == IS_UNDEF) { + prop = NULL; + } + } else if (IS_DYNAMIC_PROPERTY_OFFSET(property_offset) + && zobj->properties != NULL) { + prop = zend_hash_find(zobj->properties, name); + } + if (prop) { + result = i_zend_is_true(prop); + } else if (EXPECTED(!EG(exception)) && zobj->ce->__get && !((*guard) & IN_GET)) { (*guard) |= IN_GET; zend_std_call_getter(zobj, name, &rv); (*guard) &= ~IN_GET; From d39e2795cba46d2822c9036c4457bec87d946645 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 27 Apr 2026 08:40:51 +0200 Subject: [PATCH 2/2] Add support for `__exists()`, a magic method for distinguishing "missing" from "set to null" --- .../magic_methods/exists/basic_coalesce.phpt | 34 +++++ .../exists/creates_property_in_exists.phpt | 59 ++++++++ .../exists/direct_call_null_vs_missing.phpt | 43 ++++++ .../exists/empty_uses_exists.phpt | 58 ++++++++ .../magic_methods/exists/enum_disallowed.phpt | 12 ++ .../exists/exists_supersedes_isset.phpt | 44 ++++++ .../magic_methods/exists/inheritance.phpt | 44 ++++++ .../exists/interface_declaration.phpt | 35 +++++ .../exists/interface_enforcement.phpt | 20 +++ .../exists/isset_equivalence.phpt | 66 +++++++++ .../magic_methods/exists/no_get_defined.phpt | 38 +++++ .../exists/not_called_on_plain_read.phpt | 48 +++++++ .../exists/nullsafe_coalesce.phpt | 41 ++++++ .../exists/object_released_in_exists.phpt | 54 +++++++ .../exists/property_exists_unaffected.phpt | 42 ++++++ .../magic_methods/exists/recursion_guard.phpt | 27 ++++ .../recursion_guard_cross_property.phpt | 28 ++++ .../exists/signature_param_type.phpt | 11 ++ .../exists/signature_param_variance.phpt | 50 +++++++ .../exists/signature_return_must_be_bool.phpt | 11 ++ .../exists/signature_return_required.phpt | 11 ++ .../exists/signature_static.phpt | 11 ++ .../exists/typed_property_uninit.phpt | 50 +++++++ ...12695_typed_property_characterization.phpt | 52 +++++++ Zend/zend.h | 1 + Zend/zend_API.c | 17 +++ Zend/zend_API.h | 1 + Zend/zend_compile.c | 1 + Zend/zend_compile.h | 1 + Zend/zend_enum.c | 1 + Zend/zend_inheritance.c | 4 + Zend/zend_object_handlers.c | 132 +++++++++++++++--- ext/opcache/zend_file_cache.c | 2 + ext/opcache/zend_persist.c | 6 + ext/reflection/php_reflection.c | 5 +- 35 files changed, 1039 insertions(+), 21 deletions(-) create mode 100644 Zend/tests/magic_methods/exists/basic_coalesce.phpt create mode 100644 Zend/tests/magic_methods/exists/creates_property_in_exists.phpt create mode 100644 Zend/tests/magic_methods/exists/direct_call_null_vs_missing.phpt create mode 100644 Zend/tests/magic_methods/exists/empty_uses_exists.phpt create mode 100644 Zend/tests/magic_methods/exists/enum_disallowed.phpt create mode 100644 Zend/tests/magic_methods/exists/exists_supersedes_isset.phpt create mode 100644 Zend/tests/magic_methods/exists/inheritance.phpt create mode 100644 Zend/tests/magic_methods/exists/interface_declaration.phpt create mode 100644 Zend/tests/magic_methods/exists/interface_enforcement.phpt create mode 100644 Zend/tests/magic_methods/exists/isset_equivalence.phpt create mode 100644 Zend/tests/magic_methods/exists/no_get_defined.phpt create mode 100644 Zend/tests/magic_methods/exists/not_called_on_plain_read.phpt create mode 100644 Zend/tests/magic_methods/exists/nullsafe_coalesce.phpt create mode 100644 Zend/tests/magic_methods/exists/object_released_in_exists.phpt create mode 100644 Zend/tests/magic_methods/exists/property_exists_unaffected.phpt create mode 100644 Zend/tests/magic_methods/exists/recursion_guard.phpt create mode 100644 Zend/tests/magic_methods/exists/recursion_guard_cross_property.phpt create mode 100644 Zend/tests/magic_methods/exists/signature_param_type.phpt create mode 100644 Zend/tests/magic_methods/exists/signature_param_variance.phpt create mode 100644 Zend/tests/magic_methods/exists/signature_return_must_be_bool.phpt create mode 100644 Zend/tests/magic_methods/exists/signature_return_required.phpt create mode 100644 Zend/tests/magic_methods/exists/signature_static.phpt create mode 100644 Zend/tests/magic_methods/exists/typed_property_uninit.phpt create mode 100644 Zend/tests/magic_methods/gh12695_typed_property_characterization.phpt diff --git a/Zend/tests/magic_methods/exists/basic_coalesce.phpt b/Zend/tests/magic_methods/exists/basic_coalesce.phpt new file mode 100644 index 000000000000..c9c81fc3defa --- /dev/null +++ b/Zend/tests/magic_methods/exists/basic_coalesce.phpt @@ -0,0 +1,34 @@ +--TEST-- +__exists: `??` calls __exists then __get only if __exists returned true +--FILE-- +present ?? 'fallback'); + +echo "\n2) `??` when __exists returns false: only __exists, fallback used:\n"; +$c = new C; +var_dump($c->absent ?? 'fallback'); + +?> +--EXPECT-- +1) `??` when __exists returns true: __exists then __get: + __exists(present) + __get(present) +string(16) "value-of-present" + +2) `??` when __exists returns false: only __exists, fallback used: + __exists(absent) +string(8) "fallback" diff --git a/Zend/tests/magic_methods/exists/creates_property_in_exists.phpt b/Zend/tests/magic_methods/exists/creates_property_in_exists.phpt new file mode 100644 index 000000000000..93aaa14ea8a5 --- /dev/null +++ b/Zend/tests/magic_methods/exists/creates_property_in_exists.phpt @@ -0,0 +1,59 @@ +--TEST-- +__exists: when __exists creates the property, __get is not called (fixes GH-12695) +--FILE-- +$n = 123; + return true; + } + public function __get(string $n): mixed { + throw new Exception("__get must not be called when __exists materialized the property"); + } +} + +echo "1) `??` when __exists creates the property: __get is skipped:\n"; +$c = new C; +var_dump($c->foo ?? 'fallback'); + +echo "\n2) Subsequent `??` reads the now-real property without any magic call:\n"; +var_dump($c->foo ?? 'fallback'); + +echo "\n3) isset() when __exists creates the property: __get is skipped (real prop, value 123 is non-null):\n"; +$c = new C; +var_dump(isset($c->foo)); + +echo "\n4) When __exists materialises the property to null, `??` falls back:\n"; +#[AllowDynamicProperties] +class D { + public function __exists(string $n): bool { + echo " __exists($n)\n"; + $this->$n = null; + return true; + } + public function __get(string $n): mixed { + throw new Exception("__get must not be called when __exists materialised the property"); + } +} +$d = new D; +var_dump($d->foo ?? 'fallback'); + +?> +--EXPECT-- +1) `??` when __exists creates the property: __get is skipped: + __exists(foo) +int(123) + +2) Subsequent `??` reads the now-real property without any magic call: +int(123) + +3) isset() when __exists creates the property: __get is skipped (real prop, value 123 is non-null): + __exists(foo) +bool(true) + +4) When __exists materialises the property to null, `??` falls back: + __exists(foo) +string(8) "fallback" diff --git a/Zend/tests/magic_methods/exists/direct_call_null_vs_missing.phpt b/Zend/tests/magic_methods/exists/direct_call_null_vs_missing.phpt new file mode 100644 index 000000000000..c5d252f64c2a --- /dev/null +++ b/Zend/tests/magic_methods/exists/direct_call_null_vs_missing.phpt @@ -0,0 +1,43 @@ +--TEST-- +__exists: directly callable as a method to disambiguate "set to null" from "missing" +--FILE-- + null, 'intProp' => 5]; + public function __exists(string $n): bool { + return array_key_exists($n, $this->store); + } + public function __get(string $n): mixed { + return $this->store[$n] ?? null; + } +} + +$c = new C; + +echo "1) Direct call: `nullProp` exists (set to null):\n"; +var_dump($c->__exists('nullProp')); + +echo "\n2) Direct call: `missing` does not exist:\n"; +var_dump($c->__exists('missing')); + +echo "\n3) But isset() collapses both to `false` (legacy isset semantics):\n"; +var_dump(isset($c->nullProp)); +var_dump(isset($c->missing)); + +?> +--EXPECT-- +1) Direct call: `nullProp` exists (set to null): +bool(true) + +2) Direct call: `missing` does not exist: +bool(false) + +3) But isset() collapses both to `false` (legacy isset semantics): +bool(false) +bool(false) diff --git a/Zend/tests/magic_methods/exists/empty_uses_exists.phpt b/Zend/tests/magic_methods/exists/empty_uses_exists.phpt new file mode 100644 index 000000000000..515f4d4be2ac --- /dev/null +++ b/Zend/tests/magic_methods/exists/empty_uses_exists.phpt @@ -0,0 +1,58 @@ +--TEST-- +__exists: empty() short-circuits on __exists=false; otherwise calls __get for truthiness +--FILE-- +hasIt = $hasIt; + $this->stored = $stored; + } + public function __exists(string $n): bool { + echo " __exists($n)\n"; + return $this->hasIt; + } + public function __get(string $n): mixed { + echo " __get($n)\n"; + return $this->stored; + } +} + +echo "1) empty() when __exists=false: only __exists, returns true:\n"; +$c = new C(false); +var_dump(empty($c->any)); + +echo "\n2) empty() when __exists=true and __get returns null: returns true:\n"; +$c = new C(true, null); +var_dump(empty($c->any)); + +echo "\n3) empty() when __exists=true and __get returns '': returns true:\n"; +$c = new C(true, ''); +var_dump(empty($c->any)); + +echo "\n4) empty() when __exists=true and __get returns 'x': returns false:\n"; +$c = new C(true, 'x'); +var_dump(empty($c->any)); + +?> +--EXPECT-- +1) empty() when __exists=false: only __exists, returns true: + __exists(any) +bool(true) + +2) empty() when __exists=true and __get returns null: returns true: + __exists(any) + __get(any) +bool(true) + +3) empty() when __exists=true and __get returns '': returns true: + __exists(any) + __get(any) +bool(true) + +4) empty() when __exists=true and __get returns 'x': returns false: + __exists(any) + __get(any) +bool(false) diff --git a/Zend/tests/magic_methods/exists/enum_disallowed.phpt b/Zend/tests/magic_methods/exists/enum_disallowed.phpt new file mode 100644 index 000000000000..cfb6503de561 --- /dev/null +++ b/Zend/tests/magic_methods/exists/enum_disallowed.phpt @@ -0,0 +1,12 @@ +--TEST-- +__exists: disallowed on enums (mirrors __isset) +--FILE-- + +--EXPECTF-- +Fatal error: Enum E cannot include magic method __exists in %s on line %d diff --git a/Zend/tests/magic_methods/exists/exists_supersedes_isset.phpt b/Zend/tests/magic_methods/exists/exists_supersedes_isset.phpt new file mode 100644 index 000000000000..aaa3ea9f99a0 --- /dev/null +++ b/Zend/tests/magic_methods/exists/exists_supersedes_isset.phpt @@ -0,0 +1,44 @@ +--TEST-- +__exists: when both __exists and __isset are defined, __exists wins; __isset is never called +--FILE-- +x ?? 'fb'); + +echo "\n2) isset():\n"; +$c = new C; var_dump(isset($c->x)); + +echo "\n3) empty():\n"; +$c = new C; var_dump(empty($c->x)); + +?> +--EXPECT-- +1) `??`: + __exists(x) + __get(x) +string(1) "g" + +2) isset(): + __exists(x) + __get(x) +bool(true) + +3) empty(): + __exists(x) + __get(x) +bool(false) diff --git a/Zend/tests/magic_methods/exists/inheritance.phpt b/Zend/tests/magic_methods/exists/inheritance.phpt new file mode 100644 index 000000000000..40d558f67bd1 --- /dev/null +++ b/Zend/tests/magic_methods/exists/inheritance.phpt @@ -0,0 +1,44 @@ +--TEST-- +__exists: inheritance, child __exists overrides parent __isset +--FILE-- +present ?? 'fb'); +var_dump($c->absent ?? 'fb'); + +echo "\n2) isset() on child: uses Child::__exists + Parent::__get:\n"; +$c = new Child; +var_dump(isset($c->present)); + +?> +--EXPECT-- +1) `??` on child: uses Child::__exists, ignores Parent::__isset: + Child::__exists(present) + Parent::__get(present) +string(10) "g(present)" + Child::__exists(absent) +string(2) "fb" + +2) isset() on child: uses Child::__exists + Parent::__get: + Child::__exists(present) + Parent::__get(present) +bool(true) diff --git a/Zend/tests/magic_methods/exists/interface_declaration.phpt b/Zend/tests/magic_methods/exists/interface_declaration.phpt new file mode 100644 index 000000000000..fd8f6ffb292c --- /dev/null +++ b/Zend/tests/magic_methods/exists/interface_declaration.phpt @@ -0,0 +1,35 @@ +--TEST-- +__exists: allowed in interfaces, no special engine treatment +--FILE-- +present ?? 'fb'); +var_dump($c->absent ?? 'fb'); +var_dump($c instanceof I); + +?> +--EXPECT-- + C::__exists(present) +string(16) "value-of-present" + C::__exists(absent) +string(2) "fb" +bool(true) diff --git a/Zend/tests/magic_methods/exists/interface_enforcement.phpt b/Zend/tests/magic_methods/exists/interface_enforcement.phpt new file mode 100644 index 000000000000..b8f97a613322 --- /dev/null +++ b/Zend/tests/magic_methods/exists/interface_enforcement.phpt @@ -0,0 +1,20 @@ +--TEST-- +__exists: interface contract is enforced like any other method (missing implementation fatals) +--FILE-- + +--EXPECTF-- +Fatal error: Class C contains 1 abstract method and must therefore be declared abstract or implement the remaining method (I::__exists) in %s on line %d diff --git a/Zend/tests/magic_methods/exists/isset_equivalence.phpt b/Zend/tests/magic_methods/exists/isset_equivalence.phpt new file mode 100644 index 000000000000..141cd4f3c7bb --- /dev/null +++ b/Zend/tests/magic_methods/exists/isset_equivalence.phpt @@ -0,0 +1,66 @@ +--TEST-- +__exists: isset() distinguishes "missing" from "set to null" (and is `??`-equivalent) +--FILE-- +store = $store; } + public function __exists(string $n): bool { + echo " __exists($n)\n"; + return array_key_exists($n, $this->store); + } + public function __get(string $n): mixed { + echo " __get($n)\n"; + return $this->store[$n] ?? null; + } +} + +echo "1) isset() when __exists=false: only __exists, returns false:\n"; +$c = new C([]); +var_dump(isset($c->any)); + +echo "\n2) isset() when __exists=true and __get returns non-null: returns true:\n"; +$c = new C(['x' => 5]); +var_dump(isset($c->x)); + +echo "\n3) isset() when __exists=true and __get returns null: returns false (matches normal isset):\n"; +$c = new C(['x' => null]); +var_dump(isset($c->x)); + +echo "\n4) `??` agrees with isset() in all three cases:\n"; +$c = new C([]); var_dump($c->any ?? 'fallback'); +$c = new C(['x' => 5]); var_dump($c->x ?? 'fallback'); +$c = new C(['x' => null]); var_dump($c->x ?? 'fallback'); + +?> +--EXPECT-- +1) isset() when __exists=false: only __exists, returns false: + __exists(any) +bool(false) + +2) isset() when __exists=true and __get returns non-null: returns true: + __exists(x) + __get(x) +bool(true) + +3) isset() when __exists=true and __get returns null: returns false (matches normal isset): + __exists(x) + __get(x) +bool(false) + +4) `??` agrees with isset() in all three cases: + __exists(any) +string(8) "fallback" + __exists(x) + __get(x) +int(5) + __exists(x) + __get(x) +string(8) "fallback" diff --git a/Zend/tests/magic_methods/exists/no_get_defined.phpt b/Zend/tests/magic_methods/exists/no_get_defined.phpt new file mode 100644 index 000000000000..d09435465546 --- /dev/null +++ b/Zend/tests/magic_methods/exists/no_get_defined.phpt @@ -0,0 +1,38 @@ +--TEST-- +__exists: without __get, a true return falls through to default property access (warning + null) +--FILE-- +x ?? 'fb'); + +echo "\n2) __exists=true, no __get, but __exists materialized the property:\n"; +class E { + public int $x = 0; + public function __exists(string $n): bool { + echo " __exists($n)\n"; + $this->$n = 7; + return true; + } +} +$e = new E; +unset($e->x); +var_dump($e->x ?? 'fb'); + +?> +--EXPECTF-- +1) `??` with __exists=true and no __get: returns null, so the fallback is used: + __exists(x) +string(2) "fb" + +2) __exists=true, no __get, but __exists materialized the property: + __exists(x) +int(7) diff --git a/Zend/tests/magic_methods/exists/not_called_on_plain_read.phpt b/Zend/tests/magic_methods/exists/not_called_on_plain_read.phpt new file mode 100644 index 000000000000..7f7e711a82aa --- /dev/null +++ b/Zend/tests/magic_methods/exists/not_called_on_plain_read.phpt @@ -0,0 +1,48 @@ +--TEST-- +__exists: not consulted on a plain read ($obj->prop); only BP_VAR_IS contexts trigger it +--FILE-- +prop must go straight to __get without consulting + * __exists, otherwise classes would pay an extra magic call on every read. */ + +class C { + public function __exists(string $n): bool { + echo " __exists($n)\n"; + return true; + } + public function __get(string $n): mixed { + echo " __get($n)\n"; + return "value-of-$n"; + } +} + +echo "1) Plain read \$c->prop calls __get only:\n"; +$c = new C; +$x = $c->foo; +var_dump($x); + +echo "\n2) Plain read in assignment + var_dump: __get only:\n"; +$c = new C; +$y = $c->bar; +var_dump($y); + +echo "\n3) Same property, BP_VAR_IS context: __exists is consulted (contrast):\n"; +$c = new C; +var_dump($c->baz ?? 'fb'); + +?> +--EXPECT-- +1) Plain read $c->prop calls __get only: + __get(foo) +string(12) "value-of-foo" + +2) Plain read in assignment + var_dump: __get only: + __get(bar) +string(12) "value-of-bar" + +3) Same property, BP_VAR_IS context: __exists is consulted (contrast): + __exists(baz) + __get(baz) +string(12) "value-of-baz" diff --git a/Zend/tests/magic_methods/exists/nullsafe_coalesce.phpt b/Zend/tests/magic_methods/exists/nullsafe_coalesce.phpt new file mode 100644 index 000000000000..2a3536fecd71 --- /dev/null +++ b/Zend/tests/magic_methods/exists/nullsafe_coalesce.phpt @@ -0,0 +1,41 @@ +--TEST-- +__exists: nullsafe + ?? follows the same sequence as ?? +--FILE-- +present ?? 'fallback'); + +echo "\n2) Nullsafe + ?? when __exists=false: only __exists, fallback used:\n"; +$c = new C; +var_dump($c?->absent ?? 'fallback'); + +echo "\n3) Nullsafe + ?? on a null receiver short-circuits before __exists:\n"; +$c = null; +var_dump($c?->present ?? 'fallback'); + +?> +--EXPECT-- +1) Nullsafe + ?? when __exists=true: __exists then __get: + __exists(present) + __get(present) +string(16) "value-of-present" + +2) Nullsafe + ?? when __exists=false: only __exists, fallback used: + __exists(absent) +string(8) "fallback" + +3) Nullsafe + ?? on a null receiver short-circuits before __exists: +string(8) "fallback" diff --git a/Zend/tests/magic_methods/exists/object_released_in_exists.phpt b/Zend/tests/magic_methods/exists/object_released_in_exists.phpt new file mode 100644 index 000000000000..c3d2203273f2 --- /dev/null +++ b/Zend/tests/magic_methods/exists/object_released_in_exists.phpt @@ -0,0 +1,54 @@ +--TEST-- +__exists: object freed by __exists() during materialization (??, isset, empty) +--FILE-- +prop = 'materialised'; + return true; + } + public function __get(string $n): mixed { + throw new Exception("__get must not be called when __exists materialised the property"); + } +} + +echo "1) `??`:\n"; +$obj = new C(); +unset($obj->prop); +var_dump($obj->prop ?? 'fb'); + +echo "\n2) isset():\n"; +$obj = new C(); +unset($obj->prop); +var_dump(isset($obj->prop)); + +echo "\n3) empty():\n"; +$obj = new C(); +unset($obj->prop); +var_dump(empty($obj->prop)); + +?> +--EXPECT-- +1) `??`: +string(12) "materialised" + +2) isset(): +bool(true) + +3) empty(): +bool(false) diff --git a/Zend/tests/magic_methods/exists/property_exists_unaffected.phpt b/Zend/tests/magic_methods/exists/property_exists_unaffected.phpt new file mode 100644 index 000000000000..24170a2f1ea4 --- /dev/null +++ b/Zend/tests/magic_methods/exists/property_exists_unaffected.phpt @@ -0,0 +1,42 @@ +--TEST-- +__exists: property_exists() is not affected by __exists (mirrors __isset behaviour) +--FILE-- +__exists($name) directly. */ + +class C { + public int $declared = 1; + public function __exists(string $n): bool { + echo " __exists($n) MUST NOT be called\n"; + return true; + } + public function __get(string $n): mixed { return null; } +} + +$c = new C; + +echo "1) property_exists() on a declared property: true (no magic call):\n"; +var_dump(property_exists($c, 'declared')); + +echo "\n2) property_exists() on a non-existent property: false (no magic call):\n"; +var_dump(property_exists($c, 'missing')); + +echo "\n3) property_exists(class-string, ...) accepts class shape only:\n"; +var_dump(property_exists('C', 'declared')); +var_dump(property_exists('C', 'missing')); + +?> +--EXPECT-- +1) property_exists() on a declared property: true (no magic call): +bool(true) + +2) property_exists() on a non-existent property: false (no magic call): +bool(false) + +3) property_exists(class-string, ...) accepts class shape only: +bool(true) +bool(false) diff --git a/Zend/tests/magic_methods/exists/recursion_guard.phpt b/Zend/tests/magic_methods/exists/recursion_guard.phpt new file mode 100644 index 000000000000..63ef24e53bb2 --- /dev/null +++ b/Zend/tests/magic_methods/exists/recursion_guard.phpt @@ -0,0 +1,27 @@ +--TEST-- +__exists: recursion guard prevents infinite recursion when __exists checks the same property +--FILE-- +$n); + echo " __exists($n) inner isset = ", ($r ? 'true' : 'false'), "\n"; + return false; + } + public function __get(string $n): mixed { + echo " __get($n): should not be called when __exists returns false\n"; + return null; + } +} + +$c = new C; +var_dump($c->any ?? 'fb'); + +?> +--EXPECT-- + __exists(any) entry + __exists(any) inner isset = false +string(2) "fb" diff --git a/Zend/tests/magic_methods/exists/recursion_guard_cross_property.phpt b/Zend/tests/magic_methods/exists/recursion_guard_cross_property.phpt new file mode 100644 index 000000000000..1aa4eaa708c5 --- /dev/null +++ b/Zend/tests/magic_methods/exists/recursion_guard_cross_property.phpt @@ -0,0 +1,28 @@ +--TEST-- +__exists: recursion guard is per-property; checking a different property is allowed +--FILE-- +b should call __exists('b') for real. */ + +class C { + public function __exists(string $n): bool { + echo " __exists($n)\n"; + if ($n === 'a') { + isset($this->b); + } + return false; + } +} + +$c = new C; +var_dump(isset($c->a)); + +?> +--EXPECT-- + __exists(a) + __exists(b) +bool(false) diff --git a/Zend/tests/magic_methods/exists/signature_param_type.phpt b/Zend/tests/magic_methods/exists/signature_param_type.phpt new file mode 100644 index 000000000000..04bae81cdcb4 --- /dev/null +++ b/Zend/tests/magic_methods/exists/signature_param_type.phpt @@ -0,0 +1,11 @@ +--TEST-- +__exists: signature validation must take a single string param and return bool +--FILE-- + +--EXPECTF-- +Fatal error: WrongArgType::__exists(): Parameter #1 ($n) must be of type string when declared in %s on line %d diff --git a/Zend/tests/magic_methods/exists/signature_param_variance.phpt b/Zend/tests/magic_methods/exists/signature_param_variance.phpt new file mode 100644 index 000000000000..15b6e362cbf9 --- /dev/null +++ b/Zend/tests/magic_methods/exists/signature_param_variance.phpt @@ -0,0 +1,50 @@ +--TEST-- +__exists: parameter follows variance rules (untyped allowed; widened allowed in subclass) +--FILE-- +present ?? 'fb'); +var_dump($o->absent ?? 'fb'); + +class Parent_ { + public function __exists(string $n): bool { return false; } + public function __get(string $n): mixed { return "p-$n"; } +} +class Child extends Parent_ { + /* Contravariant widening: subclass accepts a wider input set than the parent. */ + public function __exists(string|int $n): bool { + echo " Child::__exists("; var_export($n); echo ")\n"; + return $n === 'present'; + } +} + +echo "\n2) Subclass may widen parameter type (contravariance):\n"; +$c = new Child; +var_dump($c->present ?? 'fb'); +var_dump($c->absent ?? 'fb'); + +?> +--EXPECT-- +1) Untyped parameter is allowed (mirrors __isset BC behaviour): + __exists('present') +string(16) "value-of-present" + __exists('absent') +string(2) "fb" + +2) Subclass may widen parameter type (contravariance): + Child::__exists('present') +string(9) "p-present" + Child::__exists('absent') +string(2) "fb" diff --git a/Zend/tests/magic_methods/exists/signature_return_must_be_bool.phpt b/Zend/tests/magic_methods/exists/signature_return_must_be_bool.phpt new file mode 100644 index 000000000000..a0c98c22c584 --- /dev/null +++ b/Zend/tests/magic_methods/exists/signature_return_must_be_bool.phpt @@ -0,0 +1,11 @@ +--TEST-- +__exists: return type must be bool +--FILE-- + +--EXPECTF-- +Fatal error: WrongReturnType::__exists(): Return type must be bool when declared in %s on line %d diff --git a/Zend/tests/magic_methods/exists/signature_return_required.phpt b/Zend/tests/magic_methods/exists/signature_return_required.phpt new file mode 100644 index 000000000000..4301f0bf6eeb --- /dev/null +++ b/Zend/tests/magic_methods/exists/signature_return_required.phpt @@ -0,0 +1,11 @@ +--TEST-- +__exists: return type must be declared as bool (no BC carve-out for new method) +--FILE-- + +--EXPECTF-- +Fatal error: UntypedReturn::__exists(): Return type must be bool in %s on line %d diff --git a/Zend/tests/magic_methods/exists/signature_static.phpt b/Zend/tests/magic_methods/exists/signature_static.phpt new file mode 100644 index 000000000000..0315e8e15b5a --- /dev/null +++ b/Zend/tests/magic_methods/exists/signature_static.phpt @@ -0,0 +1,11 @@ +--TEST-- +__exists: must not be static +--FILE-- + +--EXPECTF-- +Fatal error: Method StaticExists::__exists() cannot be static in %s on line %d diff --git a/Zend/tests/magic_methods/exists/typed_property_uninit.phpt b/Zend/tests/magic_methods/exists/typed_property_uninit.phpt new file mode 100644 index 000000000000..dd166a433a98 --- /dev/null +++ b/Zend/tests/magic_methods/exists/typed_property_uninit.phpt @@ -0,0 +1,50 @@ +--TEST-- +__exists: skipped for never-initialized typed properties (parity with __isset); unset() is the opt-in +--FILE-- +$n = 42; + return true; + } + public function __get(string $n): mixed { + throw new Exception("__get must not be called when __exists materialised the property"); + } +} + +echo "1) Never-initialized typed property: __exists is NOT called (parity with __isset):\n"; +$t = new T; +var_dump($t->x ?? 'fallback'); +var_dump(isset($t->x)); + +echo "\n2) After unset(), magic methods kick in and __exists materializes the property:\n"; +$t = new T; +unset($t->x); +var_dump($t->x ?? 'fallback'); +var_dump(isset($t->x)); + +echo "\n3) Subsequent access on the now-materialized property does not call any magic:\n"; +var_dump($t->x); + +?> +--EXPECT-- +1) Never-initialized typed property: __exists is NOT called (parity with __isset): +string(8) "fallback" +bool(false) + +2) After unset(), magic methods kick in and __exists materializes the property: + __exists(x) +int(42) +bool(true) + +3) Subsequent access on the now-materialized property does not call any magic: +int(42) diff --git a/Zend/tests/magic_methods/gh12695_typed_property_characterization.phpt b/Zend/tests/magic_methods/gh12695_typed_property_characterization.phpt new file mode 100644 index 000000000000..52994f6e4da0 --- /dev/null +++ b/Zend/tests/magic_methods/gh12695_typed_property_characterization.phpt @@ -0,0 +1,52 @@ +--TEST-- +GH-12695: Characterize current behavior with typed properties +--FILE-- +never ?? 'fallback'); + +echo "\n2) isset() on never-initialized typed property: __isset is skipped, returns false:\n"; +$t = new T; +var_dump(isset($t->never)); + +echo "\n3) `??` after explicit unset() on a typed property: __isset and __get are called:\n"; +$t = new T; +unset($t->explicitlyUnset); +var_dump($t->explicitlyUnset ?? 'fallback'); + +echo "\n4) isset() after explicit unset() on a typed property: only __isset is called:\n"; +$t = new T; +unset($t->explicitlyUnset); +var_dump(isset($t->explicitlyUnset)); + +?> +--EXPECT-- +1) `??` on never-initialized typed property: __isset is skipped, fallback used: +string(8) "fallback" + +2) isset() on never-initialized typed property: __isset is skipped, returns false: +bool(false) + +3) `??` after explicit unset() on a typed property: __isset and __get are called: + __isset(explicitlyUnset) + __get(explicitlyUnset) +int(42) + +4) isset() after explicit unset() on a typed property: only __isset is called: + __isset(explicitlyUnset) +bool(true) diff --git a/Zend/zend.h b/Zend/zend.h index 0d5303192b57..894a5260750e 100644 --- a/Zend/zend.h +++ b/Zend/zend.h @@ -181,6 +181,7 @@ struct _zend_class_entry { zend_function *__set; zend_function *__unset; zend_function *__isset; + zend_function *__exists; zend_function *__call; zend_function *__callstatic; zend_function *__tostring; diff --git a/Zend/zend_API.c b/Zend/zend_API.c index 65834adbafff..3be7d4a68d23 100644 --- a/Zend/zend_API.c +++ b/Zend/zend_API.c @@ -2803,6 +2803,20 @@ ZEND_API void zend_check_magic_method_implementation(const zend_class_entry *ce, zend_check_magic_method_public(ce, fptr); zend_check_magic_method_arg_type(0, ce, fptr, error_type, MAY_BE_STRING); zend_check_magic_method_return_type(ce, fptr, error_type, MAY_BE_BOOL); + } else if (zend_string_equals_literal(lcname, ZEND_EXISTS_FUNC_NAME)) { + zend_check_magic_method_args(1, ce, fptr, error_type); + zend_check_magic_method_non_static(ce, fptr, error_type); + zend_check_magic_method_public(ce, fptr); + zend_check_magic_method_arg_type(0, ce, fptr, error_type, MAY_BE_STRING); + /* Unlike __isset, __exists is a new magic method so the return + * type must be declared. Parameter typing follows the regular + * variance rules and may be omitted or widened in subclasses, + * mirroring __isset. */ + if (!(fptr->common.fn_flags & ZEND_ACC_HAS_RETURN_TYPE)) { + zend_error(error_type, "%s::%s(): Return type must be bool", + ZSTR_VAL(ce->name), ZSTR_VAL(fptr->common.function_name)); + } + zend_check_magic_method_return_type(ce, fptr, error_type, MAY_BE_BOOL); } else if (zend_string_equals_literal(lcname, ZEND_CALL_FUNC_NAME)) { zend_check_magic_method_args(2, ce, fptr, error_type); zend_check_magic_method_non_static(ce, fptr, error_type); @@ -2888,6 +2902,9 @@ ZEND_API void zend_add_magic_method(zend_class_entry *ce, zend_function *fptr, c } else if (zend_string_equals_literal(lcname, ZEND_ISSET_FUNC_NAME)) { ce->__isset = fptr; ce->ce_flags |= ZEND_ACC_USE_GUARDS; + } else if (zend_string_equals_literal(lcname, ZEND_EXISTS_FUNC_NAME)) { + ce->__exists = fptr; + ce->ce_flags |= ZEND_ACC_USE_GUARDS; } else if (zend_string_equals_literal(lcname, ZEND_CALLSTATIC_FUNC_NAME)) { ce->__callstatic = fptr; } else if (zend_string_equals_literal(lcname, ZEND_TOSTRING_FUNC_NAME)) { diff --git a/Zend/zend_API.h b/Zend/zend_API.h index 2487c8b632f2..60cad0829b44 100644 --- a/Zend/zend_API.h +++ b/Zend/zend_API.h @@ -306,6 +306,7 @@ typedef struct _zend_fcall_info_cache { class_container.__set = NULL; \ class_container.__unset = NULL; \ class_container.__isset = NULL; \ + class_container.__exists = NULL; \ class_container.__debugInfo = NULL; \ class_container.__serialize = NULL; \ class_container.__unserialize = NULL; \ diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 105f99d24171..3d646b6aa5e4 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -2099,6 +2099,7 @@ ZEND_API void zend_initialize_class_data(zend_class_entry *ce, bool nullify_hand ce->__set = NULL; ce->__unset = NULL; ce->__isset = NULL; + ce->__exists = NULL; ce->__call = NULL; ce->__callstatic = NULL; ce->__tostring = NULL; diff --git a/Zend/zend_compile.h b/Zend/zend_compile.h index 0e31332c97f0..78987361a819 100644 --- a/Zend/zend_compile.h +++ b/Zend/zend_compile.h @@ -1248,6 +1248,7 @@ END_EXTERN_C() #define ZEND_SET_FUNC_NAME "__set" #define ZEND_UNSET_FUNC_NAME "__unset" #define ZEND_ISSET_FUNC_NAME "__isset" +#define ZEND_EXISTS_FUNC_NAME "__exists" #define ZEND_CALL_FUNC_NAME "__call" #define ZEND_CALLSTATIC_FUNC_NAME "__callstatic" #define ZEND_TOSTRING_FUNC_NAME "__tostring" diff --git a/Zend/zend_enum.c b/Zend/zend_enum.c index a5091f6c1b6f..455e3aa44677 100644 --- a/Zend/zend_enum.c +++ b/Zend/zend_enum.c @@ -99,6 +99,7 @@ static void zend_verify_enum_magic_methods(const zend_class_entry *ce) ZEND_ENUM_DISALLOW_MAGIC_METHOD(__set, "__set"); ZEND_ENUM_DISALLOW_MAGIC_METHOD(__unset, "__unset"); ZEND_ENUM_DISALLOW_MAGIC_METHOD(__isset, "__isset"); + ZEND_ENUM_DISALLOW_MAGIC_METHOD(__exists, "__exists"); ZEND_ENUM_DISALLOW_MAGIC_METHOD(__tostring, "__toString"); ZEND_ENUM_DISALLOW_MAGIC_METHOD(__serialize, "__serialize"); ZEND_ENUM_DISALLOW_MAGIC_METHOD(__unserialize, "__unserialize"); diff --git a/Zend/zend_inheritance.c b/Zend/zend_inheritance.c index e85b4ea42250..33253c14bb75 100644 --- a/Zend/zend_inheritance.c +++ b/Zend/zend_inheritance.c @@ -155,6 +155,9 @@ static void do_inherit_parent_constructor(zend_class_entry *ce) /* {{{ */ if (EXPECTED(!ce->__isset)) { ce->__isset = parent->__isset; } + if (EXPECTED(!ce->__exists)) { + ce->__exists = parent->__exists; + } if (EXPECTED(!ce->__call)) { ce->__call = parent->__call; } @@ -3389,6 +3392,7 @@ static zend_class_entry *zend_lazy_class_load(const zend_class_entry *pce) zend_update_inherited_handler(__set); zend_update_inherited_handler(__call); zend_update_inherited_handler(__isset); + zend_update_inherited_handler(__exists); zend_update_inherited_handler(__unset); zend_update_inherited_handler(__tostring); zend_update_inherited_handler(__callstatic); diff --git a/Zend/zend_object_handlers.c b/Zend/zend_object_handlers.c index 373a93315cc5..b3ea18607c58 100644 --- a/Zend/zend_object_handlers.c +++ b/Zend/zend_object_handlers.c @@ -270,6 +270,14 @@ static void zend_std_call_issetter(zend_object *zobj, zend_string *prop_name, zv } /* }}} */ +static void zend_std_call_existser(zend_object *zobj, zend_string *prop_name, zval *retval) /* {{{ */ +{ + zval member; + ZVAL_STR(&member, prop_name); + zend_call_known_instance_method_with_1_params(zobj->ce->__exists, zobj, retval, &member); +} +/* }}} */ + static zend_always_inline bool is_derived_class(const zend_class_entry *child_class, const zend_class_entry *parent_class) /* {{{ */ { @@ -894,14 +902,15 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int /* For initialized lazy proxies: if the real instance's magic method * guard is already set for this property, we are inside a recursive - * call from the real instance's __get/__isset. Forward directly to - * the real instance to avoid double invocation. (GH-21478) */ + * call from the real instance's __get/__isset/__exists. Forward + * directly to the real instance to avoid double invocation. + * (GH-21478) */ if (UNEXPECTED(zend_object_is_lazy_proxy(zobj) && zend_lazy_object_initialized(zobj))) { zend_object *instance = zend_lazy_object_get_instance(zobj); if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) { uint32_t *instance_guard = zend_get_property_guard(instance, name); - uint32_t guard_type = ((type == BP_VAR_IS) && zobj->ce->__isset) + uint32_t guard_type = ((type == BP_VAR_IS) && (zobj->ce->__exists || zobj->ce->__isset)) ? IN_ISSET : IN_GET; if ((*instance_guard) & guard_type) { retval = zend_std_read_property(instance, name, type, cache_slot, rv); @@ -914,8 +923,63 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int } } - /* magic isset */ - if ((type == BP_VAR_IS) && zobj->ce->__isset) { + /* magic exists (when defined, preferred over __isset for `??`) */ + if ((type == BP_VAR_IS) && zobj->ce->__exists) { + zval tmp_result; + guard = zend_get_property_guard(zobj, name); + + if (!((*guard) & IN_ISSET)) { + GC_ADDREF(zobj); + + *guard |= IN_ISSET; + zend_std_call_existser(zobj, name, &tmp_result); + *guard &= ~IN_ISSET; + + if (!zend_is_true(&tmp_result)) { + retval = &EG(uninitialized_zval); + OBJ_RELEASE(zobj); + zval_ptr_dtor(&tmp_result); + goto exit; + } + + zval_ptr_dtor(&tmp_result); + + /* __exists() may have materialised the property by writing + * into the property table. Re-check it before deferring to + * __get(), so the freshly-written value is returned directly + * without a redundant __get() call. The value is copied into + * `rv` because the property table can be freed by the + * OBJ_RELEASE below (e.g. when __exists() drops the last + * external reference to the object). */ + if (IS_VALID_PROPERTY_OFFSET(property_offset)) { + retval = OBJ_PROP(zobj, property_offset); + if (Z_TYPE_P(retval) != IS_UNDEF) { + ZVAL_COPY(rv, retval); + retval = rv; + OBJ_RELEASE(zobj); + goto exit; + } + } else if (IS_DYNAMIC_PROPERTY_OFFSET(property_offset)) { + if (zobj->properties != NULL) { + retval = zend_hash_find(zobj->properties, name); + if (retval) { + ZVAL_COPY(rv, retval); + retval = rv; + OBJ_RELEASE(zobj); + goto exit; + } + } + } + retval = &EG(uninitialized_zval); + + if (zobj->ce->__get && !((*guard) & IN_GET)) { + goto call_getter; + } + OBJ_RELEASE(zobj); + } else if (zobj->ce->__get && !((*guard) & IN_GET)) { + goto call_getter_addref; + } + } else if ((type == BP_VAR_IS) && zobj->ce->__isset) { zval tmp_result; guard = zend_get_property_guard(zobj, name); @@ -1021,7 +1085,7 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int if (UNEXPECTED(guard && (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS))) { /* Find which guard was used on zobj, so we can set the same * guard on instance. */ - uint32_t guard_type = (type == BP_VAR_IS) && zobj->ce->__isset + uint32_t guard_type = (type == BP_VAR_IS) && (zobj->ce->__exists || zobj->ce->__isset) ? IN_ISSET : IN_GET; guard = zend_get_property_guard(instance, name); if (!((*guard) & guard_type)) { @@ -2484,9 +2548,10 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has goto exit; } - /* For initialized lazy proxies: if the real instance's __isset guard - * is already set, we are inside a recursive call from the real - * instance's __isset. Forward directly to avoid double invocation. */ + /* For initialized lazy proxies: if the real instance's __isset/__exists + * guard is already set, we are inside a recursive call from the real + * instance's __isset/__exists. Forward directly to avoid double + * invocation. */ if (UNEXPECTED(zend_object_is_lazy_proxy(zobj) && zend_lazy_object_initialized(zobj))) { zend_object *instance = zend_lazy_object_get_instance(zobj); @@ -2498,7 +2563,7 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has } } - if (!zobj->ce->__isset) { + if (!zobj->ce->__exists && !zobj->ce->__isset) { goto lazy_init; } @@ -2508,15 +2573,33 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has if (!((*guard) & IN_ISSET)) { zval rv; + bool use_exists = zobj->ce->__exists != NULL; - /* have issetter - try with it! */ GC_ADDREF(zobj); - (*guard) |= IN_ISSET; /* prevent circular getting */ - zend_std_call_issetter(zobj, name, &rv); + (*guard) |= IN_ISSET; /* prevent recursion */ + if (use_exists) { + zend_std_call_existser(zobj, name, &rv); + } else { + zend_std_call_issetter(zobj, name, &rv); + } result = zend_is_true(&rv); zval_ptr_dtor(&rv); - if (has_set_exists == ZEND_PROPERTY_NOT_EMPTY && result) { - /* GH-12695, see above. */ + + /* When the existence check returned true, we may need to + * consult the actual value: + * - empty(): always (for truthiness). + * - isset() with __exists: yes, apply non-null check + * (preserves the documented `isset() <=> ??` equivalence + * and disambiguates "set to null" from "missing"). + * - isset() with __isset: trust the bool. */ + bool consult_value = result && !EG(exception) + && (has_set_exists == ZEND_PROPERTY_NOT_EMPTY + || (use_exists && has_set_exists == ZEND_PROPERTY_ISSET)); + + if (consult_value) { + /* GH-12695: the existence check may have materialised the + * property by writing into the property table. Re-check it + * before deferring to __get(). */ zval *prop = NULL; if (IS_VALID_PROPERTY_OFFSET(property_offset)) { prop = OBJ_PROP(zobj, property_offset); @@ -2528,14 +2611,25 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has prop = zend_hash_find(zobj->properties, name); } if (prop) { - result = i_zend_is_true(prop); - } else if (EXPECTED(!EG(exception)) && zobj->ce->__get && !((*guard) & IN_GET)) { + if (has_set_exists == ZEND_PROPERTY_NOT_EMPTY) { + result = i_zend_is_true(prop); + } else { + ZVAL_DEREF(prop); + result = Z_TYPE_P(prop) != IS_NULL; + } + } else if (zobj->ce->__get && !((*guard) & IN_GET)) { (*guard) |= IN_GET; zend_std_call_getter(zobj, name, &rv); (*guard) &= ~IN_GET; - result = i_zend_is_true(&rv); + if (has_set_exists == ZEND_PROPERTY_NOT_EMPTY) { + result = i_zend_is_true(&rv); + } else { + result = Z_TYPE(rv) != IS_NULL + && (Z_TYPE(rv) != IS_REFERENCE || Z_TYPE_P(Z_REFVAL(rv)) != IS_NULL); + } zval_ptr_dtor(&rv); } else { + /* No __get available; treat the value as null. */ result = false; } } @@ -2558,7 +2652,7 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has goto exit; } - if (UNEXPECTED(zobj->ce->__isset)) { + if (UNEXPECTED(zobj->ce->__exists || zobj->ce->__isset)) { uint32_t *guard = zend_get_property_guard(zobj, name); if (!((*guard) & IN_ISSET)) { (*guard) |= IN_ISSET; diff --git a/ext/opcache/zend_file_cache.c b/ext/opcache/zend_file_cache.c index af59b9b2c34a..d46aa89b3ed6 100644 --- a/ext/opcache/zend_file_cache.c +++ b/ext/opcache/zend_file_cache.c @@ -938,6 +938,7 @@ static void zend_file_cache_serialize_class(zval *zv, SERIALIZE_PTR(ce->__serialize); SERIALIZE_PTR(ce->__unserialize); SERIALIZE_PTR(ce->__isset); + SERIALIZE_PTR(ce->__exists); SERIALIZE_PTR(ce->__unset); SERIALIZE_PTR(ce->__tostring); SERIALIZE_PTR(ce->__callstatic); @@ -1813,6 +1814,7 @@ static void zend_file_cache_unserialize_class(zval *zv, UNSERIALIZE_PTR(ce->__serialize); UNSERIALIZE_PTR(ce->__unserialize); UNSERIALIZE_PTR(ce->__isset); + UNSERIALIZE_PTR(ce->__exists); UNSERIALIZE_PTR(ce->__unset); UNSERIALIZE_PTR(ce->__tostring); UNSERIALIZE_PTR(ce->__callstatic); diff --git a/ext/opcache/zend_persist.c b/ext/opcache/zend_persist.c index c06452e6acf2..6ddec55421cd 100644 --- a/ext/opcache/zend_persist.c +++ b/ext/opcache/zend_persist.c @@ -1260,6 +1260,12 @@ void zend_update_parent_ce(zend_class_entry *ce) ce->__isset = tmp; } } + if (ce->__exists) { + zend_function *tmp = zend_shared_alloc_get_xlat_entry(ce->__exists); + if (tmp != NULL) { + ce->__exists = tmp; + } + } if (ce->__unset) { zend_function *tmp = zend_shared_alloc_get_xlat_entry(ce->__unset); if (tmp != NULL) { diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c index 50bcf4cb79c1..70af9efe13bd 100644 --- a/ext/reflection/php_reflection.c +++ b/ext/reflection/php_reflection.c @@ -6723,14 +6723,15 @@ ZEND_METHOD(ReflectionProperty, isReadable) } handle_magic_get: if (ce->__get) { - if (obj && ce->__isset) { + zend_function *check = ce->__exists ? ce->__exists : ce->__isset; + if (obj && check) { uint32_t *guard = zend_get_property_guard(obj, ref->unmangled_name); if (!((*guard) & ZEND_GUARD_PROPERTY_ISSET)) { GC_ADDREF(obj); *guard |= ZEND_GUARD_PROPERTY_ISSET; zval member; ZVAL_STR(&member, ref->unmangled_name); - zend_call_known_instance_method_with_1_params(ce->__isset, obj, return_value, &member); + zend_call_known_instance_method_with_1_params(check, obj, return_value, &member); if (Z_TYPE_P(return_value) == IS_REFERENCE) { zend_unwrap_reference(return_value);