Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion phpunit/code/inheritance_error_prop_readonly.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class A

class B extends A
{
public readonly int $x = 2;
public readonly int $x;
}

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/readonly_class_allow_dynamic.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
#[AllowDynamicProperties]
readonly class Cfg { public int $port; public function __construct() { $this->port = 80; } }

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/readonly_class_extends.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
readonly class A {}
class B extends A {}

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/readonly_class_extends_internal.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
readonly class Cfg extends ArrayObject {}

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/readonly_class_extends_rev.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
class A {}
readonly class B extends A {}

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/readonly_class_extends_valid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
readonly class A { public function __construct(public int $x) {} }
readonly class B extends A {}

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/readonly_class_trait_nonreadonly_prop.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
trait Settings { public int $port; }
readonly class Cfg { use Settings; }

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/readonly_class_trait_readonly_prop_valid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
trait Settings { public readonly int $port; }
readonly class Cfg { use Settings; public function __construct() { $this->port = 80; } }

function main() {}
5 changes: 5 additions & 0 deletions phpunit/code/readonly_class_trait_static_prop.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php
trait Settings { public static int $port = 80; }
readonly class Cfg { use Settings; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/readonly_rule_class_static.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
readonly class Cfg { public static int $port; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/readonly_rule_class_untyped.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
readonly class Cfg { public $port; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/readonly_rule_default.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Cfg { public readonly int $port = 80; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/readonly_rule_promoted_untyped.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Cfg { public function __construct(public readonly $port) {} }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/readonly_rule_static.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Cfg { public static readonly int $port; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/readonly_rule_untyped.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
class Cfg { public readonly $port; }

function main() {}
4 changes: 4 additions & 0 deletions phpunit/code/readonly_rule_valid.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php
readonly class Cfg { public int $port; public function __construct(public readonly string $host = "a") { $this->port = 80; } }

function main() {}
40 changes: 40 additions & 0 deletions phpunit/src/ClassKindInheritanceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/**
* Readonly-ness is part of the inheritance contract in both directions, and
* an interface can only extend other interfaces.
*/
class ClassKindInheritanceTest extends BaseTest
{
public function testNonReadonlyCannotExtendReadonly(): void
{
$this->exec(
'Non-readonly class `B` cannot extend readonly class `A`',
'readonly_class_extends.php'
);
}

public function testReadonlyCannotExtendNonReadonly(): void
{
$this->exec(
'Readonly class `B` cannot extend non-readonly class `A`',
'readonly_class_extends_rev.php'
);
}


public function testReadonlyExtendsReadonlyIsValid(): void
{
$this->compile('readonly_class_extends_valid.php');
}

public function testReadonlyCannotExtendNonReadonlyInternalClass(): void
{
// Internal parents are not in the symbol table; host reflection
// (ReflectionClass::isReadOnly) is authoritative for them.
$this->exec(
'Readonly class `Cfg` cannot extend non-readonly class `ArrayObject`',
'readonly_class_extends_internal.php'
);
}
}
80 changes: 80 additions & 0 deletions phpunit/src/ReadonlyDeclarationRulesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

/**
* Zend readonly property declaration rules for ZendVM-backed classes:
* no defaults, mandatory type, no static readonly, and the readonly
* class modifier applying the same rules to every property.
*/
class ReadonlyDeclarationRulesTest extends BaseTest
{
public function testReadonlyPropertyCannotHaveDefault(): void
{
$this->exec('Readonly property `Cfg::$port` cannot have default value', 'readonly_rule_default.php');
}

public function testReadonlyPropertyMustHaveType(): void
{
$this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_untyped.php');
}

public function testStaticPropertyCannotBeReadonly(): void
{
$this->exec('Static property `Cfg::$port` cannot be readonly', 'readonly_rule_static.php');
}

public function testPromotedReadonlyParamMustHaveType(): void
{
$this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_promoted_untyped.php');
}

public function testReadonlyClassPropertyMustHaveType(): void
{
$this->exec('Readonly property `Cfg::$port` must have type', 'readonly_rule_class_untyped.php');
}

public function testReadonlyClassCannotDeclareStaticProperty(): void
{
$this->exec('Static property `Cfg::$port` cannot be readonly', 'readonly_rule_class_static.php');
}

public function testWellFormedReadonlyDeclarationsStillCompile(): void
{
// Promoted readonly params may keep a parameter default: it belongs
// to the constructor argument, not to the property.
$this->compile('readonly_rule_valid.php');
}

public function testReadonlyClassCannotUseTraitWithNonReadonlyProperty(): void
{
// A trait property keeps its own declaration; the consuming class's
// readonly modifier does not upgrade it.
$this->exec(
'Readonly class `Cfg` cannot use trait with a non-readonly property `Settings::$port`',
'readonly_class_trait_nonreadonly_prop.php'
);
}

public function testReadonlyClassCannotUseTraitWithStaticProperty(): void
{
// Static properties can never be readonly, so a trait declaring one
// is unusable in a readonly class (Zend reports the same mismatch).
$this->exec(
'Readonly class `Cfg` cannot use trait with a non-readonly property `Settings::$port`',
'readonly_class_trait_static_prop.php'
);
}

public function testReadonlyClassUsingTraitWithReadonlyPropertyIsValid(): void
{
$this->compile('readonly_class_trait_readonly_prop_valid.php');
}

public function testAllowDynamicPropertiesOnReadonlyClassIsRejected(): void
{
// Dynamic properties and readonly semantics are mutually exclusive.
$this->exec(
'Cannot apply #[AllowDynamicProperties] to readonly class `Cfg`',
'readonly_class_allow_dynamic.php'
);
}
}
30 changes: 30 additions & 0 deletions src/Preprocessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,21 @@ protected function prepareClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum
if (isset($this->symbolDeclInFile[$fullClassNameLower])) {
$this->fatalError($class, "Duplicate class `{$fullClassName}`");
}
// Dynamic properties and readonly semantics are mutually exclusive:
// every property of a readonly class is readonly and declared, so
// Zend rejects the attribute at compile time.
if ($class instanceof Node\Stmt\Class_ && ($flags & Modifiers::READONLY)) {
foreach ($class->attrGroups as $group) {
foreach ($group->attrs as $attribute) {
if (strcasecmp($this->getResolvedPhpName($attribute->name), 'AllowDynamicProperties') === 0) {
$this->fatalError(
$attribute,
"Cannot apply #[AllowDynamicProperties] to readonly class `{$fullClassName}`",
);
}
}
}
}

$this->classDef = new ClassDef($this->class, $flags, $this->namespace);
$this->classDef->nativeObject = NativeClassAttributeLowering::isNative($class);
Expand Down Expand Up @@ -1674,6 +1689,21 @@ protected function addClassProperty(string $name, int $flags, ?NodeAbstract $typ
);
}
$flags = $this->parseModifiers($flags);
// A `readonly class` marks every property readonly, so the class-level
// flag participates in the same Zend declaration rules as an explicit
// per-property `readonly` modifier.
if (($flags | $this->classDef->flags) & Modifiers::READONLY) {
$className = $this->classDef->getNamespacedName(false);
if ($flags & Modifiers::STATIC) {
$this->fatalError($errorNode, "Static property `{$className}::\${$name}` cannot be readonly");
}
if ($typeNode === null) {
$this->fatalError($errorNode, "Readonly property `{$className}::\${$name}` must have type");
}
if ($defaultNode !== null) {
$this->fatalError($errorNode, "Readonly property `{$className}::\${$name}` cannot have default value");
}
}
$this->validateAsymmetricPropertyDeclaration($name, $flags, $typeNode, $errorNode);
[$type, $class] = $this->resolveTypeDecl($typeNode, self::DECL_TYPE_OF_PROPERTY);
$this->assertSupportedNativeObjectTypeNode($typeNode, self::DECL_TYPE_OF_PROPERTY, $errorNode);
Expand Down
53 changes: 53 additions & 0 deletions src/Translator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3354,6 +3354,21 @@ public function composeTraitAst(Node\Stmt\ClassLike $stmt, Node\Name $className)
}
continue;
}
// A trait property keeps its own declaration: the
// consuming class's `readonly` modifier does not
// upgrade it, so Zend refuses to compose a
// non-readonly (or static, which can never be
// readonly) trait property into a readonly class.
// Zend names the directly used trait, even when
// the property originated in a nested trait.
if (($classDef->flags & Modifiers::READONLY)
&& !($traitStmt->flags & Modifiers::READONLY)
) {
$this->fatalError(
$traitStmt,
"Readonly class `{$compositionOwner}` cannot use trait with a non-readonly property `{$traitFullName}::\${$prop->name->toString()}`",
);
}
$traitProperties[$propName] = [$traitStmt, $prop];
}
}
Expand Down Expand Up @@ -4070,9 +4085,30 @@ protected function parseClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_
if ($parent->flags & Modifiers::FINAL) {
$this->fatalError($class, "Class `{$this->class}` cannot extend final class `{$parentClass}`");
}
// Readonly-ness is part of the inheritance contract in both
// directions (Zend: a readonly class seals its property
// semantics for the whole hierarchy).
$this->assertReadonlyInheritanceContract(
$class,
$parentClass,
(bool) ($parent->flags & Modifiers::READONLY),
);
} else {
$this->fatalError($class, "Class `{$this->class}` inherits from a non-existent class `{$parentClass}`");
}
} elseif ($this->classDef->extends and $this->classDef->inheritedFromInternalClass and $class instanceof Node\Stmt\Class_) {
// Internal parents are not in the symbol table; the host runtime's
// reflection is authoritative for their readonly-ness (e.g.
// BcMath\Number is an internal readonly class, ArrayObject is not),
// so the contract holds in both directions here as well.
$parentClass = $this->getNamespacedClassName($this->parseIdentifier($class->extends));
if (class_exists($parentClass)) {
$this->assertReadonlyInheritanceContract(
$class,
$parentClass,
(new \ReflectionClass($parentClass))->isReadOnly(),
);
}
}

if (is_array($this->classDef->implements)) {
Expand Down Expand Up @@ -6176,6 +6212,23 @@ protected function parseTraitUse(Node\Stmt\TraitUse $v, array &$methodCodes): vo
}
}

/**
* Zend seals readonly-ness across a class hierarchy in both directions: a
* readonly class cannot extend a non-readonly one and vice versa. The
* parent's readonly-ness comes from the symbol table for compiled classes
* and from host reflection for internal ones.
*/
private function assertReadonlyInheritanceContract(NodeAbstract $errorNode, string $parentClass, bool $parentReadonly): void
{
$childReadonly = (bool) ($this->classDef->flags & Modifiers::READONLY);
if ($childReadonly === $parentReadonly) {
return;
}
$this->fatalError($errorNode, $parentReadonly
? "Non-readonly class `{$this->class}` cannot extend readonly class `{$parentClass}`"
: "Readonly class `{$this->class}` cannot extend non-readonly class `{$parentClass}`");
}

private function installComposedTraitDataMembers(Node\Stmt\ClassLike $class): void
{
foreach ($class->stmts as $stmt) {
Expand Down
1 change: 0 additions & 1 deletion tests/compiler/class/readonly-class.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ Readonly Classes (PHP 8.2+)
<?php

// Test basic readonly class
#[\AllowDynamicProperties]
readonly class Point {
public int $x;
public int $y;
Expand Down
Loading