Skip to content

Commit 580b919

Browse files
authored
Support readonly properties for read operations (#9316)
* Provide failing test for readonly properties * Skip writing readonly properties if the value did not change
1 parent 0d911b9 commit 580b919

File tree

9 files changed

+374
-9
lines changed

9 files changed

+374
-9
lines changed

lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,7 @@ class ClassMetadataInfo implements ClassMetadata
705705
/**
706706
* The ReflectionProperty instances of the mapped class.
707707
*
708-
* @var ReflectionProperty[]|null[]
708+
* @var array<string, ReflectionProperty|null>
709709
*/
710710
public $reflFields = [];
711711

@@ -993,7 +993,8 @@ public function wakeupReflection($reflService)
993993

994994
foreach ($this->embeddedClasses as $property => $embeddedClass) {
995995
if (isset($embeddedClass['declaredField'])) {
996-
$childProperty = $reflService->getAccessibleProperty(
996+
$childProperty = $this->getAccessibleProperty(
997+
$reflService,
997998
$this->embeddedClasses[$embeddedClass['declaredField']]['class'],
998999
$embeddedClass['originalField']
9991000
);
@@ -1007,7 +1008,8 @@ public function wakeupReflection($reflService)
10071008
continue;
10081009
}
10091010

1010-
$fieldRefl = $reflService->getAccessibleProperty(
1011+
$fieldRefl = $this->getAccessibleProperty(
1012+
$reflService,
10111013
$embeddedClass['declared'] ?? $this->name,
10121014
$property
10131015
);
@@ -1020,15 +1022,15 @@ public function wakeupReflection($reflService)
10201022
if (isset($mapping['declaredField']) && isset($parentReflFields[$mapping['declaredField']])) {
10211023
$this->reflFields[$field] = new ReflectionEmbeddedProperty(
10221024
$parentReflFields[$mapping['declaredField']],
1023-
$reflService->getAccessibleProperty($mapping['originalClass'], $mapping['originalField']),
1025+
$this->getAccessibleProperty($reflService, $mapping['originalClass'], $mapping['originalField']),
10241026
$mapping['originalClass']
10251027
);
10261028
continue;
10271029
}
10281030

10291031
$this->reflFields[$field] = isset($mapping['declared'])
1030-
? $reflService->getAccessibleProperty($mapping['declared'], $field)
1031-
: $reflService->getAccessibleProperty($this->name, $field);
1032+
? $this->getAccessibleProperty($reflService, $mapping['declared'], $field)
1033+
: $this->getAccessibleProperty($reflService, $this->name, $field);
10321034

10331035
if (isset($mapping['enumType']) && $this->reflFields[$field] !== null) {
10341036
$this->reflFields[$field] = new ReflectionEnumProperty(
@@ -1040,8 +1042,8 @@ public function wakeupReflection($reflService)
10401042

10411043
foreach ($this->associationMappings as $field => $mapping) {
10421044
$this->reflFields[$field] = isset($mapping['declared'])
1043-
? $reflService->getAccessibleProperty($mapping['declared'], $field)
1044-
: $reflService->getAccessibleProperty($this->name, $field);
1045+
? $this->getAccessibleProperty($reflService, $mapping['declared'], $field)
1046+
: $this->getAccessibleProperty($reflService, $this->name, $field);
10451047
}
10461048
}
10471049

@@ -3779,4 +3781,17 @@ private function assertMappingOrderBy(array $mapping): void
37793781
throw new InvalidArgumentException("'orderBy' is expected to be an array, not " . gettype($mapping['orderBy']));
37803782
}
37813783
}
3784+
3785+
/**
3786+
* @psalm-param class-string $class
3787+
*/
3788+
private function getAccessibleProperty(ReflectionService $reflService, string $class, string $field): ?ReflectionProperty
3789+
{
3790+
$reflectionProperty = $reflService->getAccessibleProperty($class, $field);
3791+
if ($reflectionProperty !== null && PHP_VERSION_ID >= 80100 && $reflectionProperty->isReadOnly()) {
3792+
$reflectionProperty = new ReflectionReadonlyProperty($reflectionProperty);
3793+
}
3794+
3795+
return $reflectionProperty;
3796+
}
37823797
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\ORM\Mapping;
6+
7+
use InvalidArgumentException;
8+
use LogicException;
9+
use ReflectionProperty;
10+
11+
use function assert;
12+
use function func_get_args;
13+
use function func_num_args;
14+
use function is_object;
15+
use function sprintf;
16+
17+
/**
18+
* @internal
19+
*/
20+
final class ReflectionReadonlyProperty extends ReflectionProperty
21+
{
22+
public function __construct(
23+
private ReflectionProperty $wrappedProperty
24+
) {
25+
if (! $wrappedProperty->isReadOnly()) {
26+
throw new InvalidArgumentException('Given property is not readonly.');
27+
}
28+
29+
parent::__construct($wrappedProperty->class, $wrappedProperty->name);
30+
}
31+
32+
public function getValue(?object $object = null): mixed
33+
{
34+
return $this->wrappedProperty->getValue(...func_get_args());
35+
}
36+
37+
public function setValue(mixed $objectOrValue, mixed $value = null): void
38+
{
39+
if (func_num_args() < 2 || $objectOrValue === null || ! $this->isInitialized($objectOrValue)) {
40+
$this->wrappedProperty->setValue(...func_get_args());
41+
42+
return;
43+
}
44+
45+
assert(is_object($objectOrValue));
46+
47+
if (parent::getValue($objectOrValue) !== $value) {
48+
throw new LogicException(sprintf('Attempting to change readonly property %s::$%s.', $this->class, $this->name));
49+
}
50+
}
51+
}

psalm-baseline.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,7 +820,7 @@
820820
<code>$parentReflFields[$embeddedClass['declaredField']]</code>
821821
<code>$parentReflFields[$mapping['declaredField']]</code>
822822
<code>$queryMapping['resultClass']</code>
823-
<code>$reflService-&gt;getAccessibleProperty($mapping['originalClass'], $mapping['originalField'])</code>
823+
<code>$this-&gt;getAccessibleProperty($reflService, $mapping['originalClass'], $mapping['originalField'])</code>
824824
</PossiblyNullArgument>
825825
<PossiblyNullPropertyFetch occurrences="2">
826826
<code>$embeddable-&gt;reflClass-&gt;name</code>

psalm.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@
7575
<referencedClass name="Doctrine\DBAL\Platforms\PostgreSQLPlatform" />
7676
</errorLevel>
7777
</InvalidClass>
78+
<MethodSignatureMismatch>
79+
<errorLevel type="suppress">
80+
<!-- See https://github.com/vimeo/psalm/issues/7357 -->
81+
<file name="lib/Doctrine/ORM/Mapping/ReflectionReadonlyProperty.php"/>
82+
</errorLevel>
83+
</MethodSignatureMismatch>
7884
<MissingDependency>
7985
<errorLevel type="suppress">
8086
<!-- DBAL 3.2 forward compatibility -->
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Tests\Models\ReadonlyProperties;
6+
7+
use Doctrine\ORM\Mapping\Column;
8+
use Doctrine\ORM\Mapping\Entity;
9+
use Doctrine\ORM\Mapping\GeneratedValue;
10+
use Doctrine\ORM\Mapping\Id;
11+
use Doctrine\ORM\Mapping\Table;
12+
13+
#[Entity, Table(name: 'author')]
14+
class Author
15+
{
16+
#[Column, Id, GeneratedValue]
17+
private readonly int $id;
18+
19+
#[Column]
20+
private readonly string $name;
21+
22+
public function getId(): int
23+
{
24+
return $this->id;
25+
}
26+
27+
public function getName(): string
28+
{
29+
return $this->name;
30+
}
31+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Tests\Models\ReadonlyProperties;
6+
7+
use Doctrine\Common\Collections\ArrayCollection;
8+
use Doctrine\Common\Collections\Collection;
9+
use Doctrine\ORM\Mapping\Column;
10+
use Doctrine\ORM\Mapping\Entity;
11+
use Doctrine\ORM\Mapping\GeneratedValue;
12+
use Doctrine\ORM\Mapping\Id;
13+
use Doctrine\ORM\Mapping\JoinTable;
14+
use Doctrine\ORM\Mapping\ManyToMany;
15+
use Doctrine\ORM\Mapping\Table;
16+
17+
#[Entity, Table(name: 'book')]
18+
class Book
19+
{
20+
#[Column, Id, GeneratedValue]
21+
private readonly int $id;
22+
23+
#[Column]
24+
private readonly string $title;
25+
26+
#[ManyToMany(targetEntity: Author::class), JoinTable(name: 'book_author')]
27+
private readonly Collection $authors;
28+
29+
public function __construct()
30+
{
31+
$this->authors = new ArrayCollection();
32+
}
33+
34+
public function getId(): int
35+
{
36+
return $this->id;
37+
}
38+
39+
public function getTitle(): string
40+
{
41+
return $this->title;
42+
}
43+
44+
/**
45+
* @return list<Author>
46+
*/
47+
public function getAuthors(): array
48+
{
49+
return $this->authors->getValues();
50+
}
51+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Tests\Models\ReadonlyProperties;
6+
7+
use Doctrine\ORM\Mapping\Column;
8+
use Doctrine\ORM\Mapping\Entity;
9+
use Doctrine\ORM\Mapping\GeneratedValue;
10+
use Doctrine\ORM\Mapping\Id;
11+
use Doctrine\ORM\Mapping\JoinColumn;
12+
use Doctrine\ORM\Mapping\ManyToOne;
13+
use Doctrine\ORM\Mapping\Table;
14+
15+
#[Entity, Table(name: 'simple_book')]
16+
class SimpleBook
17+
{
18+
#[Column, Id, GeneratedValue]
19+
private readonly int $id;
20+
21+
#[Column]
22+
private readonly string $title;
23+
24+
#[ManyToOne, JoinColumn(nullable: false)]
25+
private readonly Author $author;
26+
27+
public function getId(): int
28+
{
29+
return $this->id;
30+
}
31+
32+
public function getTitle(): string
33+
{
34+
return $this->title;
35+
}
36+
37+
public function getAuthor(): Author
38+
{
39+
return $this->author;
40+
}
41+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Tests\ORM\Functional;
6+
7+
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
8+
use Doctrine\ORM\Tools\SchemaTool;
9+
use Doctrine\Tests\Models\ReadonlyProperties\Author;
10+
use Doctrine\Tests\Models\ReadonlyProperties\Book;
11+
use Doctrine\Tests\Models\ReadonlyProperties\SimpleBook;
12+
use Doctrine\Tests\OrmFunctionalTestCase;
13+
use Doctrine\Tests\TestUtil;
14+
15+
use function dirname;
16+
17+
/**
18+
* @requires PHP 8.1
19+
*/
20+
class ReadonlyPropertiesTest extends OrmFunctionalTestCase
21+
{
22+
protected function setUp(): void
23+
{
24+
if (! isset(static::$sharedConn)) {
25+
static::$sharedConn = TestUtil::getConnection();
26+
}
27+
28+
$this->_em = $this->getEntityManager(null, new AttributeDriver(
29+
[dirname(__DIR__, 2) . '/Models/ReadonlyProperties']
30+
));
31+
$this->_schemaTool = new SchemaTool($this->_em);
32+
33+
parent::setUp();
34+
35+
$this->setUpEntitySchema([Author::class, Book::class, SimpleBook::class]);
36+
}
37+
38+
public function testSimpleEntity(): void
39+
{
40+
$connection = $this->_em->getConnection();
41+
42+
$connection->insert('author', ['name' => 'Jane Austen']);
43+
$authorId = $connection->lastInsertId();
44+
45+
$author = $this->_em->find(Author::class, $authorId);
46+
47+
self::assertSame('Jane Austen', $author->getName());
48+
self::assertEquals($authorId, $author->getId());
49+
}
50+
51+
public function testEntityWithLazyManyToOne(): void
52+
{
53+
$connection = $this->_em->getConnection();
54+
55+
$connection->insert('author', ['name' => 'Jane Austen']);
56+
$authorId = $connection->lastInsertId();
57+
58+
$connection->insert('simple_book', ['title' => 'Pride and Prejudice', 'author_id' => $authorId]);
59+
$bookId = $connection->lastInsertId();
60+
61+
$book = $this->_em->find(SimpleBook::class, $bookId);
62+
63+
self::assertSame('Pride and Prejudice', $book->getTitle());
64+
self::assertEquals($bookId, $book->getId());
65+
self::assertSame('Jane Austen', $book->getAuthor()->getName());
66+
}
67+
68+
public function testEntityWithEagerManyToOne(): void
69+
{
70+
$connection = $this->_em->getConnection();
71+
72+
$connection->insert('author', ['name' => 'Jane Austen']);
73+
$authorId = $connection->lastInsertId();
74+
75+
$connection->insert('simple_book', ['title' => 'Pride and Prejudice', 'author_id' => $authorId]);
76+
$bookId = $connection->lastInsertId();
77+
78+
[$book] = $this->_em->createQueryBuilder()
79+
->from(SimpleBook::class, 'b')
80+
->join('b.author', 'a')
81+
->select(['b', 'a'])
82+
->where('b.id = :id')
83+
->setParameter('id', $bookId)
84+
->getQuery()
85+
->execute();
86+
87+
self::assertInstanceOf(SimpleBook::class, $book);
88+
self::assertSame('Pride and Prejudice', $book->getTitle());
89+
self::assertEquals($bookId, $book->getId());
90+
self::assertSame('Jane Austen', $book->getAuthor()->getName());
91+
}
92+
93+
public function testEntityWithManyToMany(): void
94+
{
95+
$connection = $this->_em->getConnection();
96+
97+
$connection->insert('author', ['name' => 'Jane Austen']);
98+
$authorId = $connection->lastInsertId();
99+
100+
$connection->insert('book', ['title' => 'Pride and Prejudice']);
101+
$bookId = $connection->lastInsertId();
102+
103+
$connection->insert('book_author', ['book_id' => $bookId, 'author_id' => $authorId]);
104+
105+
$book = $this->_em->find(Book::class, $bookId);
106+
107+
self::assertSame('Pride and Prejudice', $book->getTitle());
108+
self::assertEquals($bookId, $book->getId());
109+
self::assertSame('Jane Austen', $book->getAuthors()[0]->getName());
110+
}
111+
}

0 commit comments

Comments
 (0)