Skip to content

Commit e3fe767

Browse files
feature #52503 [DoctrineBridge][Form] Introducing new LazyChoiceLoader 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 ------- d73b5eecc90 add LazyChoiceLoader and choice_lazy option
2 parents 96e19d9 + 64b30ef commit e3fe767

File tree

7 files changed

+249
-16
lines changed

7 files changed

+249
-16
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

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

1011
7.1
1112
---
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\ChoiceList\Loader;
13+
14+
use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
15+
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
16+
17+
/**
18+
* A choice loader that loads its choices and values lazily, only when necessary.
19+
*
20+
* @author Yonel Ceruto <[email protected]>
21+
*/
22+
class LazyChoiceLoader implements ChoiceLoaderInterface
23+
{
24+
private ?ChoiceListInterface $choiceList = null;
25+
26+
public function __construct(
27+
private readonly ChoiceLoaderInterface $loader,
28+
) {
29+
}
30+
31+
public function loadChoiceList(?callable $value = null): ChoiceListInterface
32+
{
33+
return $this->choiceList ??= new ArrayChoiceList([], $value);
34+
}
35+
36+
public function loadChoicesForValues(array $values, ?callable $value = null): array
37+
{
38+
$choices = $this->loader->loadChoicesForValues($values, $value);
39+
$this->choiceList = new ArrayChoiceList($choices, $value);
40+
41+
return $choices;
42+
}
43+
44+
public function loadValuesForChoices(array $choices, ?callable $value = null): array
45+
{
46+
$values = $this->loader->loadValuesForChoices($choices, $value);
47+
48+
if ($this->choiceList?->getValuesForChoices($choices) !== $values) {
49+
$this->loadChoicesForValues($values, $value);
50+
}
51+
52+
return $values;
53+
}
54+
}

Extension/Core/Type/ChoiceType.php

+19
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@
2727
use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory;
2828
use Symfony\Component\Form\ChoiceList\Factory\PropertyAccessDecorator;
2929
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
30+
use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
3031
use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
3132
use Symfony\Component\Form\ChoiceList\View\ChoiceListView;
3233
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
3334
use Symfony\Component\Form\Event\PreSubmitEvent;
35+
use Symfony\Component\Form\Exception\LogicException;
3436
use Symfony\Component\Form\Exception\TransformationFailedException;
3537
use Symfony\Component\Form\Extension\Core\DataMapper\CheckboxListMapper;
3638
use Symfony\Component\Form\Extension\Core\DataMapper\RadioListMapper;
@@ -333,11 +335,24 @@ public function configureOptions(OptionsResolver $resolver): void
333335
return $choiceTranslationDomain;
334336
};
335337

338+
$choiceLoaderNormalizer = static function (Options $options, ?ChoiceLoaderInterface $choiceLoader) {
339+
if (!$options['choice_lazy']) {
340+
return $choiceLoader;
341+
}
342+
343+
if (null === $choiceLoader) {
344+
throw new LogicException('The "choice_lazy" option can only be used if the "choice_loader" option is set.');
345+
}
346+
347+
return new LazyChoiceLoader($choiceLoader);
348+
};
349+
336350
$resolver->setDefaults([
337351
'multiple' => false,
338352
'expanded' => false,
339353
'choices' => [],
340354
'choice_filter' => null,
355+
'choice_lazy' => false,
341356
'choice_loader' => null,
342357
'choice_label' => null,
343358
'choice_name' => null,
@@ -365,9 +380,11 @@ public function configureOptions(OptionsResolver $resolver): void
365380

366381
$resolver->setNormalizer('placeholder', $placeholderNormalizer);
367382
$resolver->setNormalizer('choice_translation_domain', $choiceTranslationDomainNormalizer);
383+
$resolver->setNormalizer('choice_loader', $choiceLoaderNormalizer);
368384

369385
$resolver->setAllowedTypes('choices', ['null', 'array', \Traversable::class]);
370386
$resolver->setAllowedTypes('choice_translation_domain', ['null', 'bool', 'string']);
387+
$resolver->setAllowedTypes('choice_lazy', 'bool');
371388
$resolver->setAllowedTypes('choice_loader', ['null', ChoiceLoaderInterface::class, ChoiceLoader::class]);
372389
$resolver->setAllowedTypes('choice_filter', ['null', 'callable', 'string', PropertyPath::class, ChoiceFilter::class]);
373390
$resolver->setAllowedTypes('choice_label', ['null', 'bool', 'callable', 'string', PropertyPath::class, ChoiceLabel::class]);
@@ -381,6 +398,8 @@ public function configureOptions(OptionsResolver $resolver): void
381398
$resolver->setAllowedTypes('separator_html', ['bool']);
382399
$resolver->setAllowedTypes('duplicate_preferred_choices', 'bool');
383400
$resolver->setAllowedTypes('group_by', ['null', 'callable', 'string', PropertyPath::class, GroupBy::class]);
401+
402+
$resolver->setInfo('choice_lazy', 'Load choices on demand. When set to true, only the selected choices are loaded and rendered.');
384403
}
385404

386405
public function getBlockPrefix(): string
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Tests\ChoiceList\Loader;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
16+
use Symfony\Component\Form\Tests\Fixtures\ArrayChoiceLoader;
17+
18+
class LazyChoiceLoaderTest extends TestCase
19+
{
20+
private LazyChoiceLoader $loader;
21+
22+
protected function setUp(): void
23+
{
24+
$this->loader = new LazyChoiceLoader(new ArrayChoiceLoader(['A', 'B', 'C']));
25+
}
26+
27+
public function testInitialEmptyChoiceListLoading()
28+
{
29+
$this->assertSame([], $this->loader->loadChoiceList()->getChoices());
30+
}
31+
32+
public function testOnDemandChoiceListAfterLoadingValuesForChoices()
33+
{
34+
$this->loader->loadValuesForChoices(['A']);
35+
$this->assertSame(['A' => 'A'], $this->loader->loadChoiceList()->getChoices());
36+
}
37+
38+
public function testOnDemandChoiceListAfterLoadingChoicesForValues()
39+
{
40+
$this->loader->loadChoicesForValues(['B']);
41+
$this->assertSame(['B' => 'B'], $this->loader->loadChoiceList()->getChoices());
42+
}
43+
44+
public function testOnDemandChoiceList()
45+
{
46+
$this->loader->loadValuesForChoices(['A']);
47+
$this->loader->loadChoicesForValues(['B']);
48+
$this->assertSame(['B' => 'B'], $this->loader->loadChoiceList()->getChoices());
49+
}
50+
}

Tests/Extension/Core/Type/ChoiceTypeTest.php

+108
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader;
1515
use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
1616
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
17+
use Symfony\Component\Form\Exception\LogicException;
1718
use Symfony\Component\Form\Exception\TransformationFailedException;
1819
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
1920
use Symfony\Component\Form\FormInterface;
@@ -2277,4 +2278,111 @@ public function testWithSameLoaderAndDifferentChoiceValueCallbacks()
22772278
$this->assertSame('20', $view['choice_two']->vars['choices'][1]->value);
22782279
$this->assertSame('30', $view['choice_two']->vars['choices'][2]->value);
22792280
}
2281+
2282+
public function testChoiceLazyThrowsWhenChoiceLoaderIsNotSet()
2283+
{
2284+
$this->expectException(LogicException::class);
2285+
$this->expectExceptionMessage('The "choice_lazy" option can only be used if the "choice_loader" option is set.');
2286+
2287+
$this->factory->create(static::TESTED_TYPE, null, [
2288+
'choice_lazy' => true,
2289+
]);
2290+
}
2291+
2292+
public function testChoiceLazyLoadsAndRendersNothingWhenNoDataSet()
2293+
{
2294+
$form = $this->factory->create(static::TESTED_TYPE, null, [
2295+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B']),
2296+
'choice_lazy' => true,
2297+
]);
2298+
2299+
$this->assertNull($form->getData());
2300+
2301+
$view = $form->createView();
2302+
$this->assertArrayHasKey('choices', $view->vars);
2303+
$this->assertSame([], $view->vars['choices']);
2304+
}
2305+
2306+
public function testChoiceLazyLoadsAndRendersOnlyDataSetViaDefault()
2307+
{
2308+
$form = $this->factory->create(static::TESTED_TYPE, 'A', [
2309+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B']),
2310+
'choice_lazy' => true,
2311+
]);
2312+
2313+
$this->assertSame('A', $form->getData());
2314+
2315+
$view = $form->createView();
2316+
$this->assertArrayHasKey('choices', $view->vars);
2317+
$this->assertCount(1, $view->vars['choices']);
2318+
$this->assertSame('A', $view->vars['choices'][0]->value);
2319+
}
2320+
2321+
public function testChoiceLazyLoadsAndRendersOnlyDataSetViaSubmit()
2322+
{
2323+
$form = $this->factory->create(static::TESTED_TYPE, null, [
2324+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B']),
2325+
'choice_lazy' => true,
2326+
]);
2327+
2328+
$form->submit('B');
2329+
$this->assertSame('B', $form->getData());
2330+
2331+
$view = $form->createView();
2332+
$this->assertArrayHasKey('choices', $view->vars);
2333+
$this->assertCount(1, $view->vars['choices']);
2334+
$this->assertSame('B', $view->vars['choices'][0]->value);
2335+
}
2336+
2337+
public function testChoiceLazyErrorWhenInvalidSubmitData()
2338+
{
2339+
$form = $this->factory->create(static::TESTED_TYPE, null, [
2340+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B']),
2341+
'choice_lazy' => true,
2342+
]);
2343+
2344+
$form->submit('invalid');
2345+
$this->assertNull($form->getData());
2346+
2347+
$view = $form->createView();
2348+
$this->assertArrayHasKey('choices', $view->vars);
2349+
$this->assertCount(0, $view->vars['choices']);
2350+
$this->assertCount(1, $form->getErrors());
2351+
$this->assertSame('ERROR: The selected choice is invalid.', trim((string) $form->getErrors()));
2352+
}
2353+
2354+
public function testChoiceLazyMultipleWithDefaultData()
2355+
{
2356+
$form = $this->factory->create(static::TESTED_TYPE, ['A', 'B'], [
2357+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B', 'c' => 'C']),
2358+
'choice_lazy' => true,
2359+
'multiple' => true,
2360+
]);
2361+
2362+
$this->assertSame(['A', 'B'], $form->getData());
2363+
2364+
$view = $form->createView();
2365+
$this->assertArrayHasKey('choices', $view->vars);
2366+
$this->assertCount(2, $view->vars['choices']);
2367+
$this->assertSame('A', $view->vars['choices'][0]->value);
2368+
$this->assertSame('B', $view->vars['choices'][1]->value);
2369+
}
2370+
2371+
public function testChoiceLazyMultipleWithSubmittedData()
2372+
{
2373+
$form = $this->factory->create(static::TESTED_TYPE, null, [
2374+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B', 'c' => 'C']),
2375+
'choice_lazy' => true,
2376+
'multiple' => true,
2377+
]);
2378+
2379+
$form->submit(['B', 'C']);
2380+
$this->assertSame(['B', 'C'], $form->getData());
2381+
2382+
$view = $form->createView();
2383+
$this->assertArrayHasKey('choices', $view->vars);
2384+
$this->assertCount(2, $view->vars['choices']);
2385+
$this->assertSame('B', $view->vars['choices'][0]->value);
2386+
$this->assertSame('C', $view->vars['choices'][1]->value);
2387+
}
22802388
}

Tests/Fixtures/Descriptor/resolved_form_type_1.json

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"choice_attr",
77
"choice_filter",
88
"choice_label",
9+
"choice_lazy",
910
"choice_loader",
1011
"choice_name",
1112
"choice_translation_domain",

Tests/Fixtures/Descriptor/resolved_form_type_1.txt

+16-16
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,22 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice")
88
choice_attr FormType FormType FormTypeCsrfExtension
99
choice_filter -------------------- ------------------------------ -----------------------
1010
choice_label compound action csrf_field_name
11-
choice_loader data_class allow_file_upload csrf_message
12-
choice_name empty_data attr csrf_protection
13-
choice_translation_domain error_bubbling attr_translation_parameters csrf_token_id
14-
choice_translation_parameters invalid_message auto_initialize csrf_token_manager
15-
choice_value trim block_name
16-
choices block_prefix
17-
duplicate_preferred_choices by_reference
18-
expanded data
19-
group_by disabled
20-
multiple form_attr
21-
placeholder getter
22-
placeholder_attr help
23-
preferred_choices help_attr
24-
separator help_html
25-
separator_html help_translation_parameters
26-
inherit_data
11+
choice_lazy data_class allow_file_upload csrf_message
12+
choice_loader empty_data attr csrf_protection
13+
choice_name error_bubbling attr_translation_parameters csrf_token_id
14+
choice_translation_domain invalid_message auto_initialize csrf_token_manager
15+
choice_translation_parameters trim block_name
16+
choice_value block_prefix
17+
choices by_reference
18+
duplicate_preferred_choices data
19+
expanded disabled
20+
group_by form_attr
21+
multiple getter
22+
placeholder help
23+
placeholder_attr help_attr
24+
preferred_choices help_html
25+
separator help_translation_parameters
26+
separator_html inherit_data
2727
invalid_message_parameters
2828
is_empty_callback
2929
label

0 commit comments

Comments
 (0)