Skip to content

Commit

Permalink
feature #52503 [DoctrineBridge][Form] Introducing new `LazyChoiceLoad…
Browse files Browse the repository at this point in the history
…er` class and `choice_lazy` option for `ChoiceType` (yceruto)

This PR was merged into the 7.2 branch.

Discussion
----------

[DoctrineBridge][Form] Introducing new `LazyChoiceLoader` class and `choice_lazy` option for `ChoiceType`

| Q             | A
| ------------- | ---
| Branch?       | 7.2
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | #57724
| License       | MIT

It's quite usual to work with forms that process large datasets. In Symfony Form + Doctrine ORM, if you define an `EntityType`, it typically loads all choices/entities fully into memory, and this can lead to serious performance problems if your entity table contain several hundred or thousands of records.

The new `LazyChoiceLoader` class addresses this performance issue by implementing an on-demand choice loading strategy. This class is integrated with any `ChoiceType` subtype by using a new boolean option named `choice_lazy`, which activates the feature.

Basic usage in a Symfony form looks like this:
```php
$formBuilder->add('user', EntityType::class, [
    'class' => User::class, // a ton of users...
    'choice_lazy' => true,
]);
```

**How does it work?**

The loader operates by keeping the choice list empty until values are needed (avoiding unnecessary database queries). When form values are provided or submitted, it retrieves and caches only the necessary choices.

As you can see in the code, all this happens behind the `LazyChoiceLoader` class, which delegates the loading of choices to a wrapped `ChoiceLoaderInterface` adapter (in this case, the `DoctrineChoiceLoader`).

**Frontend Considerations**

Certainly, you may need a JavaScript component for dynamically loading `<select>` options, aka autocomplete plugins. You'll need to develop the endpoint/controller to fetch this data on your own, ensuring it corresponds to the form field data source. This aspect is not included in this project.

As a point of reference, the [Autocomplete UX Component](https://symfony.com/bundles/ux-autocomplete/current/index.html) now uses this choice loading strategy, simplifying its autocomplete form type to a single field:

<img src="https://symfony.com/doc/bundles/ux-autocomplete/2.x/ux-autocomplete-animation.gif"/>

**A Handy Use Case without Javascript?**

The `disabled` option renders an `EntityType` form field read-only, and when combined with the `choice_lazy` option, it prevents the loading of unnecessary entities in your choice list (only the pre-selected entities will be loaded), thereby enhancing performance.

---

Hope this helps to create simpler autocomplete components for Symfony forms.

Cheers!

Commits
-------

d73b5ee add LazyChoiceLoader and choice_lazy option
  • Loading branch information
nicolas-grekas committed Oct 9, 2024
2 parents 196e429 + d73b5ee commit 5763273
Show file tree
Hide file tree
Showing 8 changed files with 374 additions and 16 deletions.
125 changes: 125 additions & 0 deletions src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleStringCastableIdEntity;
use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleStringIdEntity;
use Symfony\Component\Form\ChoiceList\LazyChoiceList;
use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
use Symfony\Component\Form\Exception\RuntimeException;
Expand Down Expand Up @@ -1758,4 +1759,128 @@ public function testWithSameLoaderAndDifferentChoiceValueCallbacks()
$this->assertSame('Foo', $view['entity_two']->vars['choices']['Foo']->value);
$this->assertSame('Bar', $view['entity_two']->vars['choices']['Bar']->value);
}

public function testEmptyChoicesWhenLazy()
{
if (!class_exists(LazyChoiceLoader::class)) {
$this->markTestSkipped('This test requires symfony/form 7.2 or superior.');
}

$entity1 = new SingleIntIdEntity(1, 'Foo');
$entity2 = new SingleIntIdEntity(2, 'Bar');
$this->persist([$entity1, $entity2]);

$view = $this->factory->create(FormTypeTest::TESTED_TYPE)
->add('entity_one', self::TESTED_TYPE, [
'em' => 'default',
'class' => self::SINGLE_IDENT_CLASS,
'choice_lazy' => true,
])
->createView()
;

$this->assertCount(0, $view['entity_one']->vars['choices']);
}

public function testLoadedChoicesWhenLazyAndBoundData()
{
if (!class_exists(LazyChoiceLoader::class)) {
$this->markTestSkipped('This test requires symfony/form 7.2 or superior.');
}

$entity1 = new SingleIntIdEntity(1, 'Foo');
$entity2 = new SingleIntIdEntity(2, 'Bar');
$this->persist([$entity1, $entity2]);

$view = $this->factory->create(FormTypeTest::TESTED_TYPE, ['entity_one' => $entity1])
->add('entity_one', self::TESTED_TYPE, [
'em' => 'default',
'class' => self::SINGLE_IDENT_CLASS,
'choice_lazy' => true,
])
->createView()
;

$this->assertCount(1, $view['entity_one']->vars['choices']);
$this->assertSame('1', $view['entity_one']->vars['choices'][1]->value);
}

public function testLoadedChoicesWhenLazyAndSubmittedData()
{
if (!class_exists(LazyChoiceLoader::class)) {
$this->markTestSkipped('This test requires symfony/form 7.2 or superior.');
}

$entity1 = new SingleIntIdEntity(1, 'Foo');
$entity2 = new SingleIntIdEntity(2, 'Bar');
$this->persist([$entity1, $entity2]);

$view = $this->factory->create(FormTypeTest::TESTED_TYPE)
->add('entity_one', self::TESTED_TYPE, [
'em' => 'default',
'class' => self::SINGLE_IDENT_CLASS,
'choice_lazy' => true,
])
->submit(['entity_one' => '2'])
->createView()
;

$this->assertCount(1, $view['entity_one']->vars['choices']);
$this->assertSame('2', $view['entity_one']->vars['choices'][2]->value);
}

public function testEmptyChoicesWhenLazyAndEmptyDataIsSubmitted()
{
if (!class_exists(LazyChoiceLoader::class)) {
$this->markTestSkipped('This test requires symfony/form 7.2 or superior.');
}

$entity1 = new SingleIntIdEntity(1, 'Foo');
$entity2 = new SingleIntIdEntity(2, 'Bar');
$this->persist([$entity1, $entity2]);

$view = $this->factory->create(FormTypeTest::TESTED_TYPE, ['entity_one' => $entity1])
->add('entity_one', self::TESTED_TYPE, [
'em' => 'default',
'class' => self::SINGLE_IDENT_CLASS,
'choice_lazy' => true,
])
->submit([])
->createView()
;

$this->assertCount(0, $view['entity_one']->vars['choices']);
}

public function testErrorOnSubmitInvalidValuesWhenLazyAndCustomQueryBuilder()
{
if (!class_exists(LazyChoiceLoader::class)) {
$this->markTestSkipped('This test requires symfony/form 7.2 or superior.');
}

$entity1 = new SingleIntIdEntity(1, 'Foo');
$entity2 = new SingleIntIdEntity(2, 'Bar');
$this->persist([$entity1, $entity2]);
$qb = $this->em
->createQueryBuilder()
->select('e')
->from(self::SINGLE_IDENT_CLASS, 'e')
->where('e.id = 2')
;

$form = $this->factory->create(FormTypeTest::TESTED_TYPE, ['entity_one' => $entity2])
->add('entity_one', self::TESTED_TYPE, [
'em' => 'default',
'class' => self::SINGLE_IDENT_CLASS,
'query_builder' => $qb,
'choice_lazy' => true,
])
->submit(['entity_one' => '1'])
;
$view = $form->createView();

$this->assertCount(0, $view['entity_one']->vars['choices']);
$this->assertCount(1, $errors = $form->getErrors(true));
$this->assertSame('The selected choice is invalid.', $errors->current()->getMessage());
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Form/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ CHANGELOG

* Deprecate the `VersionAwareTest` trait, use feature detection instead
* Add support for the `calendar` option in `DateType`
* Add `LazyChoiceLoader` and `choice_lazy` option in `ChoiceType` for loading and rendering choices on demand

7.1
---
Expand Down
54 changes: 54 additions & 0 deletions src/Symfony/Component/Form/ChoiceList/Loader/LazyChoiceLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Form\ChoiceList\Loader;

use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;

/**
* A choice loader that loads its choices and values lazily, only when necessary.
*
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
class LazyChoiceLoader implements ChoiceLoaderInterface
{
private ?ChoiceListInterface $choiceList = null;

public function __construct(
private readonly ChoiceLoaderInterface $loader,
) {
}

public function loadChoiceList(?callable $value = null): ChoiceListInterface
{
return $this->choiceList ??= new ArrayChoiceList([], $value);
}

public function loadChoicesForValues(array $values, ?callable $value = null): array
{
$choices = $this->loader->loadChoicesForValues($values, $value);
$this->choiceList = new ArrayChoiceList($choices, $value);

return $choices;
}

public function loadValuesForChoices(array $choices, ?callable $value = null): array
{
$values = $this->loader->loadValuesForChoices($choices, $value);

if ($this->choiceList?->getValuesForChoices($choices) !== $values) {
$this->loadChoicesForValues($values, $value);
}

return $values;
}
}
19 changes: 19 additions & 0 deletions src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory;
use Symfony\Component\Form\ChoiceList\Factory\PropertyAccessDecorator;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
use Symfony\Component\Form\ChoiceList\View\ChoiceListView;
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Exception\LogicException;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\DataMapper\CheckboxListMapper;
use Symfony\Component\Form\Extension\Core\DataMapper\RadioListMapper;
Expand Down Expand Up @@ -333,11 +335,24 @@ public function configureOptions(OptionsResolver $resolver): void
return $choiceTranslationDomain;
};

$choiceLoaderNormalizer = static function (Options $options, ?ChoiceLoaderInterface $choiceLoader) {
if (!$options['choice_lazy']) {
return $choiceLoader;
}

if (null === $choiceLoader) {
throw new LogicException('The "choice_lazy" option can only be used if the "choice_loader" option is set.');
}

return new LazyChoiceLoader($choiceLoader);
};

$resolver->setDefaults([
'multiple' => false,
'expanded' => false,
'choices' => [],
'choice_filter' => null,
'choice_lazy' => false,
'choice_loader' => null,
'choice_label' => null,
'choice_name' => null,
Expand Down Expand Up @@ -365,9 +380,11 @@ public function configureOptions(OptionsResolver $resolver): void

$resolver->setNormalizer('placeholder', $placeholderNormalizer);
$resolver->setNormalizer('choice_translation_domain', $choiceTranslationDomainNormalizer);
$resolver->setNormalizer('choice_loader', $choiceLoaderNormalizer);

$resolver->setAllowedTypes('choices', ['null', 'array', \Traversable::class]);
$resolver->setAllowedTypes('choice_translation_domain', ['null', 'bool', 'string']);
$resolver->setAllowedTypes('choice_lazy', 'bool');
$resolver->setAllowedTypes('choice_loader', ['null', ChoiceLoaderInterface::class, ChoiceLoader::class]);
$resolver->setAllowedTypes('choice_filter', ['null', 'callable', 'string', PropertyPath::class, ChoiceFilter::class]);
$resolver->setAllowedTypes('choice_label', ['null', 'bool', 'callable', 'string', PropertyPath::class, ChoiceLabel::class]);
Expand All @@ -381,6 +398,8 @@ public function configureOptions(OptionsResolver $resolver): void
$resolver->setAllowedTypes('separator_html', ['bool']);
$resolver->setAllowedTypes('duplicate_preferred_choices', 'bool');
$resolver->setAllowedTypes('group_by', ['null', 'callable', 'string', PropertyPath::class, GroupBy::class]);

$resolver->setInfo('choice_lazy', 'Load choices on demand. When set to true, only the selected choices are loaded and rendered.');
}

public function getBlockPrefix(): string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Form\Tests\ChoiceList\Loader;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
use Symfony\Component\Form\Tests\Fixtures\ArrayChoiceLoader;

class LazyChoiceLoaderTest extends TestCase
{
private LazyChoiceLoader $loader;

protected function setUp(): void
{
$this->loader = new LazyChoiceLoader(new ArrayChoiceLoader(['A', 'B', 'C']));
}

public function testInitialEmptyChoiceListLoading()
{
$this->assertSame([], $this->loader->loadChoiceList()->getChoices());
}

public function testOnDemandChoiceListAfterLoadingValuesForChoices()
{
$this->loader->loadValuesForChoices(['A']);
$this->assertSame(['A' => 'A'], $this->loader->loadChoiceList()->getChoices());
}

public function testOnDemandChoiceListAfterLoadingChoicesForValues()
{
$this->loader->loadChoicesForValues(['B']);
$this->assertSame(['B' => 'B'], $this->loader->loadChoiceList()->getChoices());
}

public function testOnDemandChoiceList()
{
$this->loader->loadValuesForChoices(['A']);
$this->loader->loadChoicesForValues(['B']);
$this->assertSame(['B' => 'B'], $this->loader->loadChoiceList()->getChoices());
}
}
Loading

0 comments on commit 5763273

Please sign in to comment.