diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index da8ee0a5a1..b910418766 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -267,7 +267,8 @@ public function register(): void ), $app->make('filters'), $app->make(CamelCaseToSnakeCaseNameConverter::class), - $this->app->make(LoggerInterface::class) + $this->app->make(LoggerInterface::class), + $app->make(ResourceClassResolverInterface::class), ), $app->make('filters') ) diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 99219d150e..5c0db818d7 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -27,6 +27,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; @@ -55,7 +56,9 @@ public function __construct( private readonly ?ContainerInterface $filterLocator = null, private readonly ?NameConverterInterface $nameConverter = null, private readonly ?LoggerInterface $logger = null, + ?ResourceClassResolverInterface $resourceClassResolver = null, ) { + $this->resourceClassResolver = $resourceClassResolver; } public function create(string $resourceClass): ResourceMetadataCollection diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.php b/src/Symfony/Bundle/Resources/config/metadata/resource.php index eb11e227e6..ef3481bafc 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.php +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.php @@ -152,6 +152,7 @@ service('api_platform.filter_locator')->ignoreOnInvalid(), service('api_platform.name_converter')->ignoreOnInvalid(), service('logger')->ignoreOnInvalid(), + service('api_platform.resource_class_resolver')->ignoreOnInvalid(), ]); $services->set('api_platform.metadata.resource.metadata_collection_factory.cached', CachedResourceMetadataCollectionFactory::class) diff --git a/tests/Fixtures/TestBundle/Document/FreeTextArticle.php b/tests/Fixtures/TestBundle/Document/FreeTextArticle.php new file mode 100644 index 0000000000..a57d8b88ac --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FreeTextArticle.php @@ -0,0 +1,77 @@ + + * + * 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\Document; + +use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; +use ApiPlatform\Doctrine\Odm\Filter\OrFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + operations: [ + new GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'search' => new QueryParameter( + filter: new FreeTextQueryFilter(new OrFilter(new PartialSearchFilter(caseSensitive: true))), + properties: ['content', 'tag.content'], + ), + ], + ), + ] +)] +class FreeTextArticle +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $content; + + #[ODM\ReferenceOne(targetDocument: FreeTextTag::class)] + private ?FreeTextTag $tag = null; + + public function getId(): ?string + { + return $this->id; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): self + { + $this->content = $content; + + return $this; + } + + public function getTag(): ?FreeTextTag + { + return $this->tag; + } + + public function setTag(?FreeTextTag $tag): self + { + $this->tag = $tag; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/FreeTextTag.php b/tests/Fixtures/TestBundle/Document/FreeTextTag.php new file mode 100644 index 0000000000..f302c46ec6 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/FreeTextTag.php @@ -0,0 +1,43 @@ + + * + * 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\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +class FreeTextTag +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $content; + + public function getId(): ?string + { + return $this->id; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): self + { + $this->content = $content; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/PostCard.php b/tests/Fixtures/TestBundle/Document/PostCard.php new file mode 100644 index 0000000000..8216592af7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PostCard.php @@ -0,0 +1,88 @@ + + * + * 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\Document; + +use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; +use ApiPlatform\Doctrine\Odm\Filter\OrFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + shortName: 'PostCard', + operations: [ + new GetCollection( + uriTemplate: '/post_cards_embedded', + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'citySearch' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'address.city', + ), + 'freeSearch' => new QueryParameter( + filter: new FreeTextQueryFilter(new OrFilter(new PartialSearchFilter())), + properties: ['address.city', 'address.street'], + ), + ], + ), + ] +)] +class PostCard +{ + #[ODM\Id(type: 'string', strategy: 'INCREMENT')] + private ?string $id = null; + + #[ODM\Field(type: 'string')] + private string $title; + + #[ODM\EmbedOne(targetDocument: PostCardAddress::class)] + private PostCardAddress $address; + + public function __construct() + { + $this->address = new PostCardAddress(); + } + + public function getId(): ?string + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getAddress(): PostCardAddress + { + return $this->address; + } + + public function setAddress(PostCardAddress $address): self + { + $this->address = $address; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/PostCardAddress.php b/tests/Fixtures/TestBundle/Document/PostCardAddress.php new file mode 100644 index 0000000000..3b05f7c53a --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PostCardAddress.php @@ -0,0 +1,65 @@ + + * + * 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\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\EmbeddedDocument] +class PostCardAddress +{ + #[ODM\Field(type: 'string')] + private string $street; + + #[ODM\Field(type: 'string')] + private string $city; + + #[ODM\Field(type: 'string')] + private string $zipCode; + + public function getStreet(): string + { + return $this->street; + } + + public function setStreet(string $street): self + { + $this->street = $street; + + return $this; + } + + public function getCity(): string + { + return $this->city; + } + + public function setCity(string $city): self + { + $this->city = $city; + + return $this; + } + + public function getZipCode(): string + { + return $this->zipCode; + } + + public function setZipCode(string $zipCode): self + { + $this->zipCode = $zipCode; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/PostCard.php b/tests/Fixtures/TestBundle/Entity/PostCard.php new file mode 100644 index 0000000000..6d00ee4708 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PostCard.php @@ -0,0 +1,90 @@ + + * + * 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\FreeTextQueryFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + shortName: 'PostCard', + operations: [ + new GetCollection( + uriTemplate: '/post_cards_embedded', + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'citySearch' => new QueryParameter( + filter: new PartialSearchFilter(), + property: 'address.city', + ), + 'freeSearch' => new QueryParameter( + filter: new FreeTextQueryFilter(new OrFilter(new PartialSearchFilter())), + properties: ['address.city', 'address.street'], + ), + ], + ), + ] +)] +class PostCard +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(length: 255)] + private string $title; + + #[ORM\Embedded(class: PostCardAddress::class)] + private PostCardAddress $address; + + public function __construct() + { + $this->address = new PostCardAddress(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getAddress(): PostCardAddress + { + return $this->address; + } + + public function setAddress(PostCardAddress $address): self + { + $this->address = $address; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/PostCardAddress.php b/tests/Fixtures/TestBundle/Entity/PostCardAddress.php new file mode 100644 index 0000000000..6b49618714 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PostCardAddress.php @@ -0,0 +1,65 @@ + + * + * 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 Doctrine\ORM\Mapping as ORM; + +#[ORM\Embeddable] +class PostCardAddress +{ + #[ORM\Column(length: 255)] + private string $street; + + #[ORM\Column(length: 255)] + private string $city; + + #[ORM\Column(length: 10)] + private string $zipCode; + + public function getStreet(): string + { + return $this->street; + } + + public function setStreet(string $street): self + { + $this->street = $street; + + return $this; + } + + public function getCity(): string + { + return $this->city; + } + + public function setCity(string $city): self + { + $this->city = $city; + + return $this; + } + + public function getZipCode(): string + { + return $this->zipCode; + } + + public function setZipCode(string $zipCode): self + { + $this->zipCode = $zipCode; + + return $this; + } +} diff --git a/tests/Functional/Parameters/EmbeddedPartialSearchFilterTest.php b/tests/Functional/Parameters/EmbeddedPartialSearchFilterTest.php new file mode 100644 index 0000000000..54b8833a99 --- /dev/null +++ b/tests/Functional/Parameters/EmbeddedPartialSearchFilterTest.php @@ -0,0 +1,133 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\PostCard as DocumentPostCard; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\PostCardAddress as DocumentPostCardAddress; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PostCard; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PostCardAddress; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Tests that PartialSearchFilter and FreeTextQueryFilter work correctly + * with Doctrine embedded objects (ORM\Embedded / ORM\Embeddable). + * + * @see https://github.com/api-platform/core/issues/7862 + */ +final class EmbeddedPartialSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [PostCard::class]; + } + + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentPostCard::class] + : [PostCard::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + public function testPartialSearchOnEmbeddedProperty(): void + { + $response = self::createClient()->request('GET', '/post_cards_embedded?citySearch=Paris'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(1, $data['member']); + $this->assertSame('Greetings from Paris', $data['member'][0]['title']); + } + + public function testPartialSearchOnEmbeddedPropertyPartialMatch(): void + { + $response = self::createClient()->request('GET', '/post_cards_embedded?citySearch=ar'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(1, $data['member']); + } + + public function testFreeTextSearchOnEmbeddedProperties(): void + { + $response = self::createClient()->request('GET', '/post_cards_embedded?freeSearch=Paris'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(1, $data['member']); + $this->assertSame('Greetings from Paris', $data['member'][0]['title']); + } + + public function testFreeTextSearchOnEmbeddedPropertiesMatchesStreet(): void + { + $response = self::createClient()->request('GET', '/post_cards_embedded?freeSearch=Broadway'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(1, $data['member']); + $this->assertSame('Hello from New York', $data['member'][0]['title']); + } + + public function testPartialSearchOnEmbeddedPropertyNoMatch(): void + { + $response = self::createClient()->request('GET', '/post_cards_embedded?citySearch=Tokyo'); + $this->assertResponseIsSuccessful(); + + $data = $response->toArray(); + $this->assertCount(0, $data['member']); + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + $isMongoDB = $this->isMongoDB(); + + $addressClass = $isMongoDB ? DocumentPostCardAddress::class : PostCardAddress::class; + $postCardClass = $isMongoDB ? DocumentPostCard::class : PostCard::class; + + $address1 = new $addressClass(); + $address1->setCity('Paris'); + $address1->setStreet('Champs-Élysées'); + $address1->setZipCode('75008'); + + $postCard1 = new $postCardClass(); + $postCard1->setTitle('Greetings from Paris'); + $postCard1->setAddress($address1); + + $address2 = new $addressClass(); + $address2->setCity('New York'); + $address2->setStreet('Broadway'); + $address2->setZipCode('10001'); + + $postCard2 = new $postCardClass(); + $postCard2->setTitle('Hello from New York'); + $postCard2->setAddress($address2); + + $manager->persist($postCard1); + $manager->persist($postCard2); + $manager->flush(); + } +}