- Implement property hook lowering to getter/setter method calls - Add support for private(set) and protected(set) asymmetric visibility - Generate appropriate error handling for read-only hooked properties - Support compound assignments and increment/decrement operations on hooked properties - Add proper visibility checks for asymmetric property setters - Update documentation to reflect new property hook capabilities - Add comprehensive test coverage for property hook functionalitypull/17/head
parent
bd2a3da14c
commit
3e9c02a666
16 changed files with 662 additions and 30 deletions
@ -0,0 +1,12 @@ |
||||
<?php |
||||
|
||||
class PrivateSetRecord |
||||
{ |
||||
public private(set) string $name = 'default'; |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$record = new PrivateSetRecord(); |
||||
$record->name = 'outside'; |
||||
} |
||||
@ -0,0 +1,12 @@ |
||||
<?php |
||||
|
||||
class ProtectedSetRecord |
||||
{ |
||||
public protected(set) int $score = 0; |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$record = new ProtectedSetRecord(); |
||||
$record->score = 1; |
||||
} |
||||
@ -0,0 +1,204 @@ |
||||
<?php |
||||
/** |
||||
* This file is part of TypePHP. |
||||
* |
||||
* @link https://www.swoole.com/ |
||||
* @contact service@swoole.com |
||||
*/ |
||||
|
||||
namespace TypePhp; |
||||
|
||||
use PhpParser\Modifiers; |
||||
use PhpParser\Node; |
||||
use PhpParser\Node\Expr; |
||||
use PhpParser\Node\Param; |
||||
use PhpParser\Node\Stmt; |
||||
use PhpParser\NodeAbstract; |
||||
use PhpParser\NodeTraverser; |
||||
use PhpParser\NodeVisitorAbstract; |
||||
|
||||
final class PropertyHookLowering |
||||
{ |
||||
public const string BACKING_ACCESS_ATTRIBUTE = 'typephpPropertyHookBackingAccess'; |
||||
public const string METHOD_ATTRIBUTE = 'typephpPropertyHookMethod'; |
||||
private const string GET_PREFIX = '__typephp_property_get_'; |
||||
private const string SET_PREFIX = '__typephp_property_set_'; |
||||
private const string PRIVATE_SET_PREFIX = '__typephp_property_private_set_'; |
||||
private const string PROTECTED_SET_PREFIX = '__typephp_property_protected_set_'; |
||||
|
||||
public static function getterName(string $property): string |
||||
{ |
||||
return self::GET_PREFIX . bin2hex($property); |
||||
} |
||||
|
||||
public static function setterName(string $property): string |
||||
{ |
||||
return self::SET_PREFIX . bin2hex($property); |
||||
} |
||||
|
||||
public static function isGetterName(string $method): bool |
||||
{ |
||||
return str_starts_with($method, self::GET_PREFIX); |
||||
} |
||||
|
||||
public static function isSetterName(string $method): bool |
||||
{ |
||||
return str_starts_with($method, self::SET_PREFIX); |
||||
} |
||||
|
||||
public static function isAsymmetricSetterMarkerName(string $method): bool |
||||
{ |
||||
return str_starts_with($method, self::PRIVATE_SET_PREFIX) |
||||
|| str_starts_with($method, self::PROTECTED_SET_PREFIX); |
||||
} |
||||
|
||||
/** @return list<Stmt\ClassMethod> */ |
||||
public static function lowerProperty(Stmt\Property $property): array |
||||
{ |
||||
if (count($property->props) !== 1) { |
||||
return []; |
||||
} |
||||
|
||||
$propertyName = $property->props[0]->name->toString(); |
||||
$methods = []; |
||||
if ($property->flags & Modifiers::PRIVATE_SET) { |
||||
$methods[] = self::visibilityMarker( |
||||
self::PRIVATE_SET_PREFIX . bin2hex($propertyName), |
||||
$property->getAttributes() |
||||
); |
||||
} elseif ($property->flags & Modifiers::PROTECTED_SET) { |
||||
$methods[] = self::visibilityMarker( |
||||
self::PROTECTED_SET_PREFIX . bin2hex($propertyName), |
||||
$property->getAttributes() |
||||
); |
||||
} |
||||
foreach ($property->hooks as $hook) { |
||||
$kind = strtolower($hook->name->toString()); |
||||
if ($kind !== 'get' && $kind !== 'set') { |
||||
continue; |
||||
} |
||||
|
||||
self::markBackingAccesses($hook->body, $propertyName); |
||||
if ($kind === 'get') { |
||||
$stmts = self::getterStatements($hook, $propertyName); |
||||
$params = []; |
||||
$returnType = $property->type; |
||||
$methodName = self::getterName($propertyName); |
||||
} else { |
||||
$params = $hook->params; |
||||
if ($params === []) { |
||||
$params = [new Param(new Expr\Variable('value'), type: $property->type)]; |
||||
} elseif ($params[0]->type === null) { |
||||
$params[0]->type = $property->type; |
||||
} |
||||
$stmts = self::setterStatements($hook, $propertyName, $params[0]); |
||||
$returnType = new Node\Identifier('void'); |
||||
$methodName = self::setterName($propertyName); |
||||
} |
||||
|
||||
$method = new Stmt\ClassMethod($methodName, [ |
||||
'flags' => Modifiers::PUBLIC | Modifiers::FINAL, |
||||
'byRef' => $kind === 'get' && $hook->byRef, |
||||
'params' => $params, |
||||
'returnType' => $returnType, |
||||
'stmts' => $stmts, |
||||
'attrGroups' => $hook->attrGroups, |
||||
], $hook->getAttributes()); |
||||
$method->setAttribute(self::METHOD_ATTRIBUTE, [ |
||||
'kind' => $kind, |
||||
'property' => $propertyName, |
||||
]); |
||||
$methods[] = $method; |
||||
} |
||||
|
||||
return $methods; |
||||
} |
||||
|
||||
public static function lowerPromotedProperty(Param $param): ?Stmt\ClassMethod |
||||
{ |
||||
if (!$param->isPromoted() || !is_string($param->var->name)) { |
||||
return null; |
||||
} |
||||
if ($param->flags & Modifiers::PRIVATE_SET) { |
||||
$prefix = self::PRIVATE_SET_PREFIX; |
||||
} elseif ($param->flags & Modifiers::PROTECTED_SET) { |
||||
$prefix = self::PROTECTED_SET_PREFIX; |
||||
} else { |
||||
return null; |
||||
} |
||||
return self::visibilityMarker($prefix . bin2hex($param->var->name), $param->getAttributes()); |
||||
} |
||||
|
||||
private static function visibilityMarker(string $name, array $attributes): Stmt\ClassMethod |
||||
{ |
||||
return new Stmt\ClassMethod($name, [ |
||||
'flags' => Modifiers::PUBLIC | Modifiers::FINAL, |
||||
'returnType' => new Node\Identifier('void'), |
||||
'stmts' => [], |
||||
], $attributes); |
||||
} |
||||
|
||||
/** @return list<Stmt> */ |
||||
private static function getterStatements(Node\PropertyHook $hook, string $property): array |
||||
{ |
||||
if ($hook->body === null) { |
||||
return [new Stmt\Return_(self::backingFetch($property))]; |
||||
} |
||||
if ($hook->body instanceof Expr) { |
||||
return [new Stmt\Return_($hook->body, $hook->body->getAttributes())]; |
||||
} |
||||
return $hook->body; |
||||
} |
||||
|
||||
/** @return list<Stmt> */ |
||||
private static function setterStatements(Node\PropertyHook $hook, string $property, Param $param): array |
||||
{ |
||||
if ($hook->body instanceof Expr) { |
||||
return [new Stmt\Expression( |
||||
new Expr\Assign(self::backingFetch($property), $hook->body), |
||||
$hook->body->getAttributes() |
||||
)]; |
||||
} |
||||
if ($hook->body !== null) { |
||||
return $hook->body; |
||||
} |
||||
return [new Stmt\Expression(new Expr\Assign( |
||||
self::backingFetch($property), |
||||
new Expr\Variable($param->var->name) |
||||
))]; |
||||
} |
||||
|
||||
private static function backingFetch(string $property): Expr\PropertyFetch |
||||
{ |
||||
$fetch = new Expr\PropertyFetch(new Expr\Variable('this'), $property); |
||||
$fetch->setAttribute(self::BACKING_ACCESS_ATTRIBUTE, true); |
||||
return $fetch; |
||||
} |
||||
|
||||
private static function markBackingAccesses(NodeAbstract|array|null $body, string $property): void |
||||
{ |
||||
if ($body === null) { |
||||
return; |
||||
} |
||||
$nodes = is_array($body) ? $body : [$body]; |
||||
$traverser = new NodeTraverser(); |
||||
$traverser->addVisitor(new class($property) extends NodeVisitorAbstract { |
||||
public function __construct(private readonly string $property) |
||||
{ |
||||
} |
||||
|
||||
public function enterNode(Node $node): null |
||||
{ |
||||
if ($node instanceof Expr\PropertyFetch |
||||
&& $node->var instanceof Expr\Variable |
||||
&& $node->var->name === 'this' |
||||
&& $node->name instanceof Node\Identifier |
||||
&& $node->name->toString() === $this->property) { |
||||
$node->setAttribute(PropertyHookLowering::BACKING_ACCESS_ATTRIBUTE, true); |
||||
} |
||||
return null; |
||||
} |
||||
}); |
||||
$traverser->traverse($nodes); |
||||
} |
||||
} |
||||
@ -0,0 +1,83 @@ |
||||
--TEST-- |
||||
Asymmetric property set visibility works for static and dynamic writes |
||||
--FILE-- |
||||
<?php |
||||
|
||||
class BaseRecord |
||||
{ |
||||
public private(set) string $name = 'default'; |
||||
public protected(set) int $score = 0; |
||||
|
||||
public function rename(string $name): void |
||||
{ |
||||
$this->name = $name; |
||||
} |
||||
|
||||
public function renameDynamically(mixed $target, string $name): void |
||||
{ |
||||
$target->name = $name; |
||||
} |
||||
|
||||
public function setScore(int $score): void |
||||
{ |
||||
$this->score = $score; |
||||
} |
||||
|
||||
public function setRelatedChildScore(mixed $target, int $score): void |
||||
{ |
||||
$target->childScore = $score; |
||||
} |
||||
} |
||||
|
||||
class ChildRecord extends BaseRecord |
||||
{ |
||||
public protected(set) int $childScore = 0; |
||||
|
||||
public function setOwnScore(int $score): void |
||||
{ |
||||
$this->score = $score; |
||||
} |
||||
|
||||
public function setRelatedScore(mixed $target, int $score): void |
||||
{ |
||||
$target->score = $score; |
||||
} |
||||
} |
||||
|
||||
function tryExternalWrites(mixed $record): void |
||||
{ |
||||
try { |
||||
$record->name = 'outside'; |
||||
} catch (Error $error) { |
||||
echo "private blocked\n"; |
||||
} |
||||
try { |
||||
$record->score = 99; |
||||
} catch (Error $error) { |
||||
echo "protected blocked\n"; |
||||
} |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$record = new ChildRecord(); |
||||
$record->rename('inside'); |
||||
$record->renameDynamically($record, 'dynamic inside'); |
||||
$record->setScore(10); |
||||
$record->setOwnScore(15); |
||||
$record->setRelatedScore($record, 20); |
||||
$record->setRelatedChildScore($record, 30); |
||||
var_dump($record->name, $record->score, $record->childScore); |
||||
tryExternalWrites($record); |
||||
var_dump($record->name, $record->score, $record->childScore); |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
string(14) "dynamic inside" |
||||
int(20) |
||||
int(30) |
||||
private blocked |
||||
protected blocked |
||||
string(14) "dynamic inside" |
||||
int(20) |
||||
int(30) |
||||
@ -0,0 +1,40 @@ |
||||
--TEST-- |
||||
PHP 8.4 property hooks support implicit value, compound writes and dynamic reads |
||||
--FILE-- |
||||
<?php |
||||
|
||||
class Counter |
||||
{ |
||||
public int $value { |
||||
get => $this->value * 2; |
||||
set => max(0, $value); |
||||
} |
||||
} |
||||
|
||||
function readDynamically(mixed $counter): mixed |
||||
{ |
||||
return $counter->value; |
||||
} |
||||
|
||||
function writeDynamically(mixed $counter, mixed $value): void |
||||
{ |
||||
$counter->value = $value; |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$counter = new Counter(); |
||||
$counter->value = 3; |
||||
$counter->value += 2; |
||||
var_dump($counter->value); |
||||
var_dump($counter->value++); |
||||
var_dump(readDynamically($counter)); |
||||
writeDynamically($counter, -10); |
||||
var_dump(readDynamically($counter)); |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
int(16) |
||||
int(16) |
||||
int(34) |
||||
int(0) |
||||
@ -0,0 +1,40 @@ |
||||
--TEST-- |
||||
PHP 8.4 property hooks lower to getter and setter methods |
||||
--FILE-- |
||||
<?php |
||||
|
||||
class Person |
||||
{ |
||||
public string $name { |
||||
get => strtoupper($this->name); |
||||
set(string $value) => trim($value); |
||||
} |
||||
|
||||
public int $age { |
||||
get { |
||||
return $this->age + 1; |
||||
} |
||||
set(int $value) { |
||||
$this->age = $value; |
||||
} |
||||
} |
||||
} |
||||
|
||||
function setNameDynamically(mixed $person): void |
||||
{ |
||||
$person->name = ' bob '; |
||||
} |
||||
|
||||
function main(): void |
||||
{ |
||||
$person = new Person(); |
||||
$person->name = ' alice '; |
||||
$person->age = 20; |
||||
setNameDynamically($person); |
||||
var_dump($person->name); |
||||
var_dump($person->age); |
||||
} |
||||
?> |
||||
--EXPECT-- |
||||
string(3) "BOB" |
||||
int(21) |
||||
Loading…
Reference in new issue