diff --git a/docs/INTERFACE_PROPERTY_HOOKS.md b/docs/INTERFACE_PROPERTY_HOOKS.md index 59ae901a..3bb25e62 100644 --- a/docs/INTERFACE_PROPERTY_HOOKS.md +++ b/docs/INTERFACE_PROPERTY_HOOKS.md @@ -58,12 +58,12 @@ Interface Property Hook 不应伪装成普通属性或 lowering 后的普通方 ## 4. PHPX 与 Zend 元数据 -现有 `php::registerPropertyHooks()` 用于具有真实 AOT getter/setter 的具体类,不能复用于抽象 Interface Hook。 +现有 `typephp_register_property_hooks()` 用于具有真实 AOT getter/setter 的具体类,不能复用于抽象 Interface Hook。 PHPX 增加独立 helper: ```cpp -php::registerAbstractPropertyHooks( +typephp_register_abstract_property_hooks( zend_class_entry *interface_ce, zend_property_info *property_info, bool readable, diff --git a/docs/PROPERTY_HOOKS.md b/docs/PROPERTY_HOOKS.md index a2019dcc..33a16dfd 100644 --- a/docs/PROPERTY_HOOKS.md +++ b/docs/PROPERTY_HOOKS.md @@ -44,7 +44,7 @@ public string $name { `gen_stub.php` 声明属性并取得 `zend_property_info *` 后生成: ```cpp -php::registerPropertyHooks( +typephp_register_property_hooks( class_entry, property_info, getter_method_name, @@ -56,7 +56,7 @@ php::registerPropertyHooks( ## 3. PHPX 注册流程 -PHPX 的 `registerPropertyHooks()` 只在 PHP 8.4 及以上版本实现。 +PHPX 的 `typephp_register_property_hooks()` 只在 PHP 8.4 及以上版本实现,并位于 TypePHP 专用 helper 中。 ### 3.1 查找 AOT 实现方法 diff --git a/phpunit/code/inheritance_error_final_hooked_property.php b/phpunit/code/inheritance_error_final_hooked_property.php new file mode 100644 index 00000000..83aea512 --- /dev/null +++ b/phpunit/code/inheritance_error_final_hooked_property.php @@ -0,0 +1,15 @@ + 'parent'; + } +} + +class FinalHookedPropertyChild extends FinalHookedPropertyParent +{ + public string $value { + get => 'child'; + } +} diff --git a/phpunit/code/inheritance_error_final_property.php b/phpunit/code/inheritance_error_final_property.php new file mode 100644 index 00000000..792c7a79 --- /dev/null +++ b/phpunit/code/inheritance_error_final_property.php @@ -0,0 +1,11 @@ + 'parent'; + } +} + +class FinalPropertyHookChild extends FinalPropertyHookParent +{ + public string $value { + get => 'child'; + } +} diff --git a/phpunit/code/inheritance_error_private_set_property.php b/phpunit/code/inheritance_error_private_set_property.php new file mode 100644 index 00000000..b490d808 --- /dev/null +++ b/phpunit/code/inheritance_error_private_set_property.php @@ -0,0 +1,11 @@ + 'parent'; + set { + } + } +} + +class OverridePropertyValidChild extends OverridePropertyValidParent +{ + #[Override] + public string $plain = 'child'; + + #[\Override] + public string $hooked { + get => 'child'; + } + + public function __construct( + #[\Override] + public string $promoted = 'child', + ) { + } +} + +trait OverridePropertyValidTrait +{ + #[\Override] + public string $plain = 'parent'; +} + +class OverridePropertyValidTraitChild extends OverridePropertyValidParent +{ + use OverridePropertyValidTrait; +} diff --git a/phpunit/src/ClassTest.php b/phpunit/src/ClassTest.php index 748dfc88..c1b1c908 100644 --- a/phpunit/src/ClassTest.php +++ b/phpunit/src/ClassTest.php @@ -460,6 +460,46 @@ class ClassTest extends \BaseTest $this->compile('override-valid.php'); } + public function testOverrideAcceptsParentPropertiesIncludingPromotedAndHookedProperties(): void + { + $this->compile('override-property-valid.php'); + } + + public function testPropertyOverrideRequiresMatchingParentProperty(): void + { + $this->exec( + 'OverridePropertyMissing::$value has #[\\Override] attribute, but no matching parent class property exists', + 'override-property-missing.php', + ); + } + + public function testPropertyOverrideCannotHidePrivateParentProperty(): void + { + $this->exec( + 'Declaration of `OverridePropertyPrivateChild::$value` conflicts with private property ' + . '`OverridePropertyPrivateParent::$value`; property shadowing across inheritance is not allowed', + 'override-property-private-parent.php', + ); + } + + public function testPropertyOverrideIsRejectedOnInterfaceProperty(): void + { + $this->exec( + 'OverridePropertyInterface::$value has #[\\Override] attribute, ' + . 'but no matching parent class property exists', + 'override-property-interface.php', + ); + } + + public function testPropertyOverrideOnTraitIsValidatedAtUseSite(): void + { + $this->exec( + 'OverridePropertyTraitConsumer::$value has #[\\Override] attribute, ' + . 'but no matching parent class property exists', + 'override-property-trait-missing.php', + ); + } + public function testOverrideRequiresMatchingParentMethod(): void { $this->exec( @@ -500,10 +540,10 @@ class ClassTest extends \BaseTest ); } - public function testOverrideRejectsNonMethodTargets(): void + public function testOverrideRejectsNonMethodOrPropertyTargets(): void { $this->expectException(\TypePhp\Exception\SyntaxError::class); - $this->expectExceptionMessage('Override can only be applied to methods'); + $this->expectExceptionMessage('Override can only be applied to methods or properties'); $this->compile('override-invalid-target.php'); } diff --git a/phpunit/src/InheritanceErrorTest.php b/phpunit/src/InheritanceErrorTest.php index c65cb98d..4a4fab91 100644 --- a/phpunit/src/InheritanceErrorTest.php +++ b/phpunit/src/InheritanceErrorTest.php @@ -148,6 +148,38 @@ class InheritanceErrorTest extends TestCase $this->exec('Cannot override final method', 'inheritance_error_final_method.php'); } + public function testCannotOverrideFinalPropertyHook(): void + { + $this->exec( + 'Cannot override final property hook FinalPropertyHookParent::$value::get()', + 'inheritance_error_final_property_hook.php', + ); + } + + public function testCannotOverrideFinalProperty(): void + { + $this->exec( + 'Cannot override final property FinalPropertyParent::$value', + 'inheritance_error_final_property.php', + ); + } + + public function testCannotOverrideFinalHookedProperty(): void + { + $this->exec( + 'Cannot override final property FinalHookedPropertyParent::$value', + 'inheritance_error_final_hooked_property.php', + ); + } + + public function testPrivateSetPropertyIsImplicitlyFinal(): void + { + $this->exec( + 'Cannot override final property PrivateSetPropertyParent::$value', + 'inheritance_error_private_set_property.php', + ); + } + public function testInterfaceMethodStaticMismatch() { $this->exec('must be compatible', 'interface_method_static_mismatch.php'); diff --git a/phpunit/src/InterfacePropertyHookTest.php b/phpunit/src/InterfacePropertyHookTest.php index b14f723a..ba5dfe6a 100644 --- a/phpunit/src/InterfacePropertyHookTest.php +++ b/phpunit/src/InterfacePropertyHookTest.php @@ -76,6 +76,14 @@ final class InterfacePropertyHookTest extends TestCase ); } + public function testFinalInterfacePropertyIsRejected(): void + { + $this->assertCompileError( + 'interface_property_hook_final.php', + 'Property in interface cannot be final', + ); + } + public function testExplicitSetterParameterIsRejectedUntilItsIndependentTypeIsModeled(): void { $this->assertCompileError( diff --git a/phpunit/src/NativePropertyTest.php b/phpunit/src/NativePropertyTest.php index fcf61157..e8a9969d 100644 --- a/phpunit/src/NativePropertyTest.php +++ b/phpunit/src/NativePropertyTest.php @@ -162,6 +162,15 @@ class NativePropertyTest extends \BaseTest $this->exec('Cannot access private property `value` of class `NativePrivateOwner`', 'native-property-private-other-class.php'); } + public function testNativeClassCannotHideParentPrivateProperty(): void + { + $this->exec( + 'Declaration of `NativePrivateShadowChild::$value` conflicts with private property ' + . '`NativePrivateShadowParent::$value`; property shadowing across inheritance is not allowed', + 'native-property-private-shadow.php', + ); + } + public function testCannotAccessProtectedNativePropertyFromUnrelatedClass(): void { $this->exec('Cannot access protected property `value` of class `NativeProtectedOwner`', 'native-property-protected-unrelated-class.php'); diff --git a/phpunit/src/NegativeCompatibilityTest.php b/phpunit/src/NegativeCompatibilityTest.php index 0539d920..3a968a76 100644 --- a/phpunit/src/NegativeCompatibilityTest.php +++ b/phpunit/src/NegativeCompatibilityTest.php @@ -185,6 +185,20 @@ function main(): void PHP, ]; + yield 'property get hook reference return' => [ + 'prepare', + 'Property get hooks returning by reference are not supported', + <<<'PHP' + $this->value; // @diagnostic + } +} +PHP, + ]; + yield 'arrow function reference return' => [ 'convert', 'Closure and arrow functions cannot return by reference', diff --git a/src/Entity/PropertyDef.php b/src/Entity/PropertyDef.php index 331c34e3..c36891fa 100644 --- a/src/Entity/PropertyDef.php +++ b/src/Entity/PropertyDef.php @@ -8,6 +8,7 @@ namespace TypePhp\Entity; +use PhpParser\NodeAbstract; use PhpParser\Modifiers; use TypePhp\ArrayDef\ArrayDefinition; @@ -34,6 +35,9 @@ class PropertyDef public ?string $getter = null; public ?string $setter = null; public bool $virtual = false; + /** The source property carries TypePHP's compile-time #[Override] contract. */ + public bool $overrideRequired = false; + public ?NodeAbstract $node = null; public function __construct(string $name, int $flags, string $type, ?string $default = null, bool $nullable = false) { diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 2644a017..8b9d438b 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -17,9 +17,86 @@ use TypePhp\Exception\DynamicCall; use TypePhp\Exception\PlaceHolder; use TypePhp\Generator\Symbol; use TypePhp\Resolver\Reflection; +use TypePhp\Transform\PropertyHookLowering; trait MethodCallTrait { + private function parseParentPropertyHookCall(Expr\StaticCall $expr): ?string + { + if (!$expr->class instanceof Expr\StaticPropertyFetch + || !$expr->class->class instanceof Node\Name + || strtolower($expr->class->class->toString()) !== 'parent' + || !$expr->class->name instanceof Node\VarLikeIdentifier + || !$expr->name instanceof Node\Identifier + ) { + return null; + } + + $kind = strtolower($expr->name->toString()); + if ($kind !== 'get' && $kind !== 'set') { + return null; + } + if ($expr->isFirstClassCallable()) { + $this->fatalError($expr, 'Cannot create Closure for parent property hook call'); + } + + $property = $expr->class->name->toString(); + $activeHook = $this->methodDef?->node?->getAttribute(PropertyHookLowering::METHOD_ATTRIBUTE); + if (!is_array($activeHook)) { + $this->fatalError( + $expr, + "Must not use parent::\${$property}::{$kind}() outside a property hook", + ); + } + if (($activeHook['property'] ?? null) !== $property) { + $this->fatalError( + $expr, + "Must not use parent::\${$property}::{$kind}() in a different property (\$" + . ($activeHook['property'] ?? '') . ')', + ); + } + if (($activeHook['kind'] ?? null) !== $kind) { + $this->fatalError( + $expr, + "Must not use parent::\${$property}::{$kind}() in a different property hook (" + . ($activeHook['kind'] ?? '') . ')', + ); + } + if (!$this->classDef?->extends) { + $this->fatalError($expr, 'Cannot use "parent" when current class scope has no parent'); + } + + $parentClass = $this->classDef->extends; + $declaringClass = $parentClass; + $parentProperty = null; + while ($declaringClass !== '') { + $parentDef = $this->getClassDef($declaringClass); + if ($parentDef === null) { + break; + } + if ($parentDef->hasProperty($property)) { + $parentProperty = $parentDef->getProperty($property); + break; + } + $declaringClass = $parentDef->extends; + } + if ($parentProperty === null) { + $this->fatalError($expr, "Undefined property {$parentClass}::\${$property}"); + } + if ($parentProperty->isPrivate()) { + $this->fatalError($expr, "Cannot access private property {$declaringClass}::\${$property}"); + } + + $hookKind = $kind === 'get' ? 'ZEND_PROPERTY_HOOK_GET' : 'ZEND_PROPERTY_HOOK_SET'; + $function = 'typephp_get_parent_property_hook(' + . $this->getClassEntryPtr($parentClass) . ', ' + . $this->getLiteralString($property) . ', ' . $hookKind . ')'; + if ($expr->args === []) { + return 'this_.call(' . $function . ')'; + } + return 'this_.call(' . $function . ', ' . $this->parseCallArgs($expr->args) . ')'; + } + protected function runtimeMethodRequiresDynamicScope( string $class, string $method, @@ -756,6 +833,10 @@ trait MethodCallTrait protected function parseStaticCall(Expr\StaticCall $expr): string { $this->validateImmutableCall($expr); + $parentPropertyHookCall = $this->parseParentPropertyHookCall($expr); + if ($parentPropertyHookCall !== null) { + return $parentPropertyHookCall; + } if (!$this->isNameExpr($expr->class)) { $this->assertNotNativeObjectDynamicClassTarget($expr->class, $expr); } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 7a77132a..597e0efc 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -1454,6 +1454,11 @@ class Preprocessor extends CompilerBase } $propDef = new PropertyDef($name, $flags, $type, $default, $nullable); + $propDef->overrideRequired = (bool) $errorNode->getAttribute( + FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, + false, + ); + $propDef->node = $errorNode; if ($typeNode !== null && !$typeNode instanceof NullableType && !$typeNode instanceof UnionType @@ -1737,6 +1742,9 @@ class Preprocessor extends CompilerBase foreach ($v->hooks as $hook) { $kind = strtolower($hook->name->toString()); if ($kind === 'get') { + if ($hook->byRef) { + $this->fatalError($hook, 'Property get hooks returning by reference are not supported'); + } $propDef->getter = PropertyHookLowering::getterName($propName); } elseif ($kind === 'set') { $propDef->setter = PropertyHookLowering::setterName($propName); @@ -1958,6 +1966,9 @@ class Preprocessor extends CompilerBase if ($property->flags & (Modifiers::PRIVATE | Modifiers::PROTECTED)) { $this->fatalError($property, 'Property in interface cannot be protected or private'); } + if ($property->flags & Modifiers::FINAL) { + $this->fatalError($property, 'Property in interface cannot be final'); + } if ($property->flags & Modifiers::STATIC) { $this->fatalError($property, 'Cannot declare hooks for static property'); } @@ -1973,6 +1984,9 @@ class Preprocessor extends CompilerBase } $kind = strtolower($hook->name->toString()); if ($kind === 'get') { + if ($hook->byRef) { + $this->fatalError($hook, 'Property get hooks returning by reference are not supported'); + } if ($readable) { $this->fatalError($hook, 'Cannot redeclare property hook "get"'); } @@ -1997,6 +2011,14 @@ class Preprocessor extends CompilerBase $nullable = $property->type instanceof NullableType; foreach ($property->props as $prop) { $name = $this->parseIdentifier($prop->name); + if ($property->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false)) { + $this->fatalCompileTimeAttribute( + $property, + 'Override', + "{$this->interfaceDef->getNamespacedName(false)}::\${$name} has #[\\Override] attribute, " + . 'but no matching parent class property exists', + ); + } if ($this->interfaceDef->hasProperty($name)) { $this->fatalError($property, "Duplicate property `{$name}`"); } diff --git a/src/Transform/CompileTimeAttributeRegistry.php b/src/Transform/CompileTimeAttributeRegistry.php index 4ba6bf13..bed843a0 100644 --- a/src/Transform/CompileTimeAttributeRegistry.php +++ b/src/Transform/CompileTimeAttributeRegistry.php @@ -88,7 +88,13 @@ final class CompileTimeAttributeRegistry $add($name, [self::TARGET_PARAMETER], $name . ' can only be applied to function or method parameters', self::ARGUMENTS_NONE, self::PHASE_FUNCTION_LEAVE); } $add('Validate', [self::TARGET_PARAMETER], 'Validate can only be applied to function or method parameters', self::ARGUMENTS_VALIDATE, self::PHASE_FUNCTION_LEAVE); - $add('Override', [self::TARGET_METHOD], 'Override can only be applied to methods', self::ARGUMENTS_NONE, self::PHASE_ENTER); + $add( + 'Override', + [self::TARGET_METHOD, self::TARGET_PROPERTY], + 'Override can only be applied to methods or properties', + self::ARGUMENTS_NONE, + self::PHASE_ENTER, + ); $add('MustUse', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'MustUse can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER); $add('Immutable', [self::TARGET_METHOD, self::TARGET_PROPERTY_HOOK, self::TARGET_PARAMETER], 'Immutable can only be applied to methods, property hooks, or function parameters', self::ARGUMENTS_NONE, self::PHASE_ENTER); $add('Hot', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Hot can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Cold']); diff --git a/src/Transform/FunctionAttributeLowering.php b/src/Transform/FunctionAttributeLowering.php index 92351e17..ee044a87 100644 --- a/src/Transform/FunctionAttributeLowering.php +++ b/src/Transform/FunctionAttributeLowering.php @@ -37,8 +37,19 @@ final class FunctionAttributeLowering $node->setAttribute(self::IMMUTABLE_ATTRIBUTE, true); continue; } + if ($name === 'Override' + && ($node instanceof Stmt\Property || ($node instanceof Node\Param && $node->isPromoted())) + ) { + // Property override validation needs the fully linked parent + // class, so preserve only an internal marker and consume the + // compile-time attribute before stub generation. + CompileTimeAttribute::consume($node, $name); + $node->setAttribute(self::OVERRIDE_ATTRIBUTE, true); + continue; + } if (!$node instanceof Stmt\Function_ && !$node instanceof Stmt\ClassMethod) { - throw new SyntaxError($name . ' can only be applied to functions or methods'); + $target = $name === 'Override' ? 'methods or properties' : 'functions or methods'; + throw new SyntaxError($name . ' can only be applied to ' . $target); } CompileTimeAttribute::consume($node, $name); $node->setAttribute('typephp' . $name, true); diff --git a/src/Transform/PropertyHookLowering.php b/src/Transform/PropertyHookLowering.php index 566d6db4..0ebf9696 100644 --- a/src/Transform/PropertyHookLowering.php +++ b/src/Transform/PropertyHookLowering.php @@ -101,7 +101,11 @@ final class PropertyHookLowering } $method = new Stmt\ClassMethod($methodName, [ - 'flags' => Modifiers::PUBLIC | Modifiers::FINAL, + // Hidden methods participate in inheritance exactly like the + // corresponding hooks. Marking every generated method final + // rejects legal child hook overrides and also forces PHPX to + // erase final unconditionally from the Zend hook metadata. + 'flags' => Modifiers::PUBLIC | ($hook->flags & Modifiers::FINAL), 'byRef' => $kind === 'get' && $hook->byRef, 'params' => $params, 'returnType' => $returnType, @@ -165,7 +169,9 @@ final class PropertyHookLowering private static function visibilityMarker(string $name, array $attributes): Stmt\ClassMethod { return new Stmt\ClassMethod($name, [ - 'flags' => Modifiers::PUBLIC | Modifiers::FINAL, + // A child declaration may replace the generated visibility marker. + // This method is metadata for the object handler, not a final PHP API. + 'flags' => Modifiers::PUBLIC, 'returnType' => new Node\Identifier('void'), 'stmts' => [], ], $attributes); diff --git a/src/Translator.php b/src/Translator.php index e8cc1f18..1237fb16 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -46,6 +46,7 @@ use TypePhp\Resolver\ClassConstantValueTrait; use TypePhp\Transform\Visitor; use TypePhp\Transform\ConstructorLowering; use TypePhp\Transform\ConstantExpressionValidationVisitor; +use TypePhp\Transform\PropertyHookLowering; use TypePhp\Transform\RuntimeAttributeFactoryLowering; use TypePhp\Transform\VoidCastValidationVisitor; use PhpParser\Modifiers; @@ -4051,7 +4052,17 @@ CODE; } if ($methodDef->flags & Modifiers::FINAL) { _final_error: - $message = 'Cannot override final method `' . $extends . '::' . $name . '()`'; + $hook = $v->getAttribute(PropertyHookLowering::METHOD_ATTRIBUTE); + if (is_array($hook) + && isset($hook['property'], $hook['kind']) + && is_string($hook['property']) + && is_string($hook['kind']) + ) { + $message = 'Cannot override final property hook ' + . $extends . '::$' . $hook['property'] . '::' . $hook['kind'] . '()'; + } else { + $message = 'Cannot override final method `' . $extends . '::' . $name . '()`'; + } $this->fatalGeneratedMethodAttributeIfAny($v, $message, $extends, $name); $this->fatalError($v, $message); @@ -4804,6 +4815,7 @@ CODE; { $classDef = $this->classDef; $className = $this->getFullClassName(); + $matchedOverrides = []; $chainNode = $classDef; while ($chainNode->extends && !$chainNode->inheritedFromInternalClass) { $parentClass = $chainNode->extends; @@ -4814,22 +4826,29 @@ CODE; foreach ($this->classDef->properties as $name => $childProp) { if ($chainNode->hasProperty($name)) { $parentProp = $chainNode->getProperty($name); - // A parent private property would be a separate PHP slot - // hidden by the child declaration. Zend-backed TypePHP - // classes still forbid that dual-slot model, while Native - // classes have declaring-class-qualified C++ fields and - // can represent it without a runtime property table. - // Public/protected declarations instead - // describe the same inherited property slot and must obey - // PHP-compatible type, visibility and readonly rules. + // TypePHP deliberately forbids the two independent slots + // PHP would create when a child hides a parent private + // property. This applies equally to Zend-backed and Native + // classes, even though Native storage could represent it. if ($parentProp->flags & Modifiers::PRIVATE) { - if ($classDef->nativeObject) { - continue; - } $this->fatalError($classStmt, "Declaration of `{$className}::\${$name}` conflicts with private property " . "`{$parentClass}::\${$name}`; property shadowing across inheritance is not allowed"); } + $matchedOverrides[$name] = true; + // PHP inherits get and set independently. A child may + // override only one hook, or redeclare the property + // without hooks while retaining both parent hooks. + $childProp->getter ??= $parentProp->getter; + $childProp->setter ??= $parentProp->setter; + // PHP 8.4 treats private(set) properties as implicitly + // final because a child cannot widen their write scope. + if ($parentProp->flags & (Modifiers::FINAL | Modifiers::PRIVATE_SET)) { + $this->fatalError( + $classStmt, + "Cannot override final property {$parentClass}::\${$name}" + ); + } if ($childProp->type !== $parentProp->type || $childProp->class !== $parentProp->class) { $this->fatalError($classStmt, "Declaration of `{$className}::\${$name}` must be compatible " . @@ -4853,6 +4872,23 @@ CODE; } } } + + if ($classStmt instanceof Node\Stmt\Trait_) { + // A trait property is validated after it is composed into the + // consuming class, where the actual parent chain is known. + return; + } + foreach ($classDef->properties as $name => $property) { + if (!$property->overrideRequired || isset($matchedOverrides[$name])) { + continue; + } + $this->fatalCompileTimeAttribute( + $property->node ?? $classStmt, + 'Override', + "{$className}::\${$name} has #[\\Override] attribute, " + . 'but no matching parent class property exists', + ); + } } private function getPropertySetVisibilityRank(PropertyDef $property): int diff --git a/src/gen_stub.php b/src/gen_stub.php index 42d4b194..e00cb6a3 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -3602,6 +3602,7 @@ class PropertyInfo extends VariableLike $this->phpVersionIdMinimumCompatibility ); $code .= $stringInit; + $code .= "\ttypephp_prepare_property_redeclaration(class_entry, {$nameCode});\n"; if ($this->exposedDocComment) { $commentCode = "property_{$propertyName}_comment"; @@ -3635,7 +3636,7 @@ class PropertyInfo extends VariableLike if ($this->abstractHooks) { $getter = isset($this->hooks['get']) ? 'true' : 'false'; $setter = isset($this->hooks['set']) ? 'true' : 'false'; - $code .= "\tphp::registerAbstractPropertyHooks(class_entry, property_{$propertyName}, {$getter}, {$setter});\n"; + $code .= "\ttypephp_register_abstract_property_hooks(class_entry, property_{$propertyName}, {$getter}, {$setter});\n"; } else { $getter = isset($this->hooks['get']) ? 'std::string_view{"' . addslashes($this->hooks['get']) . '"}' @@ -3643,7 +3644,7 @@ class PropertyInfo extends VariableLike $setter = isset($this->hooks['set']) ? 'std::string_view{"' . addslashes($this->hooks['set']) . '"}' : 'std::string_view{}'; - $code .= "\tphp::registerPropertyHooks(class_entry, property_{$propertyName}, {$getter}, {$setter});\n"; + $code .= "\ttypephp_register_property_hooks(class_entry, property_{$propertyName}, {$getter}, {$setter});\n"; } } @@ -3658,7 +3659,9 @@ class PropertyInfo extends VariableLike $flags->addForVersionsAbove("ZEND_ACC_STATIC", PHP_70_VERSION_ID); } - if ($this->flags & Modifiers::FINAL) { + // PHP 8.4 makes private(set) properties implicitly final. Preserve + // that fact in Zend metadata as well as in TypePHP's override checks. + if ($this->flags & (Modifiers::FINAL | Modifiers::PRIVATE_SET)) { $flags->addForVersionsAbove("ZEND_ACC_FINAL", PHP_84_VERSION_ID); } @@ -4068,6 +4071,13 @@ class ClassInfo { $code .= $property->getDeclaration($allConstInfos); } + if ($this->type === 'class' && isset($this->extends[0])) { + // Internal classes are linked to their parent before their own + // properties are declared. Restore PHP's per-hook inheritance + // after those declarations have replaced inherited metadata. + $code .= "\ttypephp_finalize_property_hook_inheritance(class_entry);\n"; + } + // Zend merges interface property contracts immediately. Declare the // class/interface's own properties first so an implementation can // replace a virtual abstract contract with its real property slot. diff --git a/tests/compiler/native-class/private-property-slots.phpt b/tests/compiler/native-class/private-property-slots.phpt index 8c6b0983..0cced0b4 100644 --- a/tests/compiler/native-class/private-property-slots.phpt +++ b/tests/compiler/native-class/private-property-slots.phpt @@ -1,37 +1,37 @@ --TEST-- -Native class: parent and child private properties use independent native slots +Native class: inherited methods retain access to distinct private properties --FILE-- value; + return $this->baseValue; } public function setBaseValue(int $value): void { - $this->value = $value; + $this->baseValue = $value; } } #[Native] class NativePrivateChild extends NativePrivateBase { - private int $value = 20; + private int $childValue = 20; public function childValue(): int { - return $this->value; + return $this->childValue; } public function setChildValue(int $value): void { - $this->value = $value; + $this->childValue = $value; } } diff --git a/tests/compiler/object_property/final-property.phpt b/tests/compiler/object_property/final-property.phpt new file mode 100644 index 00000000..879e2760 --- /dev/null +++ b/tests/compiler/object_property/final-property.phpt @@ -0,0 +1,52 @@ +--TEST-- +PHP 8.4 final properties preserve runtime and reflection metadata +--FILE-- + 'hooked'; + set { + } + } + + final public string $finalHook { + final get => 'both'; + } + + public private(set) string $privateSet = 'private-set'; +} + +function main(): void +{ + $object = new FinalPropertyMetadata(); + var_dump($object->plain, $object->hooked, $object->finalHook, $object->privateSet); + + foreach (['plain', 'hooked', 'finalHook', 'privateSet'] as $name) { + $property = new ReflectionProperty(FinalPropertyMetadata::class, $name); + echo $name, + ':final=', $property->isFinal() ? 'yes' : 'no', + ':hooks=', $property->hasHooks() ? 'yes' : 'no', + ':virtual=', $property->isVirtual() ? 'yes' : 'no', + "\n"; + foreach ($property->getHooks() as $kind => $hook) { + echo $name, '-', $kind, ':', $hook->isFinal() ? 'final' : 'open', "\n"; + } + } +} +?> +--EXPECT-- +string(5) "plain" +string(6) "hooked" +string(4) "both" +string(11) "private-set" +plain:final=yes:hooks=no:virtual=no +hooked:final=yes:hooks=yes:virtual=yes +hooked-get:open +hooked-set:open +finalHook:final=yes:hooks=yes:virtual=yes +finalHook-get:final +privateSet:final=yes:hooks=no:virtual=no diff --git a/tests/compiler/object_property/override-property.phpt b/tests/compiler/object_property/override-property.phpt new file mode 100644 index 00000000..9dcad86b --- /dev/null +++ b/tests/compiler/object_property/override-property.phpt @@ -0,0 +1,29 @@ +--TEST-- +TypePHP Override attribute validates and is consumed from properties +--FILE-- +value); + + $property = new ReflectionProperty(OverridePropertyRuntimeChild::class, 'value'); + var_dump($property->getAttributes(\Override::class)); +} +?> +--EXPECT-- +string(5) "child" +array(0) { +} diff --git a/tests/compiler/object_property/property-hooks-inheritance.phpt b/tests/compiler/object_property/property-hooks-inheritance.phpt new file mode 100644 index 00000000..fa0baed6 --- /dev/null +++ b/tests/compiler/object_property/property-hooks-inheritance.phpt @@ -0,0 +1,92 @@ +--TEST-- +PHP 8.4 property hooks inherit and override get/set independently +--FILE-- + 'parent:' . $this->stored; + set { + $this->stored = 'set:' . $value; + } + } + + public function writeFromParent(string $value): void + { + $this->value = $value; + } + + public function readFromParent(): string + { + return $this->value; + } +} + +class ChildHook extends ParentHook +{ + public string $value { + get => 'child:' . $this->stored; + } +} + +class PlainHookChild extends ParentHook +{ + public string $value; +} + +function writeHookDynamically(mixed $object, string $value): void +{ + $object->value = $value; +} + +function readHookDynamically(mixed $object): string +{ + return $object->value; +} + +function main(): void +{ + $child = new ChildHook(); + + $child->value = 'direct'; + var_dump($child->value, $child->readFromParent()); + + $child->writeFromParent('parent'); + var_dump($child->value, $child->readFromParent()); + + writeHookDynamically($child, 'dynamic'); + var_dump(readHookDynamically($child)); + + $property = new ReflectionProperty(ChildHook::class, 'value'); + foreach ($property->getHooks() as $kind => $hook) { + echo $kind, ':', $hook->getDeclaringClass()->getName(), ':', $hook->isFinal() ? 'final' : 'open', "\n"; + } + + $plain = new PlainHookChild(); + $plain->value = 'plain'; + var_dump($plain->value, $plain->readFromParent()); + writeHookDynamically($plain, 'plain-dynamic'); + var_dump(readHookDynamically($plain)); + + $plainProperty = new ReflectionProperty(PlainHookChild::class, 'value'); + foreach ($plainProperty->getHooks() as $kind => $hook) { + echo 'plain-', $kind, ':', $hook->getDeclaringClass()->getName(), "\n"; + } +} +?> +--EXPECT-- +string(16) "child:set:direct" +string(16) "child:set:direct" +string(16) "child:set:parent" +string(16) "child:set:parent" +string(17) "child:set:dynamic" +get:ChildHook:open +set:ParentHook:open +string(16) "parent:set:plain" +string(16) "parent:set:plain" +string(24) "parent:set:plain-dynamic" +plain-get:ParentHook +plain-set:ParentHook diff --git a/tests/compiler/object_property/property-hooks-parent-call.phpt b/tests/compiler/object_property/property-hooks-parent-call.phpt new file mode 100644 index 00000000..2e59071f --- /dev/null +++ b/tests/compiler/object_property/property-hooks-parent-call.phpt @@ -0,0 +1,36 @@ +--TEST-- +Property hooks may call the corresponding parent get and set hook +--FILE-- + 'parent-get:' . $this->stored; + set { + $this->stored = 'parent-set:' . $value; + } + } +} + +class ChildPropertyHookCall extends ParentPropertyHookCall +{ + public string $value { + get => parent::$value::get() . ':child-get'; + set { + parent::$value::set($value . ':child-set'); + } + } +} + +function main(): void +{ + $point = new ChildPropertyHookCall(); + $point->value = 'data'; + var_dump($point->value); +} +?> +--EXPECT-- +string(46) "parent-get:parent-set:data:child-set:child-get" diff --git a/tests/compiler/object_property/property-hooks-reflection.phpt b/tests/compiler/object_property/property-hooks-reflection.phpt index 3c897a70..d9a9840f 100644 --- a/tests/compiler/object_property/property-hooks-reflection.phpt +++ b/tests/compiler/object_property/property-hooks-reflection.phpt @@ -6,7 +6,7 @@ PHP 8.4 property hooks expose Zend reflection metadata final class ReflectedPropertyHooks { public string $virtual { - get => 'value'; + final get => 'value'; set { } } @@ -25,5 +25,5 @@ function main(): void --EXPECT-- bool(true) bool(true) -get:$virtual::get:not-final +get:$virtual::get:final set:$virtual::set:not-final