diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index b89106eb7c..656d82ca21 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -143,6 +143,10 @@ private function getDefaultParameters(Operation $operation, string $resourceClas continue; } + if (!$parameter->getKey()) { + $parameter = $parameter->withKey($key); + } + ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass, $parameter); $parameter = $parameter->withProperties($propertyNames); @@ -170,7 +174,7 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $parameter = $parameter->withProvider($f->getParameterProvider()); } - $key = $parameter->getKey() ?? $key; + $key = $parameter->getKey(); ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass, $parameter); diff --git a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php index cd0c73c569..615dc1ce8f 100644 --- a/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/ParameterResourceMetadataCollectionFactoryTest.php @@ -14,7 +14,9 @@ namespace ApiPlatform\Metadata\Tests\Resource\Factory; use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -36,8 +38,12 @@ public function testParameterFactory(): void $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'hydra', 'everywhere'])); $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); $propertyMetadata->method('create')->willReturnOnConsecutiveCalls( - new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true), - new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true) + new ApiProperty(identifier: true), + new ApiProperty(readable: true), + new ApiProperty(readable: true), + new ApiProperty(identifier: true), + new ApiProperty(readable: true), + new ApiProperty(readable: true) ); $filterLocator = $this->createStub(ContainerInterface::class); $filterLocator->method('has')->willReturn(true); @@ -77,14 +83,71 @@ public function getDescription(string $resourceClass): array $this->assertNull($everywhere->getOpenApi()); } + public function testQueryParameterWithPropertyPlaceholder(): void + { + $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'name', 'description'])); + + $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadata->method('create')->willReturn( + new ApiProperty(readable: true), + ); + + $filterLocator = $this->createStub(ContainerInterface::class); + $filterLocator->method('has')->willReturn(false); // No specific filter logic needed for this test + + $parameterFactory = new ParameterResourceMetadataCollectionFactory( + $nameCollection, + $propertyMetadata, + new AttributesResourceMetadataCollectionFactory(), + $filterLocator + ); + + $resourceMetadataCollection = $parameterFactory->create(HasParameterAttribute::class); + $operation = $resourceMetadataCollection->getOperation(forceCollection: true); + $parameters = $operation->getParameters(); + + $this->assertInstanceOf(Parameters::class, $parameters); + + // Assert that the original parameter with ':property' is removed + $this->assertFalse($parameters->has('search[:property]')); + + // Assert that the new parameters are created and have the correct properties + $this->assertTrue($parameters->has('search[name]')); + $this->assertTrue($parameters->has('search[description]')); + $this->assertTrue($parameters->has('static_param')); + + $searchNameParam = $parameters->get('search[name]'); + $this->assertInstanceOf(QueryParameter::class, $searchNameParam); + $this->assertSame('Search by property', $searchNameParam->getDescription()); + $this->assertSame('name', $searchNameParam->getProperty()); + $this->assertSame('search[name]', $searchNameParam->getKey()); + + $searchDescriptionParam = $parameters->get('search[description]'); + $this->assertInstanceOf(QueryParameter::class, $searchDescriptionParam); + $this->assertSame('Search by property', $searchDescriptionParam->getDescription()); + $this->assertSame('description', $searchDescriptionParam->getProperty()); + $this->assertSame('search[description]', $searchDescriptionParam->getKey()); + + $staticParam = $parameters->get('static_param'); + $this->assertInstanceOf(QueryParameter::class, $staticParam); + $this->assertSame('A static parameter', $staticParam->getDescription()); + $this->assertNull($staticParam->getProperty()); + $this->assertSame('static_param', $staticParam->getKey()); + } + public function testParameterFactoryNoFilter(): void { $nameCollection = $this->createStub(PropertyNameCollectionFactoryInterface::class); $nameCollection->method('create')->willReturn(new PropertyNameCollection(['id', 'hydra', 'everywhere'])); $propertyMetadata = $this->createStub(PropertyMetadataFactoryInterface::class); $propertyMetadata->method('create')->willReturnOnConsecutiveCalls( - new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true), - new ApiProperty(identifier: true), new ApiProperty(readable: true), new ApiProperty(readable: true) + new ApiProperty(identifier: true), + new ApiProperty(readable: true), + new ApiProperty(readable: true), + new ApiProperty(identifier: true), + new ApiProperty(readable: true), + new ApiProperty(readable: true) ); $filterLocator = $this->createStub(ContainerInterface::class); $filterLocator->method('has')->willReturn(false); @@ -135,3 +198,25 @@ public function testParameterFactoryWithLimitedProperties(): void $this->assertSame(['name'], $param->getProperties()); } } + +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'search[:property]' => new QueryParameter( + description: 'Search by property', + properties: ['name', 'description'] + ), + 'static_param' => new QueryParameter( + description: 'A static parameter' + ), + ] + ), + ] +)] +class HasParameterAttribute +{ + public $id; + public $name; + public $description; +} diff --git a/tests/Fixtures/TestBundle/Entity/ProductWithQueryParameter.php b/tests/Fixtures/TestBundle/Entity/ProductWithQueryParameter.php new file mode 100644 index 0000000000..9c8d143ae5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ProductWithQueryParameter.php @@ -0,0 +1,87 @@ + + * + * 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\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'brand' => new QueryParameter( + filter: new ExactFilter(), + ), + 'search[:property]' => new QueryParameter( + filter: new PartialSearchFilter(), + properties: ['title', 'description'] + ), + 'filter[:property]' => new QueryParameter( + filter: new ExactFilter(), + properties: ['category', 'brand'], + ), + 'order[:property]' => new QueryParameter( + filter: new OrderFilter(), + properties: ['rating'] + ), + ] + ), + ] +)] +class ProductWithQueryParameter +{ + #[ORM\Id] + #[ORM\Column()] + #[ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\Column(length: 255)] + public ?string $sku = null; + + #[ORM\Column(length: 255)] + public ?string $title = null; + + #[ORM\Column(nullable: true)] + public ?string $description = null; + + #[ORM\Column(nullable: true)] + public ?string $category = null; + + #[ORM\Column(nullable: true)] + public ?string $brand = null; + + #[ORM\Column(nullable: true)] + public ?float $exactPrice = null; + + #[ORM\Column()] + public int $rating = 0; + + #[ORM\Column()] + public int $stock = 0; + + #[ORM\Column(type: Types::JSON, nullable: true, options: ['jsonb' => true])] + public array $tags = []; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index 0b10f44d80..33846f4fd3 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FilterWithStateOptions; use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameter as SearchFilterParameterDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ProductWithQueryParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -34,7 +35,7 @@ final class DoctrineTest extends ApiTestCase */ public static function getResources(): array { - return [SearchFilterParameter::class, FilterWithStateOptions::class]; + return [SearchFilterParameter::class, FilterWithStateOptions::class, ProductWithQueryParameter::class]; } public function testDoctrineEntitySearchFilter(): void @@ -195,7 +196,50 @@ public static function partialFilterParameterProviderForSearchFilterParameter(): ]; } - public function loadFixtures(string $resourceClass): void + public function testQueryParameterWithPropertyArgument(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $resource = ProductWithQueryParameter::class; + $this->recreateSchema([$resource]); + $this->loadProductFixtures($resource); + + // Test search[:property] with 'title' + $response = self::createClient()->request('GET', '/product_with_query_parameters?search[title]=Awesome'); + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response->toArray()['hydra:member']); + $this->assertEquals('Awesome Widget', $response->toArray()['hydra:member'][0]['title']); + + // Test search[:property] with 'description' + $response = self::createClient()->request('GET', '/product_with_query_parameters?search[description]=super'); + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response->toArray()['hydra:member']); + $this->assertEquals('Super Gadget', $response->toArray()['hydra:member'][0]['title']); + + // Test filter[:property] with 'category' + $response = self::createClient()->request('GET', '/product_with_query_parameters?filter[category]=Electronics'); + $this->assertResponseIsSuccessful(); + $this->assertCount(2, $response->toArray()['hydra:member']); + + // Test filter[:property] with 'brand' + $response = self::createClient()->request('GET', '/product_with_query_parameters?filter[brand]=BrandY'); + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response->toArray()['hydra:member']); + $this->assertEquals('Super Gadget', $response->toArray()['hydra:member'][0]['title']); + + // Test order[:property] with 'rating' + $response = self::createClient()->request('GET', '/product_with_query_parameters?order[rating]=desc'); + $this->assertResponseIsSuccessful(); + $members = $response->toArray()['hydra:member']; + $this->assertCount(3, $members); + $this->assertEquals('Awesome Widget', $members[0]['title']); + $this->assertEquals('Super Gadget', $members[1]['title']); + $this->assertEquals('Mega Device', $members[2]['title']); + } + + private function loadFixtures(string $resourceClass): void { $container = static::$kernel->getContainer(); $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); @@ -214,4 +258,46 @@ public function loadFixtures(string $resourceClass): void $manager->flush(); } + + private function loadProductFixtures(string $resourceClass): void + { + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); + + $product1 = new $resourceClass(); + $product1->sku = 'SKU001'; + $product1->title = 'Awesome Widget'; + $product1->description = 'A really awesome widget.'; + $product1->category = 'Electronics'; + $product1->brand = 'BrandX'; + $product1->rating = 5; + $product1->stock = 100; + $product1->tags = ['new', 'sale']; + $manager->persist($product1); + + $product2 = new $resourceClass(); + $product2->sku = 'SKU002'; + $product2->title = 'Super Gadget'; + $product2->description = 'A super cool gadget.'; + $product2->category = 'Electronics'; + $product2->brand = 'BrandY'; + $product2->rating = 4; + $product2->stock = 50; + $product2->tags = ['popular']; + $manager->persist($product2); + + $product3 = new $resourceClass(); + $product3->sku = 'SKU003'; + $product3->title = 'Mega Device'; + $product3->description = 'A mega useful device.'; + $product3->category = 'Home'; + $product3->brand = 'BrandX'; + $product3->rating = 3; + $product3->stock = 200; + $product3->tags = ['clearance']; + $manager->persist($product3); + + $manager->flush(); + } }