From d70b9086d5219f45abfe97d7000f58937b255afd Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 24 Mar 2026 16:57:30 +0100 Subject: [PATCH] feat(search) : add MultiSearchFilter for OR search on name + reference Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Entity/Composant.php | 2 ++ src/Entity/Piece.php | 2 ++ src/Entity/Product.php | 2 ++ src/Filter/MultiSearchFilter.php | 51 ++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 src/Filter/MultiSearchFilter.php diff --git a/src/Entity/Composant.php b/src/Entity/Composant.php index 656daa1..d04217a 100644 --- a/src/Entity/Composant.php +++ b/src/Entity/Composant.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use App\Entity\Trait\CuidEntityTrait; +use App\Filter\MultiSearchFilter; use App\Repository\ComposantRepository; use App\State\ComposantProcessor; use DateTimeImmutable; @@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Table(name: 'composants')] #[ORM\HasLifecycleCallbacks] #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])] +#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])] #[ApiResource( description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.', diff --git a/src/Entity/Piece.php b/src/Entity/Piece.php index 1d925f4..82c758b 100644 --- a/src/Entity/Piece.php +++ b/src/Entity/Piece.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use App\Entity\Trait\CuidEntityTrait; +use App\Filter\MultiSearchFilter; use App\Repository\PieceRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -29,6 +30,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Table(name: 'pieces')] #[ORM\HasLifecycleCallbacks] #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])] +#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])] #[ApiResource( description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.', diff --git a/src/Entity/Product.php b/src/Entity/Product.php index 2c2aa3f..630b718 100644 --- a/src/Entity/Product.php +++ b/src/Entity/Product.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use App\Entity\Trait\CuidEntityTrait; +use App\Filter\MultiSearchFilter; use App\Repository\ProductRepository; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; @@ -27,6 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ORM\Table(name: 'products')] #[ORM\HasLifecycleCallbacks] #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])] +#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])] #[ApiResource( description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.', diff --git a/src/Filter/MultiSearchFilter.php b/src/Filter/MultiSearchFilter.php new file mode 100644 index 0000000..63d6b49 --- /dev/null +++ b/src/Filter/MultiSearchFilter.php @@ -0,0 +1,51 @@ + [ + 'property' => null, + 'type' => 'string', + 'required' => false, + 'description' => 'Search across: '.implode(', ', array_keys($this->properties ?? [])), + 'openapi' => [ + 'allowEmptyValue' => true, + ], + ], + ]; + } + + protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if ('search' !== $property || !$value) { + return; + } + + $fields = $this->properties ?? []; + if (empty($fields)) { + return; + } + + $alias = $queryBuilder->getRootAliases()[0]; + $orConditions = []; + + foreach (array_keys($fields) as $field) { + $paramName = $queryNameGenerator->generateParameterName($field); + $orConditions[] = sprintf('LOWER(%s.%s) LIKE LOWER(:%s)', $alias, $field, $paramName); + $queryBuilder->setParameter($paramName, '%'.$value.'%'); + } + + $queryBuilder->andWhere(implode(' OR ', $orConditions)); + } +}