10000 feature #30994 [Form] Added support for caching choice lists based on… · symfony/symfony@269c4a2 · GitHub
[go: up one dir, main page]

Skip to content

Commit 269c4a2

Browse files
committed
feature #30994 [Form] Added support for caching choice lists based on options (HeahDude)
This PR was merged into the 5.1-dev branch. Discussion ---------- [Form] Added support for caching choice lists based on options | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | ~ | License | MIT | Doc PR | symfony/symfony-docs#13182 Currently, the `CachingFactoryDecorator` is responsible for unnecessary memory usage, anytime a choice option is set with a callback option defined as an anonymous function or a loader, then a new hash is generated for the choice list, while we may expect the list to be reused once "finally" configured in a form type or choice type extension. A simple case is when using one of the core intl choice types in a collection: ```php // ... class SomeFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('some_choices', ChoiceType::class, [ // before: cached choice list (unnecessary overhead) // after: no cache (better perf) 'choices' => $someObjects, 'choice_value' => function (?object $choice) { /* return some string */ }, ]) // see below the nested effects ->add('nested_fields', CollectionType::class, [ 'entry_type' => NestedFormType::class, ]) // ... } // ... class NestedFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('some_other_choices', ChoiceType::class, [ // before: cached choice list for every entry because we define a new closure instance for each field // after: no cache, a bit better for the same result, but much better if it were not nested in a collection 'choices' => $someOtherObjects, 'choice_value' => function (?object $otherChoice) { /* return some string */ }, ]) ->add('some_loaded_choices', ChoiceType::class, [ // before: cached but for every entry since every field will have its // own instance of loader, generating a new hash // after: no cache, same pro/cons as above 'choice_loader' => new CallbackChoiceLoader(function() { /* return some choices */}), // or 'choice_loader' => new SomeLoader(), ]) ->add('person', EntityType::class, [ // before: cached but for every entry, because we define extra `choice_*` option // after: no cache, same pro/cons as above 'class' => SomeEntity::class, 'choice_label' => function (?SomeEntity $choice) { /* return some label */}, ]) // before: cached for every entry, because the type define some "choice_*" option // after: cached only once, better perf since the same loader is used for every entry ->add('country', CountryType::class) // before: cached for every entry, because the type define some "choice_*" option // after: no cache, same pro/cons as above ->add('locale', LocaleType::class, [ 'preferred_choices' => [ /* some preferred locales */ ], 'group_by' => function (?string $locale, $label) { /* return some group */ }, ]) // ... ``` In such cases, we would expect every entries to use the same cached intl choice list, but not, as many list and views as entries will be kept in cache. This is even worse if some callback options like `choice_label`, `choice_value`, `choice_attr`, `choice_name`, `preferred_choices` or `group_by` are used. This PR helps making cache explicit when needed and ~deprecate~ drop unexpected implicit caching of choice list for most simple cases responsible of unnecessary overhead. The result is better performance just by upgrading to 5.1 \o/. But to solve the cases above when cache is needed per options, one should now use the new `ChoiceList` static methods to wrap option values, which is already done internally in this PR. ```php use Symfony\Component\Form\ChoiceList\ChoiceList; // ... class NestedFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder // explicitly shared cached choice lists between entries ->add('some_other_choices', ChoiceType::class, [ 'choices' => $someOtherObjects, 'choice_value' => ChoiceList::value($this, function (?object $otherChoice) { /* return some string */ }), ]), ->add('some_loaded_choices', ChoiceType::class, [ 'choice_loader' => ChoiceList::lazy($this, function() { /* return some choices */ })), // or 'choice_loader' => ChoiceList::loader($this, new SomeLoader()), ]), ->add('person', EntityType::class, [ 'class' => SomeEntity::class, 'choice_label' => ChoiceList::label($this, function (?SomeEntity $choice) { /* return some label */ }, ]) // nothing to do :) ->add('country', CountryType::class) ->add('locale', LocaleType::class, [ 'preferred_choices' => ChoiceList::preferred($this, [ /* some preferred locales */ ]), 'group_by' => ChoiceList::groupBy($this, function (?string $locale, $label) { /* return some group */ }), ]) // ... ``` I've done some nice profiling with Blackfire and the simple example above in a fresh website skeleton and only two empty entries as initial data, then submitting an empty form. That gives the following results: * Rendering the form - Before vs After <img width="714" alt="Screenshot 2020-02-16 at 9 24 58 PM" src="https://user-images.githubusercontent.com/10107633/74612132-de533180-5102-11ea-9cc4-296a16949d90.png"> * Rendering the form - Before vs After with `ChoiceList` helpers <img width="670" alt="Screenshot 2020-02-16 at 9 26 51 PM" src="https://user-images.githubusercontent.com/10107633/74612155-122e5700-5103-11ea-9c16-5d80a7541f4b.png"> * Submitting the form - Before vs After <img width="670" alt="Screenshot 2020-02-16 at 9 28 01 PM" src="https://user-images.githubusercontent.com/10107633/74612172-3be77e00-5103-11ea-9a18-4294e05402d2.png"> * Submitting the form - Before vs After with `ChoiceList` helpers <img width="670" alt="Screenshot 2020-02-16 at 9 29 10 PM" src="https://user-images.githubusercontent.com/10107633/74612193-689b9580-5103-11ea-86b9-5b4906200021.png"> _________ TODO: - [x] Docs - [x] More profiling - [x] Add some tests #EUFOSSA Commits ------- b25973c [Form] Added support for caching choice lists based on options
2 parents f01bbc7 + b25973c commit 269c4a2

21 files changed

+835
-119
lines changed

src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php

Lines changed: 42 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
2121
use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener;
2222
use Symfony\Component\Form\AbstractType;
23+
use Symfony\Component\Form\ChoiceList\ChoiceList;
2324
use Symfony\Component\Form\ChoiceList\Factory\CachingFactoryDecorator;
2425
use Symfony\Component\Form\Exception\RuntimeException;
2526
use Symfony\Component\Form\FormBuilderInterface;
@@ -40,9 +41,9 @@ abstract class DoctrineType extends AbstractType implements ResetInterface
4041
private $idReaders = [];
4142

4243
/**
43-
* @var DoctrineChoiceLoader[]
44+
* @var EntityLoaderInterface[]
4445
*/
45-
private $choiceLoaders = [];
46+
private $entityLoaders = [];
4647

4748
/**
4849
* Creates the label for a choice.
@@ -115,43 +116,26 @@ public function configureOptions(OptionsResolver $resolver)
115116
$choiceLoader = function (Options $options) {
116117
// Unless the choices are given explicitly, load them on demand
117118
if (null === $options['choices']) {
118-
$hash = null;
119-
$qbParts = null;
119+
// If there is no QueryBuilder we can safely cache
120+
$vary = [$options['em'], $options['class']];
120121

121-
// If there is no QueryBuilder we can safely cache DoctrineChoiceLoader,
122122
// also if concrete Type can return important QueryBuilder parts to generate
123-
// hash key we go for it as well
124-
if (!$options['query_builder'] || null !== $qbParts = $this->getQueryBuilderPartsForCachingHash($options['query_builder'])) {
125-
$hash = CachingFactoryDecorator::generateHash([
126-
$options['em'],
127-
$options['class'],
128-
$qbParts,
129-
]);
130-
131-
if (isset($this->choiceLoaders[$hash])) {
132-
return $this->choiceLoaders[$hash];
133-
}
123+
// hash key we go for it as well, otherwise fallback on the instance
124+
if ($options['query_builder']) {
125+
$vary[] = $this->getQueryBuilderPartsForCachingHash($options['query_builder']) ?? $options['query_builder'];
134126
}
135127

136-
if (null !== $options['query_builder']) {
137-
$entityLoader = $this->getLoader($options['em'], $options['query_builder'], $options['class']);
138-
} else {
139-
$queryBuilder = $options['em']->getRepository($options['class'])->createQueryBuilder('e');
140-
$entityLoader = $this->getLoader($options['em'], $queryBuilder, $options['class']);
141-
}
142-
143-
$doctrineChoiceLoader = new DoctrineChoiceLoader(
128+
return ChoiceList::loader($this, new DoctrineChoiceLoader(
144129
$options['em'],
145130
$options['class'],
146131
$options['id_reader'],
147-
$entityLoader
148-
);
149-
150-
if (null !== $hash) {
151-
$this->choiceLoaders[$hash] = $doctrineChoiceLoader;
152-
}
153-
154-
return $doctrineChoiceLoader;
132+
$this->getCachedEntityLoader(
133+
$options['em'],
134+
$options['query_builder'] ?? $options['em']->getRepository($options['class'])->createQueryBuilder('e'),
135+
$options['class'],
136+
$vary
137+
)
138+
), $vary);
155139
}
156140

157141
return null;
@@ -162,7 +146,7 @@ public function configureOptions(OptionsResolver $resolver)
162146
// field name. We can only use numeric IDs as names, as we cannot
163147
// guarantee that a non-numeric ID contains a valid form name
164148
if ($options['id_reader'] instanceof IdReader && $options['id_reader']->isIntId()) {
165-
return [__CLASS__, 'createChoiceName'];
149+
return ChoiceList::fieldName($this, [__CLASS__, 'createChoiceName']);
166150
}
167151

168152
// Otherwise, an incrementing integer is used as name automatically
@@ -176,7 +160,7 @@ public function configureOptions(OptionsResolver $resolver)
176160
$choiceValue = function (Options $options) {
177161
// If the entity has a single-column ID, use that ID as value
178162
if ($options['id_reader'] instanceof IdReader && $options['id_reader']->isSingleId()) {
179-
return [$options['id_reader'], 'getIdValue'];
163+
return ChoiceList::value($this, [$options['id_reader'], 'getIdValue'], $options['id_reader']);
180164
}
181165

182166
// Otherwise, an incrementing integer is used as value automatically
@@ -214,35 +198,21 @@ public function configureOptions(OptionsResolver $resolver)
214198
// Set the "id_reader" option via the normalizer. This option is not
215199
// supposed to be set by the user.
216200
$idReaderNormalizer = function (Options $options) {
217-
$hash = CachingFactoryDecorator::generateHash([
218-
$options['em'],
219-
$options['class'],
220-
]);
221-
222201
// The ID reader is a utility that is needed to read the object IDs
223202
// when generating the field values. The callback generating the
224203
// field values has no access to the object manager or the class
225204
// of the field, so we store that information in the reader.
226205
// The reader is cached so that two choice lists for the same class
227206
// (and hence with the same reader) can successfully be cached.
228-
if (!isset($this->idReaders[$hash])) {
229-
$classMetadata = $options['em']->getClassMetadata($options['class']);
230-
$this->idReaders[$hash] = new IdReader($options['em'], $classMetadata);
231-
}
232-
233-
if ($this->idReaders[$hash]->isSingleId()) {
234-
return $this->idReaders[$hash];
235-
}
236-
237-
return null;
207+
return $this->getCachedIdReader($options['em'], $options['class']);
238208
};
239209

240210
$resolver->setDefaults([
241211
'em' => null,
242212
'query_builder' => null,
243213
'choices' => null,
244214
'choice_loader' => $choiceLoader,
245-
'choice_label' => [__CLASS__, 'createChoiceLabel'],
215+
'choice_label' => ChoiceList::label($this, [__CLASS__, 'createChoiceLabel']),
246216
'choice_name' => $choiceName,
247217
'choice_value' => $choiceValue,
248218
'id_reader' => null, // internal
@@ -274,6 +244,27 @@ public function getParent()
274244

275245
public function reset()
276246
{
277-
$this->choiceLoaders = [];
247+
$this->entityLoaders = [];
248+
}
249+
250+
private function getCachedIdReader(ObjectManager $manager, string $class): ?IdReader
251+
{
252+
$hash = CachingFactoryDecorator::generateHash([$manager, $class]);
253+
254+
if (isset($this->idReaders[$hash])) {
255+
return $this->idReaders[$hash];
256+
}
257+
258+
$idReader = new IdReader($manager, $manager->getClassMetadata($class));
259+
260+
// don't cache the instance for composite ids that cannot be optimized
261+
return $this->idReaders[$hash] = $idReader->isSingleId() ? $idReader : null;
262+
}
263+
264+
private function getCachedEntityLoader(ObjectManager $manager, $queryBuilder, string $class, array $vary): EntityLoaderInterface
265+
{
< 10000 div aria-hidden="true" class="position-absolute top-0 d-flex user-select-none DiffLineTableCellParts-module__comment-indicator--eI0hb">
266+
$hash = CachingFactoryDecorator::generateHash($vary);
267+
268+
return $this->entityLoaders[$hash] ?? ($this->entityLoaders[$hash] = $this->getLoader($manager, $queryBuilder, $class));
278269
}
279270
}

src/Symfony/Bridge/Doctrine/Tests/Form/Type/EntityTypeTest.php

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,13 +1205,13 @@ public function testLoaderCaching()
12051205
'property3' => 2,
12061206
]);
12071207

1208-
$choiceLoader1 = $form->get('property1')->getConfig()->getOption('choice_loader');
1209-
$choiceLoader2 = $form->get('property2')->getConfig()->getOption('choice_loader');
1210-
$choiceLoader3 = $form->get('property3')->getConfig()->getOption('choice_loader');
1208+
$choiceList1 = $form->get('property1')->getConfig()->getAttribute('choice_list');
1209+
$choiceList2 = $form->get('property2')->getConfig()->getAttribute('choice_list');
1210+
$choiceList3 = $form->get('property3')->getConfig()->getAttribute('choice_list');
12111211

1212-
$this->assertInstanceOf('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface', $choiceLoader1);
1213-
$this->assertSame($choiceLoader1, $choiceLoader2);
1214-
$this->assertSame($choiceLoader1, $choiceLoader3);
1212+
$this->assertInstanceOf('Symfony\Component\Form\ChoiceList\LazyChoiceList', $choiceList1);
1213+
$this->assertSame($choiceList1, $choiceList2);
1214+
$this->assertSame($choiceList1, $choiceList3);
12151215
}
12161216

12171217
public function testLoaderCachingWithParameters()
@@ -1265,13 +1265,13 @@ public function testLoaderCachingWithParameters()
12651265
'property3' => 2,
12661266
]);
12671267

1268-
$choiceLoader1 = $form->get('property1')->getConfig()->getOption('choice_loader');
1269-
$choiceLoader2 = $form->get('property2')->getConfig()->getOption('choice_loader');
1270-
$choiceLoader3 = $form->get('property3')->getConfig()->getOption('choice_loader');
1268+
$choiceList1 = $form->get('property1')->getConfig()->getAttribute('choice_list');
1269+
$choiceList2 = $form->get('property2')->getConfig()->getAttribute('choice_list');
1270+
$choiceList3 = $form->get('property3')->getConfig()->getAttribute('choice_list');
12711271

1272-
$this->assertInstanceOf('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface', $choiceLoader1);
1273-
$this->assertSame($choiceLoader1, $choiceLoader2);
1274-
$this->assertSame($choiceLoader1, $choiceLoader3);
1272+
$this->assertInstanceOf('Symfony\Component\Form\ChoiceList\LazyChoiceList', $choiceList1);
1273+
$this->assertSame($choiceList1, $choiceList2);
1274+
$this->assertSame($choiceList1, $choiceList3);
12751275
}
12761276

12771277
protected function createRegistryMock($name, $em)

src/Symfony/Component/Form/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
5.1.0
55
-----
66

7+
* Added a `ChoiceList` facade to leverage explicit choice list caching based on options
78
* Added an `AbstractChoiceLoader` to simplify implementations and handle global optimizations
89
* The `view_timezone` option defaults to the `model_timezone` if no `reference_date` is configured.
910
* Added default `inputmode` attribute to Search, Email and Tel form types.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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;
13+
14+
use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceAttr;
15+
use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFieldName;
16+
use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLabel;
17+
use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLoader;
18+
use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceValue;
19+
use Symfony\Component\Form\ChoiceList\Factory\Cache\GroupBy;
20+
use Symfony\Component\Form\ChoiceList\Factory\Cache\PreferredChoice;
21+
use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader;
22+
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
23+
use Symfony\Component\Form\FormTypeExtensionInterface;
24+
use Symfony\Component\Form\FormTypeInterface;
25+
26+
/**
27+
* A set of convenient static methods to create cacheable choice list options.
28+
*
29+
* @author Jules Pietri <jules@heahprod.com>
30+
*/
31+
final class ChoiceList
32+
{
33+
/**
34+
* Creates a cacheable loader from any callable providing iterable choices.
35+
*
36+
* @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list
37+
* @param callable $choices A callable that must return iterable choices or grouped choices
38+
* @param mixed|null $vary Dynamic data used to compute a unique hash when caching the loader
39+
*/
40+
public static function lazy($formType, callable $choices, $vary = null): ChoiceLoader
41+
{
42+
return self::loader($formType, new CallbackChoiceLoader($choices), $vary);
43+
}
44+
45+
/**
46+
* Decorates a loader to make it cacheable.
47+
*
48+
* @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list
49+
* @param ChoiceLoaderInterface $loader A loader responsible for creating loading choices or grouped choices
50+
* @param mixed|null $vary Dynamic data used to compute a unique hash when caching the loader
51+
*/
52+
public static function loader($formType, ChoiceLoaderInterface $loader, $vary = null): ChoiceLoader
53+
{
54+
return new ChoiceLoader($formType, $loader, $vary);
55+
}
56+
57+
/**
58+
* Decorates a "choice_value" callback to make it cacheable.
59+
*
60+
* @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list
61+
* @param callable $value Any pseudo callable to create a unique string value from a choice
62+
* @param mixed|null $vary Dynamic data used to compute a unique hash when caching the callback
63+
*/
64+
public static function value($formType, $value, $vary = null): ChoiceValue
65+
{
66+
return new ChoiceValue($formType, $value, $vary);
67+
}
68+
69+
/**
70+
* Decorates a "choice_label" option to make it cacheable.
71+
*
72+
* @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list
73+
* @param callable|false $label Any pseudo callable to create a label from a choice or false to discard it
74+
* @param mixed|null $vary Dynamic data used to compute a unique hash when caching the option
75+
*/
76+
public static function label($formType, $label, $vary = null): ChoiceLabel
77+
{
78+
return new ChoiceLabel($formType, $label, $vary);
79+
}
80+
81+
/**
82+
* Decorates a "choice_name" callback to make it cacheable.
83+
*
84+
* @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list
85+
* @param callable $fieldName Any pseudo callable to create a field name from a choice
86+
* @param mixed|null $vary Dynamic data used to compute a unique hash when caching the callback
87+
*/
88+
public static function fieldName($formType, $fieldName, $vary = null): ChoiceFieldName
89+
{
90+
return new ChoiceFieldName($formType, $fieldName, $vary);
91+
}
92+
93+
/**
94+
* Decorates a "choice_attr" option to make it cacheable.
95+
*
96+
* @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list
97+
* @param callable|array $attr Any pseudo callable or array to create html attributes from a choice
98+
* @param mixed|null $vary Dynamic data used to compute a unique hash when caching the option
99+
*/
100+
public static function attr($formType, $attr, $vary = null): ChoiceAttr
101+
{
102+
return new ChoiceAttr($formType, $attr, $vary);
103+
}
104+
105+
/**
106+
* Decorates a "group_by" callback to make it cacheable.
107+
*
108+
* @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list
109+
* @param callable $groupBy Any pseudo callable to return a group name from a choice
110+
* @param mixed|null $vary Dynamic data used to compute a unique hash when caching the callback
111+
*/
112+
public static function groupBy($formType, $groupBy, $vary = null): GroupBy
113+
{
114+
return new GroupBy($formType, $groupBy, $vary);
115+
}
116+
117+
/**
118+
* Decorates a "preferred_choices" option to make it cacheable.
119+
*
120+
* @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list
121+
* @param callable|array $preferred Any pseudo callable or array to return a group name from a choice
122+
* @param mixed|null $vary Dynamic data used to compute a unique hash when caching the option
123+
*/
124+
public static function preferred($formType, $preferred, $vary = null): PreferredChoice
125+
{
126+
return new PreferredChoice($formType, $preferred, $vary);
127+
}
128+
129+
/**
130+
* Should not be instantiated.
131+
*/
132+
private function __construct()
133+
{
134+
}
135+
}

0 commit comments

Comments
 (0)
0