From ba0c76c6f5c8afa8622e87a155b8b99f453d6453 Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Fri, 10 Oct 2025 23:14:14 +0200 Subject: [PATCH 1/2] feat(doctrine): Remove PUT & PATCH for readonly entity --- CHANGELOG.md | 6 ++ ...neOrmResourceCollectionMetadataFactory.php | 15 +++- .../Tests/Fixtures/Entity/DummyReadOnly.php | 53 ++++++++++++ ...mResourceCollectionMetadataFactoryTest.php | 48 +++++++++++ .../TestBundle/Entity/DummyReadOnly.php | 53 ++++++++++++ .../Doctrine/EntityReadOnlyTest.php | 84 +++++++++++++++++++ 6 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 src/Doctrine/Orm/Tests/Fixtures/Entity/DummyReadOnly.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyReadOnly.php create mode 100644 tests/Functional/Doctrine/EntityReadOnlyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ad8a2945d..e571912fd0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +* [6067febe4](https://github.com/api-platform/core/commit/6067febe40d529ed8d30f85069ca957f87771f6b) feat(doctrine): Remove PUT & PATCH for readonly entity (#7019) + ## v4.2.2 ### Bug fixes diff --git a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php index 77155723a89..d2e54dd4ae0 100644 --- a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php +++ b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php @@ -19,6 +19,8 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Util\StateOptionsTrait; @@ -44,10 +46,19 @@ public function create(string $resourceClass): ResourceMetadataCollection $operations = $resourceMetadata->getOperations(); if ($operations) { - foreach ($resourceMetadata->getOperations() as $operationName => $operation) { + foreach ($operations as $operationName => $operation) { $entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class); - if (!$this->managerRegistry->getManagerForClass($entityClass) instanceof EntityManagerInterface) { + $manager = $this->managerRegistry->getManagerForClass($entityClass); + if (!$manager instanceof EntityManagerInterface) { + continue; + } + + $classMetadata = $manager->getClassMetadata($entityClass); + // @see https://www.doctrine-project.org/projects/doctrine-orm/en/3.5/reference/improving-performance.html#read-only-entities + // Read-Only allows to persist new entities of a kind and remove existing ones, they are just not considered for updates. + if ($classMetadata->isReadOnly && ($operation instanceof Put || $operation instanceof Patch)) { + $operations->remove($operationName); continue; } diff --git a/src/Doctrine/Orm/Tests/Fixtures/Entity/DummyReadOnly.php b/src/Doctrine/Orm/Tests/Fixtures/Entity/DummyReadOnly.php new file mode 100644 index 00000000000..212e421054a --- /dev/null +++ b/src/Doctrine/Orm/Tests/Fixtures/Entity/DummyReadOnly.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity(readOnly: true)] +class DummyReadOnly +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + + #[ORM\Column(type: 'string')] + private string $name; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php b/src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php index 966b8982cf4..7ec1ae7fb2d 100644 --- a/src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php +++ b/src/Doctrine/Orm/Tests/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactoryTest.php @@ -17,15 +17,20 @@ use ApiPlatform\Doctrine\Orm\State\CollectionProvider; use ApiPlatform\Doctrine\Orm\State\ItemProvider; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\DummyReadOnly; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -66,6 +71,7 @@ public function testWithoutManager(): void public function testWithProvider(HttpOperation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void { $objectManager = $this->prophesize(EntityManagerInterface::class); + $objectManager->getClassMetadata($operation->getClass())->willReturn(new ClassMetadata(Dummy::class)); $managerRegistry = $this->prophesize(ManagerRegistry::class); $managerRegistry->getManagerForClass($operation->getClass())->willReturn($objectManager->reveal()); $resourceMetadataCollectionFactory = new DoctrineOrmResourceCollectionMetadataFactory($managerRegistry->reveal(), $this->getResourceMetadataCollectionFactory($operation)); @@ -85,4 +91,46 @@ public static function operationProvider(): iterable yield [(new GetCollection())->withOperation($default), CollectionProvider::class, 'api_platform.doctrine.orm.state.persist_processor']; yield [(new Delete())->withOperation($default), ItemProvider::class, 'api_platform.doctrine.orm.state.remove_processor']; } + + public function testReadOnlyEntitiesShouldNotIncludeUpdateOperations(): void + { + $objectManager = $this->createMock(EntityManagerInterface::class); + $readOnlyMetadata = new ClassMetadata(DummyReadOnly::class); + $readOnlyMetadata->markReadOnly(); + $objectManager->method('getClassMetadata')->willReturn($readOnlyMetadata); + $managerRegistry = $this->createMock(ManagerRegistry::class); + $managerRegistry->method('getManagerForClass')->with(DummyReadOnly::class)->willReturn($objectManager); + + $resourceMetadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory + ->method('create') + ->with(DummyReadOnly::class) + ->willReturn(new ResourceMetadataCollection(DummyReadOnly::class, [ + (new ApiResource()) + ->withOperations( + new Operations([ + 'get' => (new Get())->withClass(DummyReadOnly::class), + 'get_collection' => (new GetCollection())->withClass(DummyReadOnly::class), + 'post' => (new Post())->withClass(DummyReadOnly::class), + 'put' => (new Put())->withClass(DummyReadOnly::class), + 'patch' => (new Patch())->withClass(DummyReadOnly::class), + 'delete' => (new Delete())->withClass(DummyReadOnly::class), + ]) + ), + ])); + + $resourceMetadataCollectionFactory = new DoctrineOrmResourceCollectionMetadataFactory($managerRegistry, $resourceMetadataCollectionFactory); + + $resourceMetadataCollection = $resourceMetadataCollectionFactory->create(DummyReadOnly::class); + /** @var ApiResource $apiResource */ + $apiResource = $resourceMetadataCollection->getIterator()->current(); + $operations = $apiResource->getOperations(); + $this->assertNotNull($operations); + $this->assertTrue($operations->has('get')); + $this->assertTrue($operations->has('get_collection')); + $this->assertTrue($operations->has('post')); + $this->assertFalse($operations->has('put')); + $this->assertFalse($operations->has('path')); + $this->assertTrue($operations->has('delete')); + } } diff --git a/tests/Fixtures/TestBundle/Entity/DummyReadOnly.php b/tests/Fixtures/TestBundle/Entity/DummyReadOnly.php new file mode 100644 index 00000000000..7397a58de21 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyReadOnly.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity(readOnly: true)] +class DummyReadOnly +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; + + #[ORM\Column(type: 'string')] + private string $name; + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } +} diff --git a/tests/Functional/Doctrine/EntityReadOnlyTest.php b/tests/Functional/Doctrine/EntityReadOnlyTest.php new file mode 100644 index 00000000000..106da2f173b --- /dev/null +++ b/tests/Functional/Doctrine/EntityReadOnlyTest.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyReadOnly; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EntityReadOnlyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyReadOnly::class]; + } + + public function testCannotUpdateOrPatchReadonlyEntity(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('This test is not for MongoDB.'); + } + + $this->recreateSchema([DummyReadOnly::class]); + $manager = static::getContainer()->get('doctrine')->getManager(); + + $dummy = new DummyReadOnly(); + $dummy->setName('foo'); + $manager->persist($dummy); + $manager->flush(); + + $client = static::createClient(); + $response = $client->request('GET', '/dummy_read_onlies', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseStatusCodeSame(200); + + $response = $client->request('GET', '/dummy_read_onlies/'.$dummy->getId(), ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseStatusCodeSame(200); + + $response = $client->request('POST', '/dummy_read_onlies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'bar', + ], + ]); + $this->assertResponseStatusCodeSame(201); + + $response = $client->request('DELETE', '/dummy_read_onlies/'.$dummy->getId(), ['headers' => ['Content-Type' => 'application/ld+json']]); + $this->assertResponseStatusCodeSame(204); + + $response = $client->request('PUT', '/dummy_read_onlies'.$dummy->getId(), [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'baz', + ], + ]); + $this->assertResponseStatusCodeSame(404); + + $response = $client->request('PATCH', '/dummy_read_onlies'.$dummy->getId(), [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'baz', + ], + ]); + $this->assertResponseStatusCodeSame(404); + } +} From 610265f9c1b38e10f99bc12ba905986b34c5b78e Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Sun, 12 Oct 2025 14:15:09 +0200 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e571912fd0c..9b99298c9e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -* [6067febe4](https://github.com/api-platform/core/commit/6067febe40d529ed8d30f85069ca957f87771f6b) feat(doctrine): Remove PUT & PATCH for readonly entity (#7019) +* [ba0c76c](https://github.com/api-platform/core/commit/ba0c76c6f5c8afa8622e87a155b8b99f453d6453) feat(doctrine): remove put & path for readonly entity (#7019) ## v4.2.2