From cc87e60a68d9dc1598f5637dea94906b74c97381 Mon Sep 17 00:00:00 2001 From: Hugo Seigle Date: Fri, 15 Nov 2024 00:24:45 +0100 Subject: [PATCH] [Autocomplete] Fix handling of associated properties in DQL joins --- .../src/Doctrine/EntitySearchUtil.php | 11 ++- .../tests/Fixtures/Entity/Category.php | 29 +++++++ .../tests/Fixtures/Entity/CategoryTag.php | 78 +++++++++++++++++++ .../tests/Fixtures/Entity/Product.php | 33 +++++++- .../tests/Fixtures/Entity/ProductTag.php | 78 +++++++++++++++++++ .../Fixtures/Factory/CategoryTagFactory.php | 56 +++++++++++++ .../Fixtures/Factory/ProductTagFactory.php | 56 +++++++++++++ .../Form/ProductWithTagsAutocompleteType.php | 42 ++++++++++ .../Functional/FieldAutocompleterTest.php | 30 +++++++ 9 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 src/Autocomplete/tests/Fixtures/Entity/CategoryTag.php create mode 100644 src/Autocomplete/tests/Fixtures/Entity/ProductTag.php create mode 100644 src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php create mode 100644 src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php create mode 100644 src/Autocomplete/tests/Fixtures/Form/ProductWithTagsAutocompleteType.php diff --git a/src/Autocomplete/src/Doctrine/EntitySearchUtil.php b/src/Autocomplete/src/Doctrine/EntitySearchUtil.php index d7e09d1274..3db23893b5 100644 --- a/src/Autocomplete/src/Doctrine/EntitySearchUtil.php +++ b/src/Autocomplete/src/Doctrine/EntitySearchUtil.php @@ -47,6 +47,7 @@ public function addSearchClause(QueryBuilder $queryBuilder, string $query, strin ]; $entitiesAlreadyJoined = []; + $aliasAlreadyUsed = []; $searchableProperties = empty($searchableProperties) ? $entityMetadata->getAllPropertyNames() : $searchableProperties; $expressions = []; foreach ($searchableProperties as $propertyName) { @@ -68,10 +69,18 @@ public function addSearchClause(QueryBuilder $queryBuilder, string $query, strin $associatedEntityAlias = SearchEscaper::escapeDqlAlias($associatedEntityName); $associatedPropertyName = $associatedProperties[$i + 1]; - if (!\in_array($associatedEntityName, $entitiesAlreadyJoined, true)) { + $associatedParentName = null; + if (\array_key_exists($i - 1, $associatedProperties) && $queryBuilder->getRootAliases()[0] !== $associatedProperties[$i - 1]) { + $associatedParentName = $associatedProperties[$i - 1]; + } + + $associatedEntityAlias = $associatedParentName ? $associatedParentName.'_'.$associatedEntityAlias : $associatedEntityAlias; + + if (!\in_array($associatedEntityName, $entitiesAlreadyJoined, true) || !\in_array($associatedEntityAlias, $aliasAlreadyUsed, true)) { $parentEntityName = 0 === $i ? $queryBuilder->getRootAliases()[0] : $associatedProperties[$i - 1]; $queryBuilder->leftJoin($parentEntityName.'.'.$associatedEntityName, $associatedEntityAlias); $entitiesAlreadyJoined[] = $associatedEntityName; + $aliasAlreadyUsed[] = $associatedEntityAlias; } if ($i < $numAssociatedProperties - 2) { diff --git a/src/Autocomplete/tests/Fixtures/Entity/Category.php b/src/Autocomplete/tests/Fixtures/Entity/Category.php index b6ea24841b..1372bf8921 100644 --- a/src/Autocomplete/tests/Fixtures/Entity/Category.php +++ b/src/Autocomplete/tests/Fixtures/Entity/Category.php @@ -32,9 +32,13 @@ class Category #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class)] private Collection $products; + #[ORM\ManyToMany(targetEntity: CategoryTag::class, mappedBy: 'categories')] + private Collection $tags; + public function __construct() { $this->products = new ArrayCollection(); + $this->tags = new ArrayCollection(); } public function getId(): ?int @@ -96,6 +100,31 @@ public function removeProduct(Product $product): self return $this; } + /** + * @return Collection + */ + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(CategoryTag $tag): self + { + if (!$this->tags->contains($tag)) { + $this->tags[] = $tag; + $tag->addCategory($this); + } + + return $this; + } + + public function removeTag(CategoryTag $tag): self + { + $this->tags->removeElement($tag); + + return $this; + } + public function __toString(): string { return $this->getName(); diff --git a/src/Autocomplete/tests/Fixtures/Entity/CategoryTag.php b/src/Autocomplete/tests/Fixtures/Entity/CategoryTag.php new file mode 100644 index 0000000000..f0d46ac9cc --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Entity/CategoryTag.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity()] +class CategoryTag +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column()] + private ?int $id = null; + + #[ORM\Column()] + private ?string $name = null; + + #[ORM\ManyToMany(targetEntity: Category::class, inversedBy: 'tags')] + #[ORM\JoinTable(name: 'category_tag')] + private Collection $categories; + + public function __construct() + { + $this->categories = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * @return Collection + */ + public function getCategories(): Collection + { + return $this->categories; + } + + public function addCategory(Category $category): self + { + if (!$this->categories->contains($category)) { + $this->categories[] = $category; + } + + return $this; + } + + public function removeCategory(Category $category): self + { + $this->categories->removeElement($category); + + return $this; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Entity/Product.php b/src/Autocomplete/tests/Fixtures/Entity/Product.php index 7db8056e25..4188c91a55 100644 --- a/src/Autocomplete/tests/Fixtures/Entity/Product.php +++ b/src/Autocomplete/tests/Fixtures/Entity/Product.php @@ -40,12 +40,16 @@ class Product #[ORM\JoinColumn(nullable: false)] private ?Category $category = null; - #[Orm\OneToMany(targetEntity: Ingredient::class, mappedBy: 'product')] + #[ORM\OneToMany(targetEntity: Ingredient::class, mappedBy: 'product')] private Collection $ingredients; + #[ORM\ManyToMany(targetEntity: ProductTag::class, mappedBy: 'products')] + private Collection $tags; + public function __construct() { $this->ingredients = new ArrayCollection(); + $this->tags = new ArrayCollection(); } public function getId(): ?int @@ -142,4 +146,31 @@ public function removeIngredient(Ingredient $ingredient): self return $this; } + + /** + * @return Collection + */ + public function getTags(): Collection + { + return $this->tags; + } + + public function addTag(ProductTag $tag): self + { + if (!$this->tags->contains($tag)) { + $this->tags[] = $tag; + $tag->addProduct($this); + } + + return $this; + } + + public function removeTag(ProductTag $tag): self + { + if ($this->tags->removeElement($tag)) { + $tag->removeProduct($this); + } + + return $this; + } } diff --git a/src/Autocomplete/tests/Fixtures/Entity/ProductTag.php b/src/Autocomplete/tests/Fixtures/Entity/ProductTag.php new file mode 100644 index 0000000000..2567055762 --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Entity/ProductTag.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity()] +class ProductTag +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column()] + private ?int $id = null; + + #[ORM\Column()] + private ?string $name = null; + + #[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'tags')] + #[ORM\JoinTable(name: 'product_tag')] + private Collection $products; + + public function __construct() + { + $this->products = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + /** + * @return Collection + */ + public function getProducts(): Collection + { + return $this->products; + } + + public function addProduct(Product $product): self + { + if (!$this->products->contains($product)) { + $this->products[] = $product; + } + + return $this; + } + + public function removeProduct(Product $product): self + { + $this->products->removeElement($product); + + return $this; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php b/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php new file mode 100644 index 0000000000..32eec81c1a --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Factory/CategoryTagFactory.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory; + +use Doctrine\ORM\EntityRepository; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\CategoryTag; +use Zenstruck\Foundry\ModelFactory; +use Zenstruck\Foundry\Proxy; +use Zenstruck\Foundry\RepositoryProxy; + +/** + * @extends ModelFactory + * + * @method static CategoryTag|Proxy createOne(array $attributes = []) + * @method static CategoryTag[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static CategoryTag|Proxy find(object|array|mixed $criteria) + * @method static CategoryTag|Proxy findOrCreate(array $attributes) + * @method static CategoryTag|Proxy first(string $sortedField = 'id') + * @method static CategoryTag|Proxy last(string $sortedField = 'id') + * @method static CategoryTag|Proxy random(array $attributes = []) + * @method static CategoryTag|Proxy randomOrCreate(array $attributes = []) + * @method static CategoryTag[]|Proxy[] all() + * @method static CategoryTag[]|Proxy[] findBy(array $attributes) + * @method static CategoryTag[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method static CategoryTag[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static EntityRepository|RepositoryProxy repository() + * @method CategoryTag|Proxy create(array|callable $attributes = []) + */ +final class CategoryTagFactory extends ModelFactory +{ + protected function getDefaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } + + protected function initialize(): self + { + return $this; + } + + protected static function getClass(): string + { + return CategoryTag::class; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php b/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php new file mode 100644 index 0000000000..868d61fca4 --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Factory/ProductTagFactory.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory; + +use Doctrine\ORM\EntityRepository; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\ProductTag; +use Zenstruck\Foundry\ModelFactory; +use Zenstruck\Foundry\Proxy; +use Zenstruck\Foundry\RepositoryProxy; + +/** + * @extends ModelFactory + * + * @method static ProductTag|Proxy createOne(array $attributes = []) + * @method static ProductTag[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static ProductTag|Proxy find(object|array|mixed $criteria) + * @method static ProductTag|Proxy findOrCreate(array $attributes) + * @method static ProductTag|Proxy first(string $sortedField = 'id') + * @method static ProductTag|Proxy last(string $sortedField = 'id') + * @method static ProductTag|Proxy random(array $attributes = []) + * @method static ProductTag|Proxy randomOrCreate(array $attributes = []) + * @method static ProductTag[]|Proxy[] all() + * @method static ProductTag[]|Proxy[] findBy(array $attributes) + * @method static ProductTag[]|Proxy[] randomSet(int $number, array $attributes = []) + * @method static ProductTag[]|Proxy[] randomRange(int $min, int $max, array $attributes = []) + * @method static EntityRepository|RepositoryProxy repository() + * @method ProductTag|Proxy create(array|callable $attributes = []) + */ +final class ProductTagFactory extends ModelFactory +{ + protected function getDefaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } + + protected function initialize(): self + { + return $this; + } + + protected static function getClass(): string + { + return ProductTag::class; + } +} diff --git a/src/Autocomplete/tests/Fixtures/Form/ProductWithTagsAutocompleteType.php b/src/Autocomplete/tests/Fixtures/Form/ProductWithTagsAutocompleteType.php new file mode 100644 index 0000000000..68390e0868 --- /dev/null +++ b/src/Autocomplete/tests/Fixtures/Form/ProductWithTagsAutocompleteType.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Form; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; +use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product; + +#[AsEntityAutocompleteField] +class ProductWithTagsAutocompleteType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'class' => Product::class, + 'choice_label' => function (Product $product) { + return ''.$product->getName().''; + }, + 'multiple' => true, + 'searchable_fields' => [ + 'tags.name', + 'category.tags.name', + ], + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php b/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php index 6e9420b1ff..305e75c20a 100644 --- a/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php +++ b/src/Autocomplete/tests/Functional/FieldAutocompleterTest.php @@ -14,6 +14,9 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Security\Core\User\InMemoryUser; use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryFactory; +use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\CategoryTagFactory; +use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductFactory; +use Symfony\UX\Autocomplete\Tests\Fixtures\Factory\ProductTagFactory; use Zenstruck\Browser\Test\HasBrowser; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -127,4 +130,31 @@ public function testItUsesTheCustomCallbackValue(): void ->assertJsonMatches('results[0].text', $category->getName()) ; } + + public function testItSearchesByTags(): void + { + $productTag = ProductTagFactory::createOne(['name' => 'technology']); + $categoryTag = CategoryTagFactory::createOne(['name' => 'home appliances']); + $category = CategoryFactory::createOne(['name' => 'Electronics', 'tags' => [$categoryTag]]); + $product1 = ProductFactory::createOne(['name' => 'Smartphone', 'tags' => [$productTag], 'category' => $category]); + $product2 = ProductFactory::createOne(['name' => 'Laptop', 'category' => $category]); + ProductFactory::createOne(['name' => 'Microwave']); + + $this->browser() + ->throwExceptions() + ->get('/test/autocomplete/product_with_tags_autocomplete_type?query=technology') + ->assertSuccessful() + ->assertJsonMatches('length(results)', 1) + ->assertJsonMatches('results[0].value', (string) $product1->getId()) + ->assertJsonMatches('results[0].text', 'Smartphone') + ; + + $this->browser() + ->get('/test/autocomplete/product_with_tags_autocomplete_type?query=home appliance') + ->assertSuccessful() + ->assertJsonMatches('length(results)', 2) + ->assertJsonMatches('results[0].value', (string) $product1->getId()) + ->assertJsonMatches('results[1].value', (string) $product2->getId()) + ; + } }