diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index f4e137f04b980..828fbb568e462 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -245,6 +245,9 @@ private function addFormSection(ArrayNodeDefinition $rootNode, callable $enableI ->info('Form configuration') ->{$enableIfStandalone('symfony/form', Form::class)}() ->children() + ->booleanNode('use_attribute') + ->defaultFalse() + ->end() ->arrayNode('csrf_protection') ->treatFalseLike(['enabled' => false]) ->treatTrueLike(['enabled' => true]) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2910c7cbe6508..8ce9ebfc5f537 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -82,6 +82,7 @@ use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Glob; +use Symfony\Component\Form\Attribute\AsFormType; use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension; use Symfony\Component\Form\Form; use Symfony\Component\Form\FormTypeExtensionInterface; @@ -891,6 +892,18 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont $container->setParameter('form.type_extension.csrf.enabled', false); } + if ($config['form']['use_attribute']) { + $loader->load('form_metadata.php'); + + $container->registerAttributeForAutoconfiguration(AsFormType::class, static function (ChildDefinition $definition, AsFormType $attribute, \ReflectionClass $ref) { + $definition + ->addTag('container.excluded.form.metadata.form_type', ['class_name' => $ref->getName()]) + ->addTag('container.excluded') + ->setAbstract(true) + ; + }); + } + if (!ContainerBuilder::willBeAvailable('symfony/translation', Translator::class, ['symfony/framework-bundle', 'symfony/form'])) { $container->removeDefinition('form.type_extension.upload.validator'); } diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php index 7ef10bb522af0..b91173a791142 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php @@ -335,6 +335,7 @@ [], // All type extensions are stored here by FormPass [], // All type guessers are stored here by FormPass service('debug.file_link_formatter')->nullOnInvalid(), + [], // All metadata form types are stored here by FormPass ]) ->tag('console.command') diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_metadata.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_metadata.php new file mode 100644 index 0000000000000..bb989d2387504 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_metadata.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Form\Extension\Metadata\MetadataExtension; +use Symfony\Component\Form\Metadata\Loader\AttributeLoader; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('form.metadata.attribute_loader', AttributeLoader::class) + + ->alias('form.metadata.default_loader', 'form.metadata.attribute_loader') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index c8142e98ab1a7..3e1c1027a7fdd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -751,6 +751,7 @@ protected static function getBundleDefaultConfig() 'field_attr' => ['data-controller' => 'csrf-protection'], 'token_id' => null, ], + 'use_attribute' => false, ], 'esi' => ['enabled' => false], 'ssi' => ['enabled' => false], diff --git a/src/Symfony/Component/Form/AbstractType.php b/src/Symfony/Component/Form/AbstractType.php index 8fffa379d8496..b1d74cabf7dbf 100644 --- a/src/Symfony/Component/Form/AbstractType.php +++ b/src/Symfony/Component/Form/AbstractType.php @@ -63,4 +63,9 @@ public function getBlockPrefix() { return StringUtil::fqcnToBlockPrefix(static::class) ?: ''; } + + final public function getClassName(): string + { + return static::class; + } } diff --git a/src/Symfony/Component/Form/Attribute/AsFormType.php b/src/Symfony/Component/Form/Attribute/AsFormType.php new file mode 100644 index 0000000000000..d2c303190a4ca --- /dev/null +++ b/src/Symfony/Component/Form/Attribute/AsFormType.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Attribute; + +/** + * Register a model class (e.g. DTO, entity, model, etc...) as a FormType. + * + * @author Benjamin Georgeault + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final readonly class AsFormType +{ + /** + * @param array $options + */ + public function __construct( + private array $options = [], + ) { + } + + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/Symfony/Component/Form/Attribute/Type.php b/src/Symfony/Component/Form/Attribute/Type.php new file mode 100644 index 0000000000000..a7ccd1723fc62 --- /dev/null +++ b/src/Symfony/Component/Form/Attribute/Type.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Attribute; + +/** + * Add an AsFormType class property as a FormType's field. + * + * @author Benjamin Georgeault + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final readonly class Type +{ + /** + * @param class-string|null $type the FormType class name to use for this field + * @param array $options your form options + * @param string|null $name change the form view field's name + */ + public function __construct( + private ?string $type = null, + private array $options = [], + private ?string $name = null, + ) { + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @return class-string|null + */ + public function getType(): ?string + { + return $this->type; + } + + public function getName(): ?string + { + return $this->name; + } +} diff --git a/src/Symfony/Component/Form/Command/DebugCommand.php b/src/Symfony/Component/Form/Command/DebugCommand.php index 91db6f1a91365..f435173c6de7e 100644 --- a/src/Symfony/Component/Form/Command/DebugCommand.php +++ b/src/Symfony/Component/Form/Command/DebugCommand.php @@ -42,6 +42,7 @@ public function __construct( private array $extensions = [], private array $guessers = [], private ?FileLinkFormatter $fileLinkFormatter = null, + private array $metadataTypes = [], ) { parent::__construct(); } @@ -95,6 +96,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $object = null; $options['core_types'] = $this->getCoreTypes(); $options['service_types'] = array_values(array_diff($this->types, $options['core_types'])); + $options['metadata_types'] = $this->metadataTypes; if ($input->getOption('show-deprecated')) { $options['core_types'] = $this->filterTypesByDeprecated($options['core_types']); $options['service_types'] = $this->filterTypesByDeprecated($options['service_types']); @@ -150,7 +152,7 @@ private function getFqcnTypeClass(InputInterface $input, SymfonyStyle $io, strin if (0 === $count = \count($classes)) { $message = \sprintf("Could not find type \"%s\" into the following namespaces:\n %s", $shortClassName, implode("\n ", $this->namespaces)); - $allTypes = array_merge($this->getCoreTypes(), $this->types); + $allTypes = array_merge($this->getCoreTypes(), $this->types, $this->metadataTypes); if ($alternatives = $this->findAlternatives($shortClassName, $allTypes)) { if (1 === \count($alternatives)) { $message .= "\n\nDid you mean this?\n "; @@ -238,7 +240,7 @@ private function findAlternatives(string $name, array $collection): array public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { if ($input->mustSuggestArgumentValuesFor('class')) { - $suggestions->suggestValues(array_merge($this->getCoreTypes(), $this->types)); + $suggestions->suggestValues(array_merge($this->getCoreTypes(), $this->types, $this->metadataTypes)); return; } diff --git a/src/Symfony/Component/Form/Console/Descriptor/JsonDescriptor.php b/src/Symfony/Component/Form/Console/Descriptor/JsonDescriptor.php index 1eca762b73b4d..5c8933a628003 100644 --- a/src/Symfony/Component/Form/Console/Descriptor/JsonDescriptor.php +++ b/src/Symfony/Component/Form/Console/Descriptor/JsonDescriptor.php @@ -25,6 +25,7 @@ protected function describeDefaults(array $options): void { $data['builtin_form_types'] = $options['core_types']; $data['service_form_types'] = $options['service_types']; + $data['metadata_form_types'] = $options['metadata_types']; if (!$options['show_deprecated']) { $data['type_extensions'] = $options['extensions']; $data['type_guessers'] = $options['guessers']; diff --git a/src/Symfony/Component/Form/Console/Descriptor/TextDescriptor.php b/src/Symfony/Component/Form/Console/Descriptor/TextDescriptor.php index e12b3426283f9..1f07efb9f7020 100644 --- a/src/Symfony/Component/Form/Console/Descriptor/TextDescriptor.php +++ b/src/Symfony/Component/Form/Console/Descriptor/TextDescriptor.php @@ -44,6 +44,11 @@ protected function describeDefaults(array $options): void $this->output->listing(array_map($this->formatClassLink(...), $options['service_types'])); } + if ($options['metadata_types']) { + $this->output->section('Metadata form types'); + $this->output->listing(array_map($this->formatClassLink(...), $options['metadata_types'])); + } + if (!$options['show_deprecated']) { if ($options['extensions']) { $this->output->section('Type extensions'); diff --git a/src/Symfony/Component/Form/DependencyInjection/FormPass.php b/src/Symfony/Component/Form/DependencyInjection/FormPass.php index bec1782d40995..c5c50e3b51dfc 100644 --- a/src/Symfony/Component/Form/DependencyInjection/FormPass.php +++ b/src/Symfony/Component/Form/DependencyInjection/FormPass.php @@ -17,8 +17,11 @@ use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Form\Extension\Metadata\Type\MetadataType; +use Symfony\Component\Form\Metadata\FormMetadataInterface; /** * Adds all services with the tags "form.type", "form.type_extension" and @@ -46,6 +49,7 @@ private function processFormTypes(ContainerBuilder $container): Reference { // Get service locator argument $servicesMap = []; + $metadataTypeMap = []; $namespaces = ['Symfony\Component\Form\Extension\Core\Type' => true]; $csrfTokenIds = []; @@ -61,10 +65,33 @@ private function processFormTypes(ContainerBuilder $container): Reference } } + foreach ($container->findTaggedResourceIds('container.excluded.form.metadata.form_type') as $excludedServiceId => $tag) { + if (!isset($tag[0]['class_name'])) { + throw new InvalidArgumentException(\sprintf('The excluded service "%s" with tag "container.excluded.form.metadata.form_type" must have the tag\'s attribute "class_name" set.', $excludedServiceId)); + } + + $className = $tag[0]['class_name']; + $formTypeId = $excludedServiceId.'.form_type'; + $metadataId = $excludedServiceId.'.metadata'; + + $container->setDefinition($metadataId, new Definition(FormMetadataInterface::class)) + ->setFactory([new Reference('form.metadata.default_loader'), 'load']) + ->addArgument($className) + ->addTag('form.metadata'); + + $container->setDefinition($formTypeId, new Definition(MetadataType::class)) + ->addArgument(new Reference($metadataId)) + ->addTag('form.metadata_type', ['class_name' => $className]); + + $metadataTypeMap[$className] = new Reference($formTypeId); + $namespaces[substr($className, 0, strrpos($className, '\\'))] = true; + } + if ($container->hasDefinition('console.command.form_debug')) { $commandDefinition = $container->getDefinition('console.command.form_debug'); $commandDefinition->setArgument(1, array_keys($namespaces)); $commandDefinition->setArgument(2, array_keys($servicesMap)); + $commandDefinition->setArgument(6, array_keys($metadataTypeMap)); } if ($csrfTokenIds && $container->hasDefinition('form.type_extension.csrf')) { @@ -75,7 +102,7 @@ private function processFormTypes(ContainerBuilder $container): Reference } } - return ServiceLocatorTagPass::register($container, $servicesMap); + return ServiceLocatorTagPass::register($container, [...$servicesMap, ...$metadataTypeMap]); } private function processFormTypeExtensions(ContainerBuilder $container): array diff --git a/src/Symfony/Component/Form/Exception/MetadataException.php b/src/Symfony/Component/Form/Exception/MetadataException.php new file mode 100644 index 0000000000000..fa1d763f20b7c --- /dev/null +++ b/src/Symfony/Component/Form/Exception/MetadataException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +/** + * Thrown when an error occurred during Metadata creation. + * + * @author Benjamin Georgeault + */ +class MetadataException extends LogicException +{ +} diff --git a/src/Symfony/Component/Form/Extension/DataCollector/FormDataExtractor.php b/src/Symfony/Component/Form/Extension/DataCollector/FormDataExtractor.php index f56fe911fa056..e9f752336c7c6 100644 --- a/src/Symfony/Component/Form/Extension/DataCollector/FormDataExtractor.php +++ b/src/Symfony/Component/Form/Extension/DataCollector/FormDataExtractor.php @@ -27,7 +27,7 @@ public function extractConfiguration(FormInterface $form): array $data = [ 'id' => $this->buildId($form), 'name' => $form->getName(), - 'type_class' => $form->getConfig()->getType()->getInnerType()::class, + 'type_class' => $form->getConfig()->getType()->getInnerType()->getClassName(), 'synchronized' => $form->isSynchronized(), 'passed_options' => [], 'resolved_options' => [], diff --git a/src/Symfony/Component/Form/Extension/Metadata/MetadataExtension.php b/src/Symfony/Component/Form/Extension/Metadata/MetadataExtension.php new file mode 100644 index 0000000000000..a5f25f29759e1 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Metadata/MetadataExtension.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Metadata; + +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\MetadataException; +use Symfony\Component\Form\Extension\Metadata\Type\MetadataType; +use Symfony\Component\Form\FormExtensionInterface; +use Symfony\Component\Form\FormTypeGuesserInterface; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\Form\Metadata\Loader\LoaderInterface; + +/** + * Responsible for instantiating FormType based on a {@see \Symfony\Component\Form\Metadata\FormMetadataInterface}. + * + * @author Benjamin Georgeault + */ +final class MetadataExtension implements FormExtensionInterface +{ + /** + * @var array + */ + private array $loadedTypes = []; + + public function __construct( + private readonly LoaderInterface $loader, + ) { + } + + public function getType(string $name): FormTypeInterface + { + if (null !== $type = $this->loadedTypes[$name] ?? null) { + return $type; + } + + try { + return $this->loadedTypes[$name] = new MetadataType($this->loader->load($name)); + } catch (MetadataException $e) { + throw new InvalidArgumentException(\sprintf('Cannot instantiate a "%s" for the given class "%s".', FormTypeInterface::class, $name), previous: $e); + } + } + + public function hasType(string $name): bool + { + return ($this->loadedTypes[$name] ?? false) || $this->loader->support($name); + } + + public function getTypeExtensions(string $name): array + { + return []; + } + + public function hasTypeExtensions(string $name): bool + { + return false; + } + + public function getTypeGuesser(): ?FormTypeGuesserInterface + { + return null; + } +} diff --git a/src/Symfony/Component/Form/Extension/Metadata/Type/MetadataType.php b/src/Symfony/Component/Form/Extension/Metadata/Type/MetadataType.php new file mode 100644 index 0000000000000..f1e501c950280 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Metadata/Type/MetadataType.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Metadata\Type; + +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\Metadata\FormMetadataInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @internal + * + * @author Benjamin Georgeault + */ +final readonly class MetadataType implements FormTypeInterface +{ + public function __construct( + private FormMetadataInterface $metadata, + ) { + } + + public function getParent(): string + { + return $this->metadata->getParent(); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults($this->metadata->getOptions()); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + foreach ($this->metadata->getFields() as $fieldMetadata) { + $builder->add($fieldMetadata->getName(), $fieldMetadata->getType(), $fieldMetadata->getOptions()); + } + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['form_metadata'] = $this->metadata; + } + + public function finishView(FormView $view, FormInterface $form, array $options): void + { + } + + public function getBlockPrefix(): string + { + return $this->metadata->getBlockPrefix(); + } + + public function getClassName(): string + { + return $this->metadata->getClassName(); + } +} diff --git a/src/Symfony/Component/Form/FormRegistry.php b/src/Symfony/Component/Form/FormRegistry.php index ecf654a2a3dc1..fcada0248cebf 100644 --- a/src/Symfony/Component/Form/FormRegistry.php +++ b/src/Symfony/Component/Form/FormRegistry.php @@ -90,7 +90,13 @@ public function getType(string $name): ResolvedFormTypeInterface private function resolveType(FormTypeInterface $type): ResolvedFormTypeInterface { $parentType = $type->getParent(); - $fqcn = $type::class; + + if (method_exists($type, 'getClassName')) { + $fqcn = $type->getClassName(); + } else { + trigger_deprecation('symfony/form', '7.4', 'Not implementing the method "getClassName" on "%s" that implement "%s" is deprecated. It will mandatory in 8.0.', $type::class, FormTypeInterface::class); + $fqcn = $type::class; + } if (isset($this->checkedTypes[$fqcn])) { $types = implode(' > ', array_merge(array_keys($this->checkedTypes), [$fqcn])); diff --git a/src/Symfony/Component/Form/FormTypeInterface.php b/src/Symfony/Component/Form/FormTypeInterface.php index 2bc9f7711e9a6..03a7b37790fea 100644 --- a/src/Symfony/Component/Form/FormTypeInterface.php +++ b/src/Symfony/Component/Form/FormTypeInterface.php @@ -14,6 +14,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver; /** + * @method string getClassName() + * * @author Bernhard Schussek */ interface FormTypeInterface @@ -95,4 +97,11 @@ public function finishView(FormView $view, FormInterface $form, array $options); * @return string */ public function getBlockPrefix(); + + /* + * Returns the FQCN of the class representing the FormType. + * + * @return class-string + */ + // public function getClassName(): string; } diff --git a/src/Symfony/Component/Form/Metadata/FieldMetadata.php b/src/Symfony/Component/Form/Metadata/FieldMetadata.php new file mode 100644 index 0000000000000..d775a2350eba8 --- /dev/null +++ b/src/Symfony/Component/Form/Metadata/FieldMetadata.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Metadata; + +/** + * Represent metadata for a FormType's field. + * + * @author Benjamin Georgeault + */ +final readonly class FieldMetadata implements FieldMetadataInterface +{ + /** + * @param class-string|null $type + * @param array $options + */ + public function __construct( + private string $name, + private ?string $type, + private array $options, + ) { + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return class-string|null + */ + public function getType(): ?string + { + return $this->type; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/Symfony/Component/Form/Metadata/FieldMetadataInterface.php b/src/Symfony/Component/Form/Metadata/FieldMetadataInterface.php new file mode 100644 index 0000000000000..dc4501d5a6e09 --- /dev/null +++ b/src/Symfony/Component/Form/Metadata/FieldMetadataInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Metadata; + +/** + * Represent the contract of metadata for a FormType's field. + * + * @author Benjamin Georgeault + */ +interface FieldMetadataInterface +{ + public function getName(): string; + + /** + * @return class-string|null + */ + public function getType(): ?string; + + /** + * @return array + */ + public function getOptions(): array; +} diff --git a/src/Symfony/Component/Form/Metadata/FormMetadata.php b/src/Symfony/Component/Form/Metadata/FormMetadata.php new file mode 100644 index 0000000000000..12e24408b6ce9 --- /dev/null +++ b/src/Symfony/Component/Form/Metadata/FormMetadata.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Metadata; + +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Util\StringUtil; + +/** + * Represent metadata for a FormType. + * + * @author Benjamin Georgeault + */ +final readonly class FormMetadata implements FormMetadataInterface +{ + private array $options; + + /** + * @param array $fields + * @param array $options + */ + public function __construct( + private string $className, + private ?string $parent = null, + private array $fields = [], + array $options = [], + ) { + $this->options = [ + ...$options, + 'data_class' => $this->className, + ]; + } + + public function getClassName(): string + { + return $this->className; + } + + public function getParent(): string + { + return $this->parent ?? FormType::class; + } + + public function getBlockPrefix(): string + { + return StringUtil::fqcnToBlockPrefix($this->getClassName()) ?: ''; + } + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->fields; + } +} diff --git a/src/Symfony/Component/Form/Metadata/FormMetadataInterface.php b/src/Symfony/Component/Form/Metadata/FormMetadataInterface.php new file mode 100644 index 0000000000000..f5d4a3197de9c --- /dev/null +++ b/src/Symfony/Component/Form/Metadata/FormMetadataInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Metadata; + +/** + * Represent the contract of metadata for a FormType. + * + * @author Benjamin Georgeault + */ +interface FormMetadataInterface +{ + /** + * @return class-string + */ + public function getClassName(): string; + + public function getParent(): string; + + public function getBlockPrefix(): string; + + /** + * @return array + */ + public function getOptions(): array; + + /** + * @return array + */ + public function getFields(): array; +} diff --git a/src/Symfony/Component/Form/Metadata/Loader/AttributeLoader.php b/src/Symfony/Component/Form/Metadata/Loader/AttributeLoader.php new file mode 100644 index 0000000000000..7869658e54e01 --- /dev/null +++ b/src/Symfony/Component/Form/Metadata/Loader/AttributeLoader.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Metadata\Loader; + +use Symfony\Component\Form\Attribute\AsFormType; +use Symfony\Component\Form\Attribute\Type; +use Symfony\Component\Form\Exception\MetadataException; +use Symfony\Component\Form\Metadata\FieldMetadata; +use Symfony\Component\Form\Metadata\FormMetadata; +use Symfony\Component\Form\Metadata\FormMetadataInterface; + +/** + * Load a {@see FormMetadata} from a class where an attribute {@see AsFormType} is applied. + * + * @author Benjamin Georgeault + * + * @template T as object + */ +final readonly class AttributeLoader implements LoaderInterface +{ + public function load(string $class): FormMetadataInterface + { + if (!class_exists($class)) { + throw new MetadataException(\sprintf('Class "%s" does not exist.', $class)); + } + + $reflectionClass = new \ReflectionClass($class); + if (null === $asFormType = $this->getOneAttributeInstance(AsFormType::class, $reflectionClass)) { + throw new MetadataException(\sprintf('The loader "%s" cannot load metadata for class "%s". There is no "%s" attribute on it.', self::class, $class, AsFormType::class)); + } + + $fields = []; + foreach ($this->getFields($reflectionClass) as $name => $typeAttribute) { + if (null !== $typeAttribute->getType() && !class_exists($typeAttribute->getType())) { + throw new MetadataException(\sprintf('The given form type "%s" does not exist for field "%s" of class "%s".', $typeAttribute->getType(), $name, $class)); + } + + $options = $typeAttribute->getOptions(); + if (null === $viewName = $typeAttribute->getName()) { + $fieldName = $name; + } else { + $options['property_path'] = $name; + $fieldName = $viewName; + } + + $fields[$fieldName] = new FieldMetadata($fieldName, $typeAttribute->getType(), $options); + } + + return new FormMetadata( + $class, + $this->closestAsFormTypeParent($reflectionClass), + $fields, + $asFormType->getOptions(), + ); + } + + public function support(string $class): bool + { + return class_exists($class) && $this->isAsFormType(new \ReflectionClass($class)); + } + + /** + * @return iterable + */ + private function getFields(\ReflectionClass $reflectionClass): iterable + { + foreach ($reflectionClass->getProperties() as $propRef) { + if ( + $reflectionClass->getName() !== $propRef->getDeclaringClass()->getName() + || null === $typeAttribute = $this->getOneAttributeInstance(Type::class, $propRef) + ) { + continue; + } + + yield $propRef->getName() => $typeAttribute; + } + } + + /** + * @param class-string $attributeClass + * + * @return T|null + */ + private function getOneAttributeInstance(string $attributeClass, \ReflectionProperty|\ReflectionClass $ref): ?object + { + foreach ($this->getAttributeInstances($attributeClass, $ref) as $attrInstance) { + return $attrInstance; + } + + return null; + } + + /** + * @param class-string $attributeClass + * + * @return iterable + */ + private function getAttributeInstances(string $attributeClass, \ReflectionProperty|\ReflectionClass $ref): iterable + { + /** @var \ReflectionAttribute $attrRef */ + foreach ($ref->getAttributes() as $attrRef) { + if (is_a($attrRef->getName(), $attributeClass, true)) { + yield $attrRef->newInstance(); + } + } + } + + private function closestAsFormTypeParent(\ReflectionClass $reflectionClass): ?string + { + while ($parent = $reflectionClass->getParentClass()) { + if ($this->isAsFormType($parent)) { + return $parent->getName(); + } + + $reflectionClass = $parent; + } + + return null; + } + + private function isAsFormType(\ReflectionClass $reflectionClass): bool + { + foreach ($reflectionClass->getAttributes() as $attrRef) { + if (is_a($attrRef->getName(), AsFormType::class, true)) { + return true; + } + } + + return false; + } +} diff --git a/src/Symfony/Component/Form/Metadata/Loader/LoaderInterface.php b/src/Symfony/Component/Form/Metadata/Loader/LoaderInterface.php new file mode 100644 index 0000000000000..01d68b8ebdb86 --- /dev/null +++ b/src/Symfony/Component/Form/Metadata/Loader/LoaderInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Metadata\Loader; + +use Symfony\Component\Form\Exception\MetadataException; +use Symfony\Component\Form\Metadata\FormMetadataInterface; + +/** + * Implement this interface to create a loader to create {@see FormMetadataInterface} from a class. + * + * @author Benjamin Georgeault + */ +interface LoaderInterface +{ + /** + * @param class-string $class + * + * @throws MetadataException if metadata cannot be loaded + */ + public function load(string $class): FormMetadataInterface; + + /** + * @param class-string $class + */ + public function support(string $class): bool; +} diff --git a/src/Symfony/Component/Form/Tests/Console/Descriptor/AbstractDescriptorTestCase.php b/src/Symfony/Component/Form/Tests/Console/Descriptor/AbstractDescriptorTestCase.php index 456b433eee115..612cc70363896 100644 --- a/src/Symfony/Component/Form/Tests/Console/Descriptor/AbstractDescriptorTestCase.php +++ b/src/Symfony/Component/Form/Tests/Console/Descriptor/AbstractDescriptorTestCase.php @@ -84,6 +84,7 @@ public static function getDescribeDefaultsTestData() { $options['core_types'] = ['Symfony\Component\Form\Extension\Core\Type\FormType']; $options['service_types'] = ['Symfony\Bridge\Doctrine\Form\Type\EntityType']; + $options['metadata_types'] = []; $options['extensions'] = ['Symfony\Component\Form\Extension\Csrf\Type\FormTypeCsrfExtension']; $options['guessers'] = ['Symfony\Component\Form\Extension\Validator\ValidatorTypeGuesser']; $options['decorated'] = false; diff --git a/src/Symfony/Component/Form/Tests/DependencyInjection/FormPassTest.php b/src/Symfony/Component/Form/Tests/DependencyInjection/FormPassTest.php index f0ccd3f095fb0..8e349a2cf0660 100644 --- a/src/Symfony/Component/Form/Tests/DependencyInjection/FormPassTest.php +++ b/src/Symfony/Component/Form/Tests/DependencyInjection/FormPassTest.php @@ -75,7 +75,15 @@ public function testAddTaggedTypesToDebugCommand() $container = $this->createContainerBuilder(); $container->register('form.registry', FormRegistry::class); - $commandDefinition = new Definition(DebugCommand::class, [new Reference('form.registry')]); + $commandDefinition = new Definition(DebugCommand::class, [ + new Reference('form.registry'), + [], + [], + [], + [], + null, + [], + ]); $commandDefinition->setPublic(true); $container->setDefinition('form.extension', $this->createExtensionDefinition()); diff --git a/src/Symfony/Component/Form/Tests/Extension/DataCollector/FormDataExtractorTest.php b/src/Symfony/Component/Form/Tests/Extension/DataCollector/FormDataExtractorTest.php index 29f9359df8df0..b096391dbd9c1 100644 --- a/src/Symfony/Component/Form/Tests/Extension/DataCollector/FormDataExtractorTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/DataCollector/FormDataExtractorTest.php @@ -18,11 +18,13 @@ use Symfony\Component\Form\Extension\Core\DataMapper\DataMapper; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\DataCollector\FormDataExtractor; +use Symfony\Component\Form\Extension\Metadata\Type\MetadataType; use Symfony\Component\Form\FormBuilder; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormFactory; use Symfony\Component\Form\FormRegistry; use Symfony\Component\Form\FormView; +use Symfony\Component\Form\Metadata\FormMetadata; use Symfony\Component\Form\ResolvedFormType; use Symfony\Component\Form\ResolvedFormTypeFactory; use Symfony\Component\Form\Tests\Fixtures\FixedDataTransformer; @@ -415,6 +417,22 @@ public function testExtractViewVariables() ], $this->dataExtractor->extractViewVariables($view)); } + public function testTypeClassWithMetadata() + { + $form = $this->createBuilder('name') + ->setType(new ResolvedFormType(new MetadataType(new FormMetadata('Foo')))) + ->getForm(); + + $this->assertSame([ + 'id' => 'name', + 'name' => 'name', + 'type_class' => 'Foo', + 'synchronized' => true, + 'passed_options' => [], + 'resolved_options' => [], + ], $this->dataExtractor->extractConfiguration($form)); + } + private function createBuilder(string $name, array $options = []): FormBuilder { return new FormBuilder($name, null, new EventDispatcher(), new FormFactory(new FormRegistry([], new ResolvedFormTypeFactory())), $options); diff --git a/src/Symfony/Component/Form/Tests/Extension/Metadata/MetadataExtensionTest.php b/src/Symfony/Component/Form/Tests/Extension/Metadata/MetadataExtensionTest.php new file mode 100644 index 0000000000000..e7e42d70be964 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Metadata/MetadataExtensionTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\Metadata; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\MetadataException; +use Symfony\Component\Form\Extension\Metadata\MetadataExtension; +use Symfony\Component\Form\Extension\Metadata\Type\MetadataType; +use Symfony\Component\Form\Metadata\FormMetadataInterface; +use Symfony\Component\Form\Metadata\Loader\LoaderInterface; + +class MetadataExtensionTest extends TestCase +{ + /** + * @dataProvider hasTypeProvider + */ + public function testHasType(string $class, bool $expected) + { + ($loader = $this->createMock(LoaderInterface::class)) + ->expects($this->once()) + ->method('support') + ->with($class) + ->willReturn($expected); + + $this->assertSame($expected, $this->getExtension($loader)->hasType($class)); + } + + public function hasTypeProvider(): \Generator + { + yield [Model::class, true]; + yield [Model::class, false]; + } + + public function testGetTypeWithException() + { + ($loader = $this->createMock(LoaderInterface::class)) + ->expects($this->once()) + ->method('load') + ->willThrowException(new MetadataException()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot instantiate a "Symfony\Component\Form\FormTypeInterface" for the given class "Foo".'); + $this->getExtension($loader)->getType('Foo'); + } + + public function testGetType() + { + ($loader = $this->createMock(LoaderInterface::class)) + ->expects($this->once()) + ->method('load') + ->willReturn($this->createMock(FormMetadataInterface::class)); + + $extension = $this->getExtension($loader); + $this->assertInstanceOf(MetadataType::class, $firstInstance = $extension->getType('Foo')); + $this->assertSame($firstInstance, $extension->getType('Foo')); + } + + private function getExtension(LoaderInterface $loader): MetadataExtension + { + return new MetadataExtension($loader); + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/Metadata/Type/MetadataTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Metadata/Type/MetadataTypeTest.php new file mode 100644 index 0000000000000..902d780768b48 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Metadata/Type/MetadataTypeTest.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\Metadata\Type; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Extension\Metadata\Type\MetadataType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\Metadata\FieldMetadataInterface; +use Symfony\Component\Form\Metadata\FormMetadataInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class MetadataTypeTest extends TestCase +{ + private FormMetadataInterface $metadata; + + protected function setUp(): void + { + $this->metadata = new class implements FormMetadataInterface { + public function getClassName(): string + { + return 'ClassName'; + } + + public function getParent(): string + { + return 'Parent'; + } + + public function getBlockPrefix(): string + { + return 'block_prefix'; + } + + public function getOptions(): array + { + return [ + 'label' => 'Foo', + ]; + } + + public function getFields(): array + { + return [ + 'foo' => new class implements FieldMetadataInterface { + public function getName(): string + { + return 'foo'; + } + + public function getType(): ?string + { + return null; + } + + public function getOptions(): array + { + return [ + 'label' => 'Foo', + ]; + } + }, + ]; + } + }; + } + + public function testGetParent() + { + $this->assertEquals('Parent', (new MetadataType($this->metadata))->getParent()); + } + + public function testConfigureOptions() + { + $resolver = new OptionsResolver(); + (new MetadataType($this->metadata))->configureOptions($resolver); + + $this->assertSame(['label' => 'Foo'], $resolver->resolve()); + } + + public function testBuildForm() + { + ($builder = $this->createMock(FormBuilderInterface::class)) + ->expects($this->once()) + ->method('add') + ->with('foo', null, [ + 'label' => 'Foo', + ]); + + (new MetadataType($this->metadata))->buildForm($builder, []); + } + + public function testGetBlockPrefix() + { + $this->assertEquals('block_prefix', (new MetadataType($this->metadata))->getBlockPrefix()); + } + + public function testClassName() + { + $this->assertEquals('ClassName', (new MetadataType($this->metadata))->getClassName()); + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/defaults_1.json b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/defaults_1.json index 7629e80431ebe..7ef3ea4d153e3 100644 --- a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/defaults_1.json +++ b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/defaults_1.json @@ -5,6 +5,7 @@ "service_form_types": [ "Symfony\\Bridge\\Doctrine\\Form\\Type\\EntityType" ], + "metadata_form_types": [], "type_extensions": [ "Symfony\\Component\\Form\\Extension\\Csrf\\Type\\FormTypeCsrfExtension" ], diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/types_with_deprecated_options.json b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/types_with_deprecated_options.json index 8648b7d5e36a5..4b89ada4833ec 100644 --- a/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/types_with_deprecated_options.json +++ b/src/Symfony/Component/Form/Tests/Fixtures/Descriptor/types_with_deprecated_options.json @@ -2,5 +2,6 @@ "builtin_form_types": [], "service_form_types": [ "Symfony\\Component\\Form\\Tests\\Console\\Descriptor\\FooType" - ] + ], + "metadata_form_types": [] } diff --git a/src/Symfony/Component/Form/Tests/Metadata/FormMetadataTest.php b/src/Symfony/Component/Form/Tests/Metadata/FormMetadataTest.php new file mode 100644 index 0000000000000..9daba67db4752 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Metadata/FormMetadataTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Metadata; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Metadata\FormMetadata; + +class FormMetadataTest extends TestCase +{ + public function testDefaultParent() + { + $this->assertSame(FormType::class, (new FormMetadata('Foo'))->getParent()); + } + + public function testDataClass() + { + $metadata = new FormMetadata('Foo'); + $this->assertSame('Foo', $metadata->getOptions()['data_class']); + + $metadata = new FormMetadata('Foo', null, [], [ + 'data_class' => 'Bar', + ]); + $this->assertSame('Foo', $metadata->getOptions()['data_class']); + } +} diff --git a/src/Symfony/Component/Form/Tests/Metadata/Loader/AttributeLoaderTest.php b/src/Symfony/Component/Form/Tests/Metadata/Loader/AttributeLoaderTest.php new file mode 100644 index 0000000000000..11556794687ad --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Metadata/Loader/AttributeLoaderTest.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Metadata\Loader; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Attribute\AsFormType; +use Symfony\Component\Form\Attribute\Type; +use Symfony\Component\Form\Exception\MetadataException; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Metadata\Loader\AttributeLoader; + +class AttributeLoaderTest extends TestCase +{ + private AttributeLoader $loader; + + protected function setUp(): void + { + $this->loader = new AttributeLoader(); + } + + /** + * @dataProvider supportProvider + */ + public function testSupport(string $class, bool $expected) + { + $this->assertSame($expected, $this->loader->support($class)); + } + + public function supportProvider(): \Generator + { + yield 'Class with AsFormType attribute' => [Model::class, true]; + yield 'Class without AsFormType attribute' => [ModelNoForm::class, false]; + } + + /** + * @dataProvider loadWithExceptionProvider + */ + public function testLoadWithException(string $class, string $exceptionMessage) + { + $this->expectException(MetadataException::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->loader->load($class); + } + + public function loadWithExceptionProvider(): \Generator + { + yield 'Non existing class' => [ + ClassThatDoNotExistAtAll::class, + 'Class "Symfony\Component\Form\Tests\Metadata\Loader\ClassThatDoNotExistAtAll" does not exist.', + ]; + + yield 'Class missing AsFormType attribute' => [ + ModelNoForm::class, + 'The loader "Symfony\Component\Form\Metadata\Loader\AttributeLoader" cannot load metadata for class "Symfony\Component\Form\Tests\Metadata\Loader\ModelNoForm". There is no "Symfony\Component\Form\Attribute\AsFormType" attribute on it.', + ]; + + yield 'Non existing field type' => [ + ModelErrorField::class, + 'The given form type "Symfony\Component\Form\Tests\Metadata\Loader\ClassThatDoNotExistAtAll" does not exist for field "name" of class "Symfony\Component\Form\Tests\Metadata\Loader\ModelErrorField".', + ]; + } + + public function testLoad() + { + $metadata = $this->loader->load(Model::class); + + $this->assertSame(Model::class, $metadata->getClassName()); + $this->assertSame(FormType::class, $metadata->getParent()); + $this->assertSame([ + 'data_class' => Model::class, + ], $metadata->getOptions()); + + $fields = $metadata->getFields(); + $this->assertCount(4, $fields); + + $nameField = $fields['name']; + $this->assertSame('name', $nameField->getName()); + $this->assertNull($nameField->getType()); + $this->assertEmpty($nameField->getOptions()); + + $withOptions = $fields['withOptions']; + $this->assertSame('withOptions', $withOptions->getName()); + $this->assertNull($withOptions->getType()); + $this->assertSame([ + 'label' => 'value', + ], $withOptions->getOptions()); + + $description = $fields['description']; + $this->assertSame('description', $description->getName()); + $this->assertSame(TextareaType::class, $description->getType()); + $this->assertEmpty($description->getOptions()); + + $withTypeAndOptions = $fields['withTypeAndOptions']; + $this->assertSame('withTypeAndOptions', $withTypeAndOptions->getName()); + $this->assertSame(TextareaType::class, $withTypeAndOptions->getType()); + $this->assertSame([ + 'label' => 'value', + ], $withTypeAndOptions->getOptions()); + } + + /** + * @dataProvider inheritanceProvider + */ + public function testInheritance(string $class, string $expectedParent, array $directFields) + { + $metadata = $this->loader->load($class); + + $this->assertSame($expectedParent, $metadata->getParent()); + $this->assertSame($directFields, array_keys($metadata->getFields())); + } + + public function inheritanceProvider(): \Generator + { + yield 'No extends' => [Model::class, FormType::class, ['name', 'withOptions', 'description', 'withTypeAndOptions']]; + yield 'With extends' => [ModelChild::class, Model::class, ['anotherField']]; + } + + public function testRenameField() + { + $metadata = $this->loader->load(ModelRenameViewField::class); + + $fields = $metadata->getFields(); + + $this->assertArrayHasKey('renamedField', $fields); + $this->assertArrayNotHasKey('originalField', $fields); + + $options = $fields['renamedField']->getOptions(); + + $this->assertSame(['property_path' => 'originalField'], $options); + } +} + +#[AsFormType] +class Model +{ + #[Type] + public string $name; + + #[Type(options: ['label' => 'value'])] + public string $withOptions; + + #[Type(TextareaType::class)] + public string $description; + + #[Type(TextareaType::class, ['label' => 'value'])] + public string $withTypeAndOptions; +} + +class ModelNoForm +{ +} + +#[AsFormType] +class ModelErrorField +{ + #[Type(ClassThatDoNotExistAtAll::class)] + public string $name; +} + +#[AsFormType] +class ModelChild extends Model +{ + #[Type] + public string $anotherField; +} + +#[AsFormType] +class ModelRenameViewField +{ + #[Type(name: 'renamedField')] + public string $originalField; +}