diff --git a/composer.json b/composer.json index 1e83a16e2..51aeed35c 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "symfony/twig-bundle": "~6.4.0", "symfony/var-dumper": "~6.4.0", "symfony/yaml": "~6.4.0", + "symfonycasts/dynamic-forms": "^0.1.3", "tecnickcom/tcpdf": "^6.6.0", "thenetworg/oauth2-azure": "^2.0" }, diff --git a/lib/composer/autoload_classmap.php b/lib/composer/autoload_classmap.php index 87e679229..b8a59d585 100644 --- a/lib/composer/autoload_classmap.php +++ b/lib/composer/autoload_classmap.php @@ -2816,6 +2816,9 @@ return array( 'Symfony\\Runtime\\Symfony\\Component\\HttpFoundation\\RequestRuntime' => $vendorDir . '/symfony/runtime/Internal/HttpFoundation/RequestRuntime.php', 'Symfony\\Runtime\\Symfony\\Component\\HttpFoundation\\ResponseRuntime' => $vendorDir . '/symfony/runtime/Internal/HttpFoundation/ResponseRuntime.php', 'Symfony\\Runtime\\Symfony\\Component\\HttpKernel\\HttpKernelInterfaceRuntime' => $vendorDir . '/symfony/runtime/Internal/HttpKernel/HttpKernelInterfaceRuntime.php', + 'Symfonycasts\\DynamicForms\\DependentField' => $vendorDir . '/symfonycasts/dynamic-forms/src/DependentField.php', + 'Symfonycasts\\DynamicForms\\DependentFieldConfig' => $vendorDir . '/symfonycasts/dynamic-forms/src/DependentFieldConfig.php', + 'Symfonycasts\\DynamicForms\\DynamicFormBuilder' => $vendorDir . '/symfonycasts/dynamic-forms/src/DynamicFormBuilder.php', 'SynchroExceptionNotStarted' => $baseDir . '/application/exceptions/SynchroExceptionNotStarted.php', 'System' => $vendorDir . '/pear/pear-core-minimal/src/System.php', 'TCPDF' => $vendorDir . '/tecnickcom/tcpdf/tcpdf.php', diff --git a/lib/composer/autoload_psr4.php b/lib/composer/autoload_psr4.php index 6816f3d26..14a1bd66f 100644 --- a/lib/composer/autoload_psr4.php +++ b/lib/composer/autoload_psr4.php @@ -8,6 +8,7 @@ $baseDir = dirname($vendorDir); return array( 'Twig\\' => array($vendorDir . '/twig/twig/src'), 'TheNetworg\\OAuth2\\Client\\' => array($vendorDir . '/thenetworg/oauth2-azure/src'), + 'Symfonycasts\\DynamicForms\\' => array($vendorDir . '/symfonycasts/dynamic-forms/src'), 'Symfony\\Runtime\\Symfony\\Component\\' => array($vendorDir . '/symfony/runtime/Internal'), 'Symfony\\Polyfill\\Php83\\' => array($vendorDir . '/symfony/polyfill-php83'), 'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'), diff --git a/lib/composer/installed.json b/lib/composer/installed.json index 681a0acf7..f40bebc3d 100644 --- a/lib/composer/installed.json +++ b/lib/composer/installed.json @@ -5051,6 +5051,63 @@ ], "install-path": "../symfony/yaml" }, + { + "name": "symfonycasts/dynamic-forms", + "version": "v0.1.3", + "version_normalized": "0.1.3.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/dynamic-forms.git", + "reference": "4c86c48f18a707e451c4dfffe87f3710b2052be6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/dynamic-forms/zipball/4c86c48f18a707e451c4dfffe87f3710b2052be6", + "reference": "4c86c48f18a707e451c4dfffe87f3710b2052be6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/form": "^5.4|^6.3|^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "symfony/framework-bundle": "^6.3|^7.0", + "symfony/options-resolver": "^5.4|^6.3|^7.0", + "symfony/phpunit-bridge": "^5.4.32|^6.3.9|^7.0", + "symfony/twig-bundle": "^5.4|^6.3|^7.0", + "twig/twig": "^2.15|^3.0", + "zenstruck/browser": "^1.4" + }, + "time": "2024-10-22T16:59:02+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Symfonycasts\\DynamicForms\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Weaver", + "homepage": "https://symfonycasts.com" + } + ], + "description": "Add dynamic/dependent fields to Symfony forms", + "keywords": [ + "Forms", + "symfony" + ], + "support": { + "issues": "https://github.com/SymfonyCasts/dynamic-forms/issues", + "source": "https://github.com/SymfonyCasts/dynamic-forms/tree/v0.1.3" + }, + "install-path": "../symfonycasts/dynamic-forms" + }, { "name": "tecnickcom/tcpdf", "version": "6.10.0", diff --git a/lib/composer/installed.php b/lib/composer/installed.php index a513414cd..cf8816fe8 100644 --- a/lib/composer/installed.php +++ b/lib/composer/installed.php @@ -3,7 +3,7 @@ 'name' => 'combodo/itop', 'pretty_version' => 'dev-develop', 'version' => 'dev-develop', - 'reference' => '7e515e7216f019f4c69e3699ad9bc6221988ff1e', + 'reference' => '5cb1102e6e3ea68aae14166e47455934fc42669e', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -22,7 +22,7 @@ 'combodo/itop' => array( 'pretty_version' => 'dev-develop', 'version' => 'dev-develop', - 'reference' => '7e515e7216f019f4c69e3699ad9bc6221988ff1e', + 'reference' => '5cb1102e6e3ea68aae14166e47455934fc42669e', 'type' => 'project', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -660,6 +660,15 @@ 'aliases' => array(), 'dev_requirement' => false, ), + 'symfonycasts/dynamic-forms' => array( + 'pretty_version' => 'v0.1.3', + 'version' => '0.1.3.0', + 'reference' => '4c86c48f18a707e451c4dfffe87f3710b2052be6', + 'type' => 'library', + 'install_path' => __DIR__ . '/../symfonycasts/dynamic-forms', + 'aliases' => array(), + 'dev_requirement' => false, + ), 'tecnickcom/tcpdf' => array( 'pretty_version' => '6.10.0', 'version' => '6.10.0.0', diff --git a/lib/symfony/form/AbstractExtension.php b/lib/symfony/form/AbstractExtension.php new file mode 100644 index 000000000..cffca7d39 --- /dev/null +++ b/lib/symfony/form/AbstractExtension.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * @author Bernhard Schussek + */ +abstract class AbstractExtension implements FormExtensionInterface +{ + /** + * The types provided by this extension. + * + * @var FormTypeInterface[] + */ + private array $types; + + /** + * The type extensions provided by this extension. + * + * @var FormTypeExtensionInterface[][] + */ + private array $typeExtensions; + + /** + * The type guesser provided by this extension. + */ + private ?FormTypeGuesserInterface $typeGuesser = null; + + /** + * Whether the type guesser has been loaded. + */ + private bool $typeGuesserLoaded = false; + + public function getType(string $name): FormTypeInterface + { + if (!isset($this->types)) { + $this->initTypes(); + } + + if (!isset($this->types[$name])) { + throw new InvalidArgumentException(sprintf('The type "%s" cannot be loaded by this extension.', $name)); + } + + return $this->types[$name]; + } + + public function hasType(string $name): bool + { + if (!isset($this->types)) { + $this->initTypes(); + } + + return isset($this->types[$name]); + } + + public function getTypeExtensions(string $name): array + { + if (!isset($this->typeExtensions)) { + $this->initTypeExtensions(); + } + + return $this->typeExtensions[$name] + ?? []; + } + + public function hasTypeExtensions(string $name): bool + { + if (!isset($this->typeExtensions)) { + $this->initTypeExtensions(); + } + + return isset($this->typeExtensions[$name]) && \count($this->typeExtensions[$name]) > 0; + } + + public function getTypeGuesser(): ?FormTypeGuesserInterface + { + if (!$this->typeGuesserLoaded) { + $this->initTypeGuesser(); + } + + return $this->typeGuesser; + } + + /** + * Registers the types. + * + * @return FormTypeInterface[] + */ + protected function loadTypes() + { + return []; + } + + /** + * Registers the type extensions. + * + * @return FormTypeExtensionInterface[] + */ + protected function loadTypeExtensions(): array + { + return []; + } + + /** + * Registers the type guesser. + * + * @return FormTypeGuesserInterface|null + */ + protected function loadTypeGuesser() + { + return null; + } + + /** + * Initializes the types. + * + * @throws UnexpectedTypeException if any registered type is not an instance of FormTypeInterface + */ + private function initTypes(): void + { + $this->types = []; + + foreach ($this->loadTypes() as $type) { + if (!$type instanceof FormTypeInterface) { + throw new UnexpectedTypeException($type, FormTypeInterface::class); + } + + $this->types[$type::class] = $type; + } + } + + /** + * Initializes the type extensions. + * + * @throws UnexpectedTypeException if any registered type extension is not + * an instance of FormTypeExtensionInterface + */ + private function initTypeExtensions(): void + { + $this->typeExtensions = []; + + foreach ($this->loadTypeExtensions() as $extension) { + if (!$extension instanceof FormTypeExtensionInterface) { + throw new UnexpectedTypeException($extension, FormTypeExtensionInterface::class); + } + + foreach ($extension::getExtendedTypes() as $extendedType) { + $this->typeExtensions[$extendedType][] = $extension; + } + } + } + + /** + * Initializes the type guesser. + * + * @throws UnexpectedTypeException if the type guesser is not an instance of FormTypeGuesserInterface + */ + private function initTypeGuesser(): void + { + $this->typeGuesserLoaded = true; + + $this->typeGuesser = $this->loadTypeGuesser(); + if (null !== $this->typeGuesser && !$this->typeGuesser instanceof FormTypeGuesserInterface) { + throw new UnexpectedTypeException($this->typeGuesser, FormTypeGuesserInterface::class); + } + } +} diff --git a/lib/symfony/form/AbstractRendererEngine.php b/lib/symfony/form/AbstractRendererEngine.php new file mode 100644 index 000000000..3f1ab79c2 --- /dev/null +++ b/lib/symfony/form/AbstractRendererEngine.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Contracts\Service\ResetInterface; + +/** + * Default implementation of {@link FormRendererEngineInterface}. + * + * @author Bernhard Schussek + */ +abstract class AbstractRendererEngine implements FormRendererEngineInterface, ResetInterface +{ + /** + * The variable in {@link FormView} used as cache key. + */ + public const CACHE_KEY_VAR = 'cache_key'; + + /** + * @var array + */ + protected $defaultThemes; + + /** + * @var array[] + */ + protected $themes = []; + + /** + * @var bool[] + */ + protected $useDefaultThemes = []; + + /** + * @var array[] + */ + protected $resources = []; + + /** + * @var array> + */ + private array $resourceHierarchyLevels = []; + + /** + * Creates a new renderer engine. + * + * @param array $defaultThemes The default themes. The type of these + * themes is open to the implementation. + */ + public function __construct(array $defaultThemes = []) + { + $this->defaultThemes = $defaultThemes; + } + + /** + * @return void + */ + public function setTheme(FormView $view, mixed $themes, bool $useDefaultThemes = true) + { + $cacheKey = $view->vars[self::CACHE_KEY_VAR]; + + // Do not cast, as casting turns objects into arrays of properties + $this->themes[$cacheKey] = \is_array($themes) ? $themes : [$themes]; + $this->useDefaultThemes[$cacheKey] = $useDefaultThemes; + + // Unset instead of resetting to an empty array, in order to allow + // implementations (like TwigRendererEngine) to check whether $cacheKey + // is set at all. + unset($this->resources[$cacheKey], $this->resourceHierarchyLevels[$cacheKey]); + } + + public function getResourceForBlockName(FormView $view, string $blockName): mixed + { + $cacheKey = $view->vars[self::CACHE_KEY_VAR]; + + if (!isset($this->resources[$cacheKey][$blockName])) { + $this->loadResourceForBlockName($cacheKey, $view, $blockName); + } + + return $this->resources[$cacheKey][$blockName]; + } + + public function getResourceForBlockNameHierarchy(FormView $view, array $blockNameHierarchy, int $hierarchyLevel): mixed + { + $cacheKey = $view->vars[self::CACHE_KEY_VAR]; + $blockName = $blockNameHierarchy[$hierarchyLevel]; + + if (!isset($this->resources[$cacheKey][$blockName])) { + $this->loadResourceForBlockNameHierarchy($cacheKey, $view, $blockNameHierarchy, $hierarchyLevel); + } + + return $this->resources[$cacheKey][$blockName]; + } + + public function getResourceHierarchyLevel(FormView $view, array $blockNameHierarchy, int $hierarchyLevel): int|false + { + $cacheKey = $view->vars[self::CACHE_KEY_VAR]; + $blockName = $blockNameHierarchy[$hierarchyLevel]; + + if (!isset($this->resources[$cacheKey][$blockName])) { + $this->loadResourceForBlockNameHierarchy($cacheKey, $view, $blockNameHierarchy, $hierarchyLevel); + } + + // If $block was previously rendered loaded with loadTemplateForBlock(), the template + // is cached but the hierarchy level is not. In this case, we know that the block + // exists at this very hierarchy level, so we can just set it. + if (!isset($this->resourceHierarchyLevels[$cacheKey][$blockName])) { + $this->resourceHierarchyLevels[$cacheKey][$blockName] = $hierarchyLevel; + } + + return $this->resourceHierarchyLevels[$cacheKey][$blockName]; + } + + /** + * Loads the cache with the resource for a given block name. + * + * @see getResourceForBlock() + * + * @return bool + */ + abstract protected function loadResourceForBlockName(string $cacheKey, FormView $view, string $blockName); + + /** + * Loads the cache with the resource for a specific level of a block hierarchy. + * + * @see getResourceForBlockHierarchy() + */ + private function loadResourceForBlockNameHierarchy(string $cacheKey, FormView $view, array $blockNameHierarchy, int $hierarchyLevel): bool + { + $blockName = $blockNameHierarchy[$hierarchyLevel]; + + // Try to find a template for that block + if ($this->loadResourceForBlockName($cacheKey, $view, $blockName)) { + // If loadTemplateForBlock() returns true, it was able to populate the + // cache. The only missing thing is to set the hierarchy level at which + // the template was found. + $this->resourceHierarchyLevels[$cacheKey][$blockName] = $hierarchyLevel; + + return true; + } + + if ($hierarchyLevel > 0) { + $parentLevel = $hierarchyLevel - 1; + $parentBlockName = $blockNameHierarchy[$parentLevel]; + + // The next two if statements contain slightly duplicated code. This is by intention + // and tries to avoid execution of unnecessary checks in order to increase performance. + + if (isset($this->resources[$cacheKey][$parentBlockName])) { + // It may happen that the parent block is already loaded, but its level is not. + // In this case, the parent block must have been loaded by loadResourceForBlock(), + // which does not check the hierarchy of the block. Subsequently the block must have + // been found directly on the parent level. + if (!isset($this->resourceHierarchyLevels[$cacheKey][$parentBlockName])) { + $this->resourceHierarchyLevels[$cacheKey][$parentBlockName] = $parentLevel; + } + + // Cache the shortcuts for further accesses + $this->resources[$cacheKey][$blockName] = $this->resources[$cacheKey][$parentBlockName]; + $this->resourceHierarchyLevels[$cacheKey][$blockName] = $this->resourceHierarchyLevels[$cacheKey][$parentBlockName]; + + return true; + } + + if ($this->loadResourceForBlockNameHierarchy($cacheKey, $view, $blockNameHierarchy, $parentLevel)) { + // Cache the shortcuts for further accesses + $this->resources[$cacheKey][$blockName] = $this->resources[$cacheKey][$parentBlockName]; + $this->resourceHierarchyLevels[$cacheKey][$blockName] = $this->resourceHierarchyLevels[$cacheKey][$parentBlockName]; + + return true; + } + } + + // Cache the result for further accesses + $this->resources[$cacheKey][$blockName] = false; + $this->resourceHierarchyLevels[$cacheKey][$blockName] = false; + + return false; + } + + public function reset(): void + { + $this->themes = []; + $this->useDefaultThemes = []; + $this->resources = []; + $this->resourceHierarchyLevels = []; + } +} diff --git a/lib/symfony/form/AbstractType.php b/lib/symfony/form/AbstractType.php new file mode 100644 index 000000000..8fffa379d --- /dev/null +++ b/lib/symfony/form/AbstractType.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Util\StringUtil; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Bernhard Schussek + */ +abstract class AbstractType implements FormTypeInterface +{ + /** + * @return string|null + */ + public function getParent() + { + return FormType::class; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + } + + /** + * @return string + */ + public function getBlockPrefix() + { + return StringUtil::fqcnToBlockPrefix(static::class) ?: ''; + } +} diff --git a/lib/symfony/form/AbstractTypeExtension.php b/lib/symfony/form/AbstractTypeExtension.php new file mode 100644 index 000000000..1956bd00a --- /dev/null +++ b/lib/symfony/form/AbstractTypeExtension.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Bernhard Schussek + */ +abstract class AbstractTypeExtension implements FormTypeExtensionInterface +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + } +} diff --git a/lib/symfony/form/Button.php b/lib/symfony/form/Button.php new file mode 100644 index 000000000..42e9e0318 --- /dev/null +++ b/lib/symfony/form/Button.php @@ -0,0 +1,373 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\AlreadySubmittedException; +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * A form button. + * + * @author Bernhard Schussek + * + * @implements \IteratorAggregate + */ +class Button implements \IteratorAggregate, FormInterface +{ + private ?FormInterface $parent = null; + private FormConfigInterface $config; + private bool $submitted = false; + + /** + * Creates a new button from a form configuration. + */ + public function __construct(FormConfigInterface $config) + { + $this->config = $config; + } + + /** + * Unsupported method. + */ + public function offsetExists(mixed $offset): bool + { + return false; + } + + /** + * Unsupported method. + * + * This method should not be invoked. + * + * @throws BadMethodCallException + */ + public function offsetGet(mixed $offset): FormInterface + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + /** + * Unsupported method. + * + * This method should not be invoked. + * + * @throws BadMethodCallException + */ + public function offsetSet(mixed $offset, mixed $value): void + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + /** + * Unsupported method. + * + * This method should not be invoked. + * + * @throws BadMethodCallException + */ + public function offsetUnset(mixed $offset): void + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + public function setParent(?FormInterface $parent = null): static + { + if (1 > \func_num_args()) { + trigger_deprecation('symfony/form', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); + } + if ($this->submitted) { + throw new AlreadySubmittedException('You cannot set the parent of a submitted button.'); + } + + $this->parent = $parent; + + return $this; + } + + public function getParent(): ?FormInterface + { + return $this->parent; + } + + /** + * Unsupported method. + * + * This method should not be invoked. + * + * @throws BadMethodCallException + */ + public function add(string|FormInterface $child, ?string $type = null, array $options = []): static + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + /** + * Unsupported method. + * + * This method should not be invoked. + * + * @throws BadMethodCallException + */ + public function get(string $name): FormInterface + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + /** + * Unsupported method. + */ + public function has(string $name): bool + { + return false; + } + + /** + * Unsupported method. + * + * This method should not be invoked. + * + * @throws BadMethodCallException + */ + public function remove(string $name): static + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + public function all(): array + { + return []; + } + + public function getErrors(bool $deep = false, bool $flatten = true): FormErrorIterator + { + return new FormErrorIterator($this, []); + } + + /** + * Unsupported method. + * + * This method should not be invoked. + * + * @return $this + */ + public function setData(mixed $modelData): static + { + // no-op, called during initialization of the form tree + return $this; + } + + /** + * Unsupported method. + */ + public function getData(): mixed + { + return null; + } + + /** + * Unsupported method. + */ + public function getNormData(): mixed + { + return null; + } + + /** + * Unsupported method. + */ + public function getViewData(): mixed + { + return null; + } + + /** + * Unsupported method. + */ + public function getExtraData(): array + { + return []; + } + + /** + * Returns the button's configuration. + */ + public function getConfig(): FormConfigInterface + { + return $this->config; + } + + /** + * Returns whether the button is submitted. + */ + public function isSubmitted(): bool + { + return $this->submitted; + } + + /** + * Returns the name by which the button is identified in forms. + */ + public function getName(): string + { + return $this->config->getName(); + } + + /** + * Unsupported method. + */ + public function getPropertyPath(): ?PropertyPathInterface + { + return null; + } + + /** + * Unsupported method. + * + * @throws BadMethodCallException + */ + public function addError(FormError $error): static + { + throw new BadMethodCallException('Buttons cannot have errors.'); + } + + /** + * Unsupported method. + */ + public function isValid(): bool + { + return true; + } + + /** + * Unsupported method. + */ + public function isRequired(): bool + { + return false; + } + + public function isDisabled(): bool + { + if ($this->parent?->isDisabled()) { + return true; + } + + return $this->config->getDisabled(); + } + + /** + * Unsupported method. + */ + public function isEmpty(): bool + { + return true; + } + + /** + * Unsupported method. + */ + public function isSynchronized(): bool + { + return true; + } + + /** + * Unsupported method. + */ + public function getTransformationFailure(): ?TransformationFailedException + { + return null; + } + + /** + * Unsupported method. + * + * @throws BadMethodCallException + */ + public function initialize(): static + { + throw new BadMethodCallException('Buttons cannot be initialized. Call initialize() on the root form instead.'); + } + + /** + * Unsupported method. + * + * @throws BadMethodCallException + */ + public function handleRequest(mixed $request = null): static + { + throw new BadMethodCallException('Buttons cannot handle requests. Call handleRequest() on the root form instead.'); + } + + /** + * Submits data to the button. + * + * @return $this + * + * @throws Exception\AlreadySubmittedException if the button has already been submitted + */ + public function submit(array|string|null $submittedData, bool $clearMissing = true): static + { + if ($this->submitted) { + throw new AlreadySubmittedException('A form can only be submitted once.'); + } + + $this->submitted = true; + + return $this; + } + + public function getRoot(): FormInterface + { + return $this->parent ? $this->parent->getRoot() : $this; + } + + public function isRoot(): bool + { + return null === $this->parent; + } + + public function createView(?FormView $parent = null): FormView + { + if (null === $parent && $this->parent) { + $parent = $this->parent->createView(); + } + + $type = $this->config->getType(); + $options = $this->config->getOptions(); + + $view = $type->createView($this, $parent); + + $type->buildView($view, $this, $options); + $type->finishView($view, $this, $options); + + return $view; + } + + /** + * Unsupported method. + */ + public function count(): int + { + return 0; + } + + /** + * Unsupported method. + */ + public function getIterator(): \EmptyIterator + { + return new \EmptyIterator(); + } +} diff --git a/lib/symfony/form/ButtonBuilder.php b/lib/symfony/form/ButtonBuilder.php new file mode 100644 index 000000000..626920ee5 --- /dev/null +++ b/lib/symfony/form/ButtonBuilder.php @@ -0,0 +1,738 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * A builder for {@link Button} instances. + * + * @author Bernhard Schussek + * + * @implements \IteratorAggregate + */ +class ButtonBuilder implements \IteratorAggregate, FormBuilderInterface +{ + protected $locked = false; + + private bool $disabled = false; + private ResolvedFormTypeInterface $type; + private string $name; + private array $attributes = []; + private array $options; + + /** + * @throws InvalidArgumentException if the name is empty + */ + public function __construct(?string $name, array $options = []) + { + if ('' === $name || null === $name) { + throw new InvalidArgumentException('Buttons cannot have empty names.'); + } + + $this->name = $name; + $this->options = $options; + + FormConfigBuilder::validateName($name); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function add(string|FormBuilderInterface $child, ?string $type = null, array $options = []): static + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function create(string $name, ?string $type = null, array $options = []): FormBuilderInterface + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function get(string $name): FormBuilderInterface + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function remove(string $name): static + { + throw new BadMethodCallException('Buttons cannot have children.'); + } + + /** + * Unsupported method. + */ + public function has(string $name): bool + { + return false; + } + + /** + * Returns the children. + */ + public function all(): array + { + return []; + } + + /** + * Creates the button. + */ + public function getForm(): Button + { + return new Button($this->getFormConfig()); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function addEventListener(string $eventName, callable $listener, int $priority = 0): static + { + throw new BadMethodCallException('Buttons do not support event listeners.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function addEventSubscriber(EventSubscriberInterface $subscriber): static + { + throw new BadMethodCallException('Buttons do not support event subscribers.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function addViewTransformer(DataTransformerInterface $viewTransformer, bool $forcePrepend = false): static + { + throw new BadMethodCallException('Buttons do not support data transformers.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function resetViewTransformers(): static + { + throw new BadMethodCallException('Buttons do not support data transformers.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function addModelTransformer(DataTransformerInterface $modelTransformer, bool $forceAppend = false): static + { + throw new BadMethodCallException('Buttons do not support data transformers.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function resetModelTransformers(): static + { + throw new BadMethodCallException('Buttons do not support data transformers.'); + } + + /** + * @return $this + */ + public function setAttribute(string $name, mixed $value): static + { + $this->attributes[$name] = $value; + + return $this; + } + + /** + * @return $this + */ + public function setAttributes(array $attributes): static + { + $this->attributes = $attributes; + + return $this; + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setDataMapper(?DataMapperInterface $dataMapper = null): static + { + if (1 > \func_num_args()) { + trigger_deprecation('symfony/form', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); + } + + throw new BadMethodCallException('Buttons do not support data mappers.'); + } + + /** + * Set whether the button is disabled. + * + * @return $this + */ + public function setDisabled(bool $disabled): static + { + $this->disabled = $disabled; + + return $this; + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setEmptyData(mixed $emptyData): static + { + throw new BadMethodCallException('Buttons do not support empty data.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setErrorBubbling(bool $errorBubbling): static + { + throw new BadMethodCallException('Buttons do not support error bubbling.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setRequired(bool $required): static + { + throw new BadMethodCallException('Buttons cannot be required.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setPropertyPath(string|PropertyPathInterface|null $propertyPath): static + { + throw new BadMethodCallException('Buttons do not support property paths.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setMapped(bool $mapped): static + { + throw new BadMethodCallException('Buttons do not support data mapping.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setByReference(bool $byReference): static + { + throw new BadMethodCallException('Buttons do not support data mapping.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setCompound(bool $compound): static + { + throw new BadMethodCallException('Buttons cannot be compound.'); + } + + /** + * Sets the type of the button. + * + * @return $this + */ + public function setType(ResolvedFormTypeInterface $type): static + { + $this->type = $type; + + return $this; + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setData(mixed $data): static + { + throw new BadMethodCallException('Buttons do not support data.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setDataLocked(bool $locked): static + { + throw new BadMethodCallException('Buttons do not support data locking.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setFormFactory(FormFactoryInterface $formFactory) + { + throw new BadMethodCallException('Buttons do not support form factories.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setAction(string $action): static + { + throw new BadMethodCallException('Buttons do not support actions.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setMethod(string $method): static + { + throw new BadMethodCallException('Buttons do not support methods.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setRequestHandler(RequestHandlerInterface $requestHandler): static + { + throw new BadMethodCallException('Buttons do not support request handlers.'); + } + + /** + * Unsupported method. + * + * @return $this + * + * @throws BadMethodCallException + */ + public function setAutoInitialize(bool $initialize): static + { + if (true === $initialize) { + throw new BadMethodCallException('Buttons do not support automatic initialization.'); + } + + return $this; + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setInheritData(bool $inheritData): static + { + throw new BadMethodCallException('Buttons do not support data inheritance.'); + } + + /** + * Builds and returns the button configuration. + */ + public function getFormConfig(): FormConfigInterface + { + // This method should be idempotent, so clone the builder + $config = clone $this; + $config->locked = true; + + return $config; + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function setIsEmptyCallback(?callable $isEmptyCallback): static + { + throw new BadMethodCallException('Buttons do not support "is empty" callback.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function getEventDispatcher(): EventDispatcherInterface + { + throw new BadMethodCallException('Buttons do not support event dispatching.'); + } + + public function getName(): string + { + return $this->name; + } + + /** + * Unsupported method. + */ + public function getPropertyPath(): ?PropertyPathInterface + { + return null; + } + + /** + * Unsupported method. + */ + public function getMapped(): bool + { + return false; + } + + /** + * Unsupported method. + */ + public function getByReference(): bool + { + return false; + } + + /** + * Unsupported method. + */ + public function getCompound(): bool + { + return false; + } + + /** + * Returns the form type used to construct the button. + */ + public function getType(): ResolvedFormTypeInterface + { + return $this->type; + } + + /** + * Unsupported method. + */ + public function getViewTransformers(): array + { + return []; + } + + /** + * Unsupported method. + */ + public function getModelTransformers(): array + { + return []; + } + + /** + * Unsupported method. + */ + public function getDataMapper(): ?DataMapperInterface + { + return null; + } + + /** + * Unsupported method. + */ + public function getRequired(): bool + { + return false; + } + + /** + * Returns whether the button is disabled. + */ + public function getDisabled(): bool + { + return $this->disabled; + } + + /** + * Unsupported method. + */ + public function getErrorBubbling(): bool + { + return false; + } + + /** + * Unsupported method. + */ + public function getEmptyData(): mixed + { + return null; + } + + /** + * Returns additional attributes of the button. + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Returns whether the attribute with the given name exists. + */ + public function hasAttribute(string $name): bool + { + return \array_key_exists($name, $this->attributes); + } + + /** + * Returns the value of the given attribute. + */ + public function getAttribute(string $name, mixed $default = null): mixed + { + return \array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default; + } + + /** + * Unsupported method. + */ + public function getData(): mixed + { + return null; + } + + /** + * Unsupported method. + */ + public function getDataClass(): ?string + { + return null; + } + + /** + * Unsupported method. + */ + public function getDataLocked(): bool + { + return false; + } + + /** + * Unsupported method. + * + * @return never + */ + public function getFormFactory(): FormFactoryInterface + { + throw new BadMethodCallException('Buttons do not support adding children.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function getAction(): string + { + throw new BadMethodCallException('Buttons do not support actions.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function getMethod(): string + { + throw new BadMethodCallException('Buttons do not support methods.'); + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function getRequestHandler(): RequestHandlerInterface + { + throw new BadMethodCallException('Buttons do not support request handlers.'); + } + + /** + * Unsupported method. + */ + public function getAutoInitialize(): bool + { + return false; + } + + /** + * Unsupported method. + */ + public function getInheritData(): bool + { + return false; + } + + /** + * Returns all options passed during the construction of the button. + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Returns whether a specific option exists. + */ + public function hasOption(string $name): bool + { + return \array_key_exists($name, $this->options); + } + + /** + * Returns the value of a specific option. + */ + public function getOption(string $name, mixed $default = null): mixed + { + return \array_key_exists($name, $this->options) ? $this->options[$name] : $default; + } + + /** + * Unsupported method. + * + * @return never + * + * @throws BadMethodCallException + */ + public function getIsEmptyCallback(): ?callable + { + throw new BadMethodCallException('Buttons do not support "is empty" callback.'); + } + + /** + * Unsupported method. + */ + public function count(): int + { + return 0; + } + + /** + * Unsupported method. + */ + public function getIterator(): \EmptyIterator + { + return new \EmptyIterator(); + } +} diff --git a/lib/symfony/form/ButtonTypeInterface.php b/lib/symfony/form/ButtonTypeInterface.php new file mode 100644 index 000000000..dd5117c4d --- /dev/null +++ b/lib/symfony/form/ButtonTypeInterface.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; + +/** + * A type that should be converted into a {@link Button} instance. + * + * @author Bernhard Schussek + */ +interface ButtonTypeInterface extends FormTypeInterface +{ +} diff --git a/lib/symfony/form/CHANGELOG.md b/lib/symfony/form/CHANGELOG.md new file mode 100644 index 000000000..9fba1a3f5 --- /dev/null +++ b/lib/symfony/form/CHANGELOG.md @@ -0,0 +1,616 @@ +CHANGELOG +========= + +6.4 +--- + + * Deprecate using `DateTime` or `DateTimeImmutable` model data with a different timezone than configured with the + `model_timezone` option in `DateType`, `DateTimeType`, and `TimeType` + * Deprecate `PostSetDataEvent::setData()`, use `PreSetDataEvent::setData()` instead + * Deprecate `PostSubmitEvent::setData()`, use `PreSubmitDataEvent::setData()` or `SubmitDataEvent::setData()` instead + * Add `duplicate_preferred_choices` option in `ChoiceType` + * Add `$duplicatePreferredChoices` parameter to `ChoiceListFactoryInterface::createView()` + +6.3 +--- + + * Don't render seconds for HTML5 date pickers unless "with_seconds" is explicitly set + * Add a `placeholder_attr` option to `ChoiceType` + * Deprecate not configuring the "widget" option of date/time form types, it will default to "single_text" in v7 + +6.2 +--- + + * Allow passing `TranslatableInterface` objects to the `ChoiceView` label + * Allow passing `TranslatableInterface` objects to the `help` option + * Deprecate calling `Button/Form::setParent()`, `ButtonBuilder/FormConfigBuilder::setDataMapper()`, `TransformationFailedException::setInvalidMessage()` without arguments + * Change the signature of `FormConfigBuilderInterface::setDataMapper()` to `setDataMapper(?DataMapperInterface)` + * Change the signature of `FormInterface::setParent()` to `setParent(?self)` + * Add `PasswordHasherExtension` with support for `hash_property_path` option in `PasswordType` + +6.1 +--- + + * Add a `prototype_options` option to `CollectionType` + +6.0 +--- + + * Remove `PropertyPathMaper` + * Remove `Symfony\Component\Form\Extension\Validator\Util\ServerParams` + * Remove `FormPass` configuration + * Remove the `NumberToLocalizedStringTransformer::ROUND_*` constants, use `\NumberFormatter::ROUND_*` instead + * The `rounding_mode` option of the `PercentType` defaults to `\NumberFormatter::ROUND_HALFUP` + * The rounding mode argument of the constructor of `PercentToLocalizedStringTransformer` defaults to `\NumberFormatter::ROUND_HALFUP` + * Add `FormConfigInterface::getIsEmptyCallback()` and `FormConfigBuilderInterface::setIsEmptyCallback()` + * Change `$forms` parameter type of the `DataMapper::mapDataToForms()` method from `iterable` to `\Traversable` + * Change `$forms` parameter type of the `DataMapper::mapFormsToData()` method from `iterable` to `\Traversable` + * Change `$checkboxes` parameter type of the `CheckboxListMapper::mapDataToForms()` method from `iterable` to `\Traversable` + * Change `$checkboxes` parameter type of the `CheckboxListMapper::mapFormsToData()` method from `iterable` to `\Traversable` + * Change `$radios` parameter type of the `RadioListMapper::mapDataToForms()` method from `iterable` to `\Traversable` + * Change `$radios` parameter type of the `RadioListMapper::mapFormsToData()` method from `iterable` to `\Traversable` + +5.4 +--- + + * Deprecate calling `FormErrorIterator::children()` if the current element is not iterable. + * Allow to pass `TranslatableMessage` objects to the `help` option + * Add the `EnumType` + +5.3 +--- + + * Changed `$forms` parameter type of the `DataMapperInterface::mapDataToForms()` method from `iterable` to `\Traversable`. + * Changed `$forms` parameter type of the `DataMapperInterface::mapFormsToData()` method from `iterable` to `\Traversable`. + * Deprecated passing an array as the second argument of the `DataMapper::mapDataToForms()` method, pass `\Traversable` instead. + * Deprecated passing an array as the first argument of the `DataMapper::mapFormsToData()` method, pass `\Traversable` instead. + * Deprecated passing an array as the second argument of the `CheckboxListMapper::mapDataToForms()` method, pass `\Traversable` instead. + * Deprecated passing an array as the first argument of the `CheckboxListMapper::mapFormsToData()` method, pass `\Traversable` instead. + * Deprecated passing an array as the second argument of the `RadioListMapper::mapDataToForms()` method, pass `\Traversable` instead. + * Deprecated passing an array as the first argument of the `RadioListMapper::mapFormsToData()` method, pass `\Traversable` instead. + * Added a `choice_translation_parameters` option to `ChoiceType` + * Add `UuidType` and `UlidType` + * Dependency on `symfony/intl` was removed. Install `symfony/intl` if you are using `LocaleType`, `CountryType`, `CurrencyType`, `LanguageType` or `TimezoneType`. + * Add `priority` option to `BaseType` and sorting view fields + +5.2.0 +----- + + * Added support for using the `{{ label }}` placeholder in constraint messages, which is replaced in the `ViolationMapper` by the corresponding field form label. + * Added `DataMapper`, `ChainAccessor`, `PropertyPathAccessor` and `CallbackAccessor` with new callable `getter` and `setter` options for each form type + * Deprecated `PropertyPathMapper` in favor of `DataMapper` and `PropertyPathAccessor` + * Added an `html5` option to `MoneyType` and `PercentType`, to use `` + +5.1.0 +----- + + * Deprecated not configuring the `rounding_mode` option of the `PercentType`. It will default to `\NumberFormatter::ROUND_HALFUP` in Symfony 6. + * Deprecated not passing a rounding mode to the constructor of `PercentToLocalizedStringTransformer`. It will default to `\NumberFormatter::ROUND_HALFUP` in Symfony 6. + * Added `collection_entry` block prefix to `CollectionType` entries + * Added a `choice_filter` option to `ChoiceType` + * Added argument `callable|null $filter` to `ChoiceListFactoryInterface::createListFromChoices()` and `createListFromLoader()` - not defining them is deprecated. + * Added a `ChoiceList` facade to leverage explicit choice list caching based on options + * Added an `AbstractChoiceLoader` to simplify implementations and handle global optimizations + * The `view_timezone` option defaults to the `model_timezone` if no `reference_date` is configured. + * Implementing the `FormConfigInterface` without implementing the `getIsEmptyCallback()` method + is deprecated. The method will be added to the interface in 6.0. + * Implementing the `FormConfigBuilderInterface` without implementing the `setIsEmptyCallback()` method + is deprecated. The method will be added to the interface in 6.0. + * Added a `rounding_mode` option for the PercentType and correctly round the value when submitted + * Deprecated `Symfony\Component\Form\Extension\Validator\Util\ServerParams` in favor of its parent class `Symfony\Component\Form\Util\ServerParams` + * Added the `html5` option to the `ColorType` to validate the input + * Deprecated `NumberToLocalizedStringTransformer::ROUND_*` constants, use `\NumberFormatter::ROUND_*` instead + +5.0.0 +----- + + * Removed support for using different values for the "model_timezone" and "view_timezone" options of the `TimeType` + without configuring a reference date. + * Removed the `scale` option of the `IntegerType`. + * Using the `date_format`, `date_widget`, and `time_widget` options of the `DateTimeType` when the `widget` option is + set to `single_text` is not supported anymore. + * The `format` option of `DateType` and `DateTimeType` cannot be used when the `html5` option is enabled. + * Using names for buttons that do not start with a letter, a digit, or an underscore throw an exception + * Using names for buttons that do not contain only letters, digits, underscores, hyphens, and colons throw an exception. + * removed the `ChoiceLoaderInterface` implementation in `CountryType`, `LanguageType`, `LocaleType` and `CurrencyType` + * removed `getExtendedType()` method of the `FormTypeExtensionInterface` + * added static `getExtendedTypes()` method to the `FormTypeExtensionInterface` + * calling to `FormRenderer::searchAndRenderBlock()` method for fields which were already rendered throw a `BadMethodCallException` + * removed the `regions` option of the `TimezoneType` + * removed the `$scale` argument of the `IntegerToLocalizedStringTransformer` + * removed `TemplatingExtension` and `TemplatingRendererEngine` classes, use Twig instead + * passing a null message when instantiating a `Symfony\Component\Form\FormError` is not allowed + * removed support for using `int` or `float` as data for the `NumberType` when the `input` option is set to `string` + +4.4.0 +----- + + * add new `WeekType` + * using different values for the "model_timezone" and "view_timezone" options of the `TimeType` without configuring a + reference date is deprecated + * preferred choices are repeated in the list of all choices + * deprecated using `int` or `float` as data for the `NumberType` when the `input` option is set to `string` + * The type guesser guesses the HTML accept attribute when a mime type is configured in the File or Image constraint. + * Overriding the methods `FormIntegrationTestCase::setUp()`, `TypeTestCase::setUp()` and `TypeTestCase::tearDown()` without the `void` return-type is deprecated. + * marked all dispatched event classes as `@final` + * Added the `validate` option to `SubmitType` to toggle the browser built-in form validation. + * Added the `alpha3` option to `LanguageType` and `CountryType` to use alpha3 instead of alpha2 codes + +4.3.0 +----- + + * added a `symbol` option to the `PercentType` that allows to disable or customize the output of the percent character + * Using the `format` option of `DateType` and `DateTimeType` when the `html5` option is enabled is deprecated. + * Using names for buttons that do not start with a letter, a digit, or an underscore is deprecated and will lead to an + exception in 5.0. + * Using names for buttons that do not contain only letters, digits, underscores, hyphens, and colons is deprecated and + will lead to an exception in 5.0. + * added `html5` option to `NumberType` that allows to render `type="number"` input fields + * deprecated using the `date_format`, `date_widget`, and `time_widget` options of the `DateTimeType` when the `widget` + option is set to `single_text` + * added `block_prefix` option to `BaseType`. + * added `help_html` option to display the `help` text as HTML. + * `FormError` doesn't implement `Serializable` anymore + * `FormDataCollector` has been marked as `final` + * added `label_translation_parameters`, `attr_translation_parameters`, `help_translation_parameters` options + to `FormType` to pass translation parameters to form labels, attributes (`placeholder` and `title`) and help text respectively. + The passed parameters will replace placeholders in translation messages. + + ```php + class OrderType extends AbstractType + { + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add('comment', TextType::class, [ + 'label' => 'Comment to the order to %company%', + 'label_translation_parameters' => [ + '%company%' => 'Acme', + ], + 'help' => 'The address of the %company% is %address%', + 'help_translation_parameters' => [ + '%company%' => 'Acme Ltd.', + '%address%' => '4 Form street, Symfonyville', + ], + ]) + } + } + ``` + * added the `input_format` option to `DateType`, `DateTimeType`, and `TimeType` to specify the input format when setting + the `input` option to `string` + * dispatch `PreSubmitEvent` on `form.pre_submit` + * dispatch `SubmitEvent` on `form.submit` + * dispatch `PostSubmitEvent` on `form.post_submit` + * dispatch `PreSetDataEvent` on `form.pre_set_data` + * dispatch `PostSetDataEvent` on `form.post_set_data` + * added an `input` option to `NumberType` + * removed default option grouping in `TimezoneType`, use `group_by` instead + +4.2.0 +----- + + * The `getExtendedType()` method of the `FormTypeExtensionInterface` is deprecated and will be removed in 5.0. Type + extensions must implement the static `getExtendedTypes()` method instead and return an iterable of extended types. + + Before: + + ```php + class FooTypeExtension extends AbstractTypeExtension + { + public function getExtendedType() + { + return FormType::class; + } + + // ... + } + ``` + + After: + + ```php + class FooTypeExtension extends AbstractTypeExtension + { + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } + + // ... + } + ``` + * deprecated the `$scale` argument of the `IntegerToLocalizedStringTransformer` + * added `Symfony\Component\Form\ClearableErrorsInterface` + * deprecated calling `FormRenderer::searchAndRenderBlock` for fields which were already rendered + * added a cause when a CSRF error has occurred + * deprecated the `scale` option of the `IntegerType` + * removed restriction on allowed HTTP methods + * deprecated the `regions` option of the `TimezoneType` + +4.1.0 +----- + + * added `input=datetime_immutable` to `DateType`, `TimeType`, `DateTimeType` + * added `rounding_mode` option to `MoneyType` + * added `choice_translation_locale` option to `CountryType`, `LanguageType`, `LocaleType` and `CurrencyType` + * deprecated the `ChoiceLoaderInterface` implementation in `CountryType`, `LanguageType`, `LocaleType` and `CurrencyType` + * added `input=datetime_immutable` to DateType, TimeType, DateTimeType + * added `rounding_mode` option to MoneyType + +4.0.0 +----- + + * using the `choices` option in `CountryType`, `CurrencyType`, `LanguageType`, + `LocaleType`, and `TimezoneType` when the `choice_loader` option is not `null` + is not supported anymore and the configured choices will be ignored + * callable strings that are passed to the options of the `ChoiceType` are + treated as property paths + * the `choices_as_values` option of the `ChoiceType` has been removed + * removed the support for caching loaded choice lists in `LazyChoiceList`, + cache the choice list in the used `ChoiceLoaderInterface` implementation + instead + * removed the support for objects implementing both `\Traversable` and `\ArrayAccess` in `ResizeFormListener::preSubmit()` + * removed the ability to use `FormDataCollector` without the `symfony/var-dumper` component + * removed passing a `ValueExporter` instance to the `FormDataExtractor::__construct()` method + * removed passing guesser services ids as the fourth argument of `DependencyInjectionExtension::__construct()` + * removed the ability to validate an unsubmitted form. + * removed `ChoiceLoaderInterface` implementation in `TimezoneType` + * added the `false_values` option to the `CheckboxType` which allows to configure custom values which will be treated as `false` during submission + +3.4.0 +----- + + * added `DebugCommand` + * deprecated `ChoiceLoaderInterface` implementation in `TimezoneType` + * added options "input" and "regions" to `TimezoneType` + * added an option to ``Symfony\Component\Form\FormRendererEngineInterface::setTheme()`` and + ``Symfony\Component\Form\FormRendererInterface::setTheme()`` to disable usage of default themes when rendering a form + +3.3.0 +----- + + * deprecated using "choices" option in ``CountryType``, ``CurrencyType``, ``LanguageType``, ``LocaleType``, and + ``TimezoneType`` when "choice_loader" is not ``null`` + * added `Symfony\Component\Form\FormErrorIterator::findByCodes()` + * added `getTypedExtensions`, `getTypes`, and `getTypeGuessers` to `Symfony\Component\Form\Test\FormIntegrationTestCase` + * added `FormPass` + +3.2.0 +----- + + * added `CallbackChoiceLoader` + * implemented `ChoiceLoaderInterface` in children of `ChoiceType` + +3.1.0 +----- + + * deprecated the "choices_as_values" option of ChoiceType + * deprecated support for data objects that implements both `Traversable` and + `ArrayAccess` in `ResizeFormListener::preSubmit` method + * Using callable strings as choice options in `ChoiceType` has been deprecated + and will be used as `PropertyPath` instead of callable in Symfony 4.0. + * implemented `DataTransformerInterface` in `TextType` + * deprecated caching loaded choice list in `LazyChoiceList::$loadedList` + +3.0.0 +----- + + * removed `FormTypeInterface::setDefaultOptions()` method + * removed `AbstractType::setDefaultOptions()` method + * removed `FormTypeExtensionInterface::setDefaultOptions()` method + * removed `AbstractTypeExtension::setDefaultOptions()` method + * added `FormTypeInterface::configureOptions()` method + * added `FormTypeExtensionInterface::configureOptions()` method + +2.8.0 +----- + + * added option "choice_translation_domain" to DateType, TimeType and DateTimeType. + * deprecated option "read_only" in favor of "attr['readonly']" + * added the html5 "range" FormType + * deprecated the "cascade_validation" option in favor of setting "constraints" + with the Valid constraint + * moved data trimming logic of TrimListener into StringUtil + * [BC BREAK] When registering a type extension through the DI extension, the tag alias has to match the actual extended type. + +2.7.38 +------ + + * [BC BREAK] the `isFileUpload()` method was added to the `RequestHandlerInterface` + +2.7.0 +----- + + * added option "choice_translation_domain" to ChoiceType. + * deprecated option "precision" in favor of "scale" + * deprecated the overwriting of AbstractType::setDefaultOptions() in favor of overwriting AbstractType::configureOptions(). + * deprecated the overwriting of AbstractTypeExtension::setDefaultOptions() in favor of overwriting AbstractTypeExtension::configureOptions(). + * added new ChoiceList interface and implementations in the Symfony\Component\Form\ChoiceList namespace + * added new ChoiceView in the Symfony\Component\Form\ChoiceList\View namespace + * choice groups are now represented by ChoiceGroupView objects in the view + * deprecated the old ChoiceList interface and implementations + * deprecated the old ChoiceView class + * added CheckboxListMapper and RadioListMapper + * deprecated ChoiceToBooleanArrayTransformer and ChoicesToBooleanArrayTransformer + * deprecated FixCheckboxInputListener and FixRadioInputListener + * deprecated the "choice_list" option of ChoiceType + * added new options to ChoiceType: + * "choices_as_values" + * "choice_loader" + * "choice_label" + * "choice_name" + * "choice_value" + * "choice_attr" + * "group_by" + +2.6.2 +----- + + * Added back the `model_timezone` and `view_timezone` options for `TimeType`, `DateType` + and `BirthdayType` + +2.6.0 +----- + + * added "html5" option to Date, Time and DateTimeFormType to be able to + enable/disable HTML5 input date when widget option is "single_text" + * added "label_format" option with possible placeholders "%name%" and "%id%" + * [BC BREAK] drop support for model_timezone and view_timezone options in TimeType, DateType and BirthdayType, + update to 2.6.2 to get back support for these options + +2.5.0 +------ + + * deprecated options "max_length" and "pattern" in favor of putting these values in "attr" option + * added an option for multiple files upload + * form errors now reference their cause (constraint violation, exception, ...) + * form errors now remember which form they were originally added to + * [BC BREAK] added two optional parameters to FormInterface::getErrors() and + changed the method to return a Symfony\Component\Form\FormErrorIterator + instance instead of an array + * errors mapped to unsubmitted forms are discarded now + * ObjectChoiceList now compares choices by their value, if a value path is + given + * you can now pass interface names in the "data_class" option + * [BC BREAK] added `FormInterface::getTransformationFailure()` + +2.4.0 +----- + + * moved CSRF implementation to the new Security CSRF sub-component + * deprecated CsrfProviderInterface and its implementations + * deprecated options "csrf_provider" and "intention" in favor of the new options "csrf_token_manager" and "csrf_token_id" + +2.3.0 +----- + + * deprecated FormPerformanceTestCase and FormIntegrationTestCase in the Symfony\Component\Form\Tests namespace and moved them to the Symfony\Component\Form\Test namespace + * deprecated TypeTestCase in the Symfony\Component\Form\Tests\Extension\Core\Type namespace and moved it to the Symfony\Component\Form\Test namespace + * changed FormRenderer::humanize() to humanize also camel cased field name + * added RequestHandlerInterface and FormInterface::handleRequest() + * deprecated passing a Request instance to FormInterface::bind() + * added options "method" and "action" to FormType + * deprecated option "virtual" in favor "inherit_data" + * deprecated VirtualFormAwareIterator in favor of InheritDataAwareIterator + * [BC BREAK] removed the "array" type hint from DataMapperInterface + * improved forms inheriting their parent data to actually return that data from getData(), getNormData() and getViewData() + * added component-level exceptions for various SPL exceptions + changed all uses of the deprecated Exception class to use more specialized exceptions instead + removed NotInitializedException, NotValidException, TypeDefinitionException, TypeLoaderException, CreationException + * added events PRE_SUBMIT, SUBMIT and POST_SUBMIT + * deprecated events PRE_BIND, BIND and POST_BIND + * [BC BREAK] renamed bind() and isBound() in FormInterface to submit() and isSubmitted() + * added methods submit() and isSubmitted() to Form + * deprecated bind() and isBound() in Form + * deprecated AlreadyBoundException in favor of AlreadySubmittedException + * added support for PATCH requests + * [BC BREAK] added initialize() to FormInterface + * [BC BREAK] added getAutoInitialize() to FormConfigInterface + * [BC BREAK] added setAutoInitialize() to FormConfigBuilderInterface + * [BC BREAK] initialization for Form instances added to a form tree must be manually disabled + * PRE_SET_DATA is now guaranteed to be called after children were added by the form builder, + unless FormInterface::setData() is called manually + * fixed CSRF error message to be translated + * custom CSRF error messages can now be set through the "csrf_message" option + * fixed: expanded single-choice fields now show a radio button for the empty value + +2.2.0 +----- + + * TrimListener now removes unicode whitespaces + * deprecated getParent(), setParent() and hasParent() in FormBuilderInterface + * FormInterface::add() now accepts a FormInterface instance OR a field's name, type and options + * removed special characters between the choice or text fields of DateType unless + the option "format" is set to a custom value + * deprecated FormException and introduced ExceptionInterface instead + * [BC BREAK] FormException is now an interface + * protected FormBuilder methods from being called when it is turned into a FormConfigInterface with getFormConfig() + * [BC BREAK] inserted argument `$message` in the constructor of `FormError` + * the PropertyPath class and related classes were moved to a dedicated + PropertyAccess component. During the move, InvalidPropertyException was + renamed to NoSuchPropertyException. FormUtil was split: FormUtil::singularify() + can now be found in Symfony\Component\PropertyAccess\StringUtil. The methods + getValue() and setValue() from PropertyPath were extracted into a new class + PropertyAccessor. + * added an optional PropertyAccessorInterface parameter to FormType, + ObjectChoiceList and PropertyPathMapper + * [BC BREAK] PropertyPathMapper and FormType now have a constructor + * [BC BREAK] setting the option "validation_groups" to ``false`` now disables validation + instead of assuming group "Default" + +2.1.0 +----- + + * [BC BREAK] ``read_only`` field attribute now renders as ``readonly="readonly"``, use ``disabled`` instead + * [BC BREAK] child forms now aren't validated anymore by default + * made validation of form children configurable (new option: cascade_validation) + * added support for validation groups as callbacks + * made the translation catalogue configurable via the "translation_domain" option + * added Form::getErrorsAsString() to help debugging forms + * allowed setting different options for RepeatedType fields (like the label) + * added support for empty form name at root level, this enables rendering forms + without form name prefix in field names + * [BC BREAK] form and field names must start with a letter, digit or underscore + and only contain letters, digits, underscores, hyphens and colons + * [BC BREAK] changed default name of the prototype in the "collection" type + from "$$name$$" to "\__name\__". No dollars are appended/prepended to custom + names anymore. + * [BC BREAK] improved ChoiceListInterface + * [BC BREAK] added SimpleChoiceList and LazyChoiceList as replacement of + ArrayChoiceList + * added ChoiceList and ObjectChoiceList to use objects as choices + * [BC BREAK] removed EntitiesToArrayTransformer and EntityToIdTransformer. + The former has been replaced by CollectionToArrayTransformer in combination + with EntityChoiceList, the latter is not required in the core anymore. + * [BC BREAK] renamed + * ArrayToBooleanChoicesTransformer to ChoicesToBooleanArrayTransformer + * ScalarToBooleanChoicesTransformer to ChoiceToBooleanArrayTransformer + * ArrayToChoicesTransformer to ChoicesToValuesTransformer + * ScalarToChoiceTransformer to ChoiceToValueTransformer + to be consistent with the naming in ChoiceListInterface. + They were merged into ChoiceList and have no public equivalent anymore. + * choice fields now throw a FormException if neither the "choices" nor the + "choice_list" option is set + * the radio type is now a child of the checkbox type + * the collection, choice (with multiple selection) and entity (with multiple + selection) types now make use of addXxx() and removeXxx() methods in your + model if you set "by_reference" to false. For a custom, non-recognized + singular form, set the "property_path" option like this: "plural|singular" + * forms now don't create an empty object anymore if they are completely + empty and not required. The empty value for such forms is null. + * added constant Guess::VERY_HIGH_CONFIDENCE + * [BC BREAK] The methods `add`, `remove`, `setParent`, `bind` and `setData` + in class Form now throw an exception if the form is already bound + * fields of constrained classes without a NotBlank or NotNull constraint are + set to not required now, as stated in the docs + * fixed TimeType and DateTimeType to not display seconds when "widget" is + "single_text" unless "with_seconds" is set to true + * checkboxes of in an expanded multiple-choice field don't include the choice + in their name anymore. Their names terminate with "[]" now. + * deprecated FormValidatorInterface and substituted its implementations + by event subscribers + * simplified CSRF protection and removed the csrf type + * deprecated FieldType and merged it into FormType + * added new option "compound" that lets you switch between field and form behavior + * [BC BREAK] renamed theme blocks + * "field_*" to "form_*" + * "field_widget" to "form_widget_simple" + * "widget_choice_options" to "choice_widget_options" + * "generic_label" to "form_label" + * added theme blocks "form_widget_compound", "choice_widget_expanded" and + "choice_widget_collapsed" to make theming more modular + * ValidatorTypeGuesser now guesses "collection" for array type constraint + * added method `guessPattern` to FormTypeGuesserInterface to guess which pattern to use in the HTML5 attribute "pattern" + * deprecated method `guessMinLength` in favor of `guessPattern` + * labels don't display field attributes anymore. Label attributes can be + passed in the "label_attr" option/variable + * added option "mapped" which should be used instead of setting "property_path" to false + * [BC BREAK] "data_class" now *must* be set if a form maps to an object and should be left empty otherwise + * improved error mapping on forms + * dot (".") rules are now allowed to map errors assigned to a form to + one of its children + * errors are not mapped to unsynchronized forms anymore + * [BC BREAK] changed Form constructor to accept a single `FormConfigInterface` object + * [BC BREAK] changed argument order in the FormBuilder constructor + * added Form method `getViewData` + * deprecated Form methods + * `getTypes` + * `getErrorBubbling` + * `getNormTransformers` + * `getClientTransformers` + * `getAttribute` + * `hasAttribute` + * `getClientData` + * added FormBuilder methods + * `getTypes` + * `addViewTransformer` + * `getViewTransformers` + * `resetViewTransformers` + * `addModelTransformer` + * `getModelTransformers` + * `resetModelTransformers` + * deprecated FormBuilder methods + * `prependClientTransformer` + * `appendClientTransformer` + * `getClientTransformers` + * `resetClientTransformers` + * `prependNormTransformer` + * `appendNormTransformer` + * `getNormTransformers` + * `resetNormTransformers` + * deprecated the option "validation_constraint" in favor of the new + option "constraints" + * removed superfluous methods from DataMapperInterface + * `mapFormToData` + * `mapDataToForm` + * added `setDefaultOptions` to FormTypeInterface and FormTypeExtensionInterface + which accepts an OptionsResolverInterface instance + * deprecated the methods `getDefaultOptions` and `getAllowedOptionValues` + in FormTypeInterface and FormTypeExtensionInterface + * options passed during construction can now be accessed from FormConfigInterface + * added FormBuilderInterface and FormConfigEditorInterface + * [BC BREAK] the method `buildForm` in FormTypeInterface and FormTypeExtensionInterface + now receives a FormBuilderInterface instead of a FormBuilder instance + * [BC BREAK] the method `buildViewBottomUp` was renamed to `finishView` in + FormTypeInterface and FormTypeExtensionInterface + * [BC BREAK] the options array is now passed as last argument of the + methods + * `buildView` + * `finishView` + in FormTypeInterface and FormTypeExtensionInterface + * [BC BREAK] no options are passed to `getParent` of FormTypeInterface anymore + * deprecated DataEvent and FilterDataEvent in favor of the new FormEvent which is + now passed to all events thrown by the component + * FormEvents::BIND now replaces FormEvents::BIND_NORM_DATA + * FormEvents::PRE_SET_DATA now replaces FormEvents::SET_DATA + * FormEvents::PRE_BIND now replaces FormEvents::BIND_CLIENT_DATA + * deprecated FormEvents::SET_DATA, FormEvents::BIND_CLIENT_DATA and + FormEvents::BIND_NORM_DATA + * [BC BREAK] reversed the order of the first two arguments to `createNamed` + and `createNamedBuilder` in `FormFactoryInterface` + * deprecated `getChildren` in Form and FormBuilder in favor of `all` + * deprecated `hasChildren` in Form and FormBuilder in favor of `count` + * FormBuilder now implements \IteratorAggregate + * [BC BREAK] compound forms now always need a data mapper + * FormBuilder now maintains the order when explicitly adding form builders as children + * ChoiceType now doesn't add the empty value anymore if the choices already contain an empty element + * DateType, TimeType and DateTimeType now show empty values again if not required + * [BC BREAK] fixed rendering of errors for DateType, BirthdayType and similar ones + * [BC BREAK] fixed: form constraints are only validated if they belong to the validated group + * deprecated `bindRequest` in `Form` and replaced it by a listener to FormEvents::PRE_BIND + * fixed: the "data" option supersedes default values from the model + * changed DateType to refer to the "format" option for calculating the year and day choices instead + of padding them automatically + * [BC BREAK] DateType defaults to the format "yyyy-MM-dd" now if the widget is + "single_text", in order to support the HTML 5 date field out of the box + * added the option "format" to DateTimeType + * [BC BREAK] DateTimeType now outputs RFC 3339 dates by default, as generated and + consumed by HTML5 browsers, if the widget is "single_text" + * deprecated the options "data_timezone" and "user_timezone" in DateType, DateTimeType and TimeType + and renamed them to "model_timezone" and "view_timezone" + * fixed: TransformationFailedExceptions thrown in the model transformer are now caught by the form + * added FormRegistryInterface, ResolvedFormTypeInterface and ResolvedFormTypeFactoryInterface + * deprecated FormFactory methods + * `addType` + * `hasType` + * `getType` + * [BC BREAK] FormFactory now expects a FormRegistryInterface and a ResolvedFormTypeFactoryInterface as constructor argument + * [BC BREAK] The method `createBuilder` in FormTypeInterface is not supported anymore for performance reasons + * [BC BREAK] Removed `setTypes` from FormBuilder + * deprecated AbstractType methods + * `getExtensions` + * `setExtensions` + * ChoiceType now caches its created choice lists to improve performance + * [BC BREAK] Rows of a collection field cannot be themed individually anymore. All rows in the collection + field now have the same block names, which contains "entry" where it previously contained the row index. + * [BC BREAK] When registering a type through the DI extension, the tag alias has to match the actual type name. + * added FormRendererInterface, FormRendererEngineInterface and implementations of these interfaces + * [BC BREAK] removed the following methods from FormUtil: + * `toArrayKey` + * `toArrayKeys` + * `isChoiceGroup` + * `isChoiceSelected` + * [BC BREAK] renamed method `renderBlock` in FormHelper to `block` and changed its signature + * made FormView properties public and deprecated their accessor methods + * made the normalized data of a form accessible in the template through the variable "form.vars.data" + * made the original data of a choice accessible in the template through the property "choice.data" + * added convenience class Forms and FormFactoryBuilderInterface diff --git a/lib/symfony/form/CallbackTransformer.php b/lib/symfony/form/CallbackTransformer.php new file mode 100644 index 000000000..2a79b5b36 --- /dev/null +++ b/lib/symfony/form/CallbackTransformer.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; + +class CallbackTransformer implements DataTransformerInterface +{ + private \Closure $transform; + private \Closure $reverseTransform; + + public function __construct(callable $transform, callable $reverseTransform) + { + $this->transform = $transform(...); + $this->reverseTransform = $reverseTransform(...); + } + + public function transform(mixed $data): mixed + { + return ($this->transform)($data); + } + + public function reverseTransform(mixed $data): mixed + { + return ($this->reverseTransform)($data); + } +} diff --git a/lib/symfony/form/ChoiceList/ArrayChoiceList.php b/lib/symfony/form/ChoiceList/ArrayChoiceList.php new file mode 100644 index 000000000..36c9854aa --- /dev/null +++ b/lib/symfony/form/ChoiceList/ArrayChoiceList.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList; + +/** + * A list of choices with arbitrary data types. + * + * The user of this class is responsible for assigning string values to the + * choices and for their uniqueness. + * Both the choices and their values are passed to the constructor. + * Each choice must have a corresponding value (with the same key) in + * the values array. + * + * @author Bernhard Schussek + */ +class ArrayChoiceList implements ChoiceListInterface +{ + /** + * The choices in the list. + * + * @var array + */ + protected $choices; + + /** + * The values indexed by the original keys. + * + * @var array + */ + protected $structuredValues; + + /** + * The original keys of the choices array. + * + * @var int[]|string[] + */ + protected $originalKeys; + protected $valueCallback; + + /** + * Creates a list with the given choices and values. + * + * The given choice array must have the same array keys as the value array. + * + * @param iterable $choices The selectable choices + * @param callable|null $value The callable for creating the value + * for a choice. If `null` is passed, + * incrementing integers are used as + * values + */ + public function __construct(iterable $choices, ?callable $value = null) + { + if ($choices instanceof \Traversable) { + $choices = iterator_to_array($choices); + } + + if (null === $value && $this->castableToString($choices)) { + $value = static fn ($choice) => false === $choice ? '0' : (string) $choice; + } + + if (null !== $value) { + // If a deterministic value generator was passed, use it later + $this->valueCallback = $value(...); + } else { + // Otherwise generate incrementing integers as values + $value = static function () { + static $i = 0; + + return $i++; + }; + } + + // If the choices are given as recursive array (i.e. with explicit + // choice groups), flatten the array. The grouping information is needed + // in the view only. + $this->flatten($choices, $value, $choicesByValues, $keysByValues, $structuredValues); + + $this->choices = $choicesByValues; + $this->originalKeys = $keysByValues; + $this->structuredValues = $structuredValues; + } + + public function getChoices(): array + { + return $this->choices; + } + + public function getValues(): array + { + return array_map('strval', array_keys($this->choices)); + } + + public function getStructuredValues(): array + { + return $this->structuredValues; + } + + public function getOriginalKeys(): array + { + return $this->originalKeys; + } + + public function getChoicesForValues(array $values): array + { + $choices = []; + + foreach ($values as $i => $givenValue) { + if (\array_key_exists($givenValue, $this->choices)) { + $choices[$i] = $this->choices[$givenValue]; + } + } + + return $choices; + } + + public function getValuesForChoices(array $choices): array + { + $values = []; + + // Use the value callback to compare choices by their values, if present + if ($this->valueCallback) { + $givenValues = []; + + foreach ($choices as $i => $givenChoice) { + $givenValues[$i] = (string) ($this->valueCallback)($givenChoice); + } + + return array_intersect($givenValues, array_keys($this->choices)); + } + + // Otherwise compare choices by identity + foreach ($choices as $i => $givenChoice) { + foreach ($this->choices as $value => $choice) { + if ($choice === $givenChoice) { + $values[$i] = (string) $value; + break; + } + } + } + + return $values; + } + + /** + * Flattens an array into the given output variables. + * + * @param array $choices The array to flatten + * @param callable $value The callable for generating choice values + * @param array|null $choicesByValues The flattened choices indexed by the + * corresponding values + * @param array|null $keysByValues The original keys indexed by the + * corresponding values + * @param array|null $structuredValues The values indexed by the original keys + * + * @internal + */ + protected function flatten(array $choices, callable $value, ?array &$choicesByValues, ?array &$keysByValues, ?array &$structuredValues): void + { + if (null === $choicesByValues) { + $choicesByValues = []; + $keysByValues = []; + $structuredValues = []; + } + + foreach ($choices as $key => $choice) { + if (\is_array($choice)) { + $this->flatten($choice, $value, $choicesByValues, $keysByValues, $structuredValues[$key]); + + continue; + } + + $choiceValue = (string) $value($choice); + $choicesByValues[$choiceValue] = $choice; + $keysByValues[$choiceValue] = $key; + $structuredValues[$key] = $choiceValue; + } + } + + /** + * Checks whether the given choices can be cast to strings without + * generating duplicates. + * This method is responsible for preventing conflict between scalar values + * and the empty value. + */ + private function castableToString(array $choices, array &$cache = []): bool + { + foreach ($choices as $choice) { + if (\is_array($choice)) { + if (!$this->castableToString($choice, $cache)) { + return false; + } + + continue; + } elseif (!\is_scalar($choice)) { + return false; + } + + // prevent having false casted to the empty string by isset() + $choice = false === $choice ? '0' : (string) $choice; + + if (isset($cache[$choice])) { + return false; + } + + $cache[$choice] = true; + } + + return true; + } +} diff --git a/lib/symfony/form/ChoiceList/ChoiceList.php b/lib/symfony/form/ChoiceList/ChoiceList.php new file mode 100644 index 000000000..31166c1bd --- /dev/null +++ b/lib/symfony/form/ChoiceList/ChoiceList.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList; + +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceAttr; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFieldName; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFilter; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLabel; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLoader; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceTranslationParameters; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceValue; +use Symfony\Component\Form\ChoiceList\Factory\Cache\GroupBy; +use Symfony\Component\Form\ChoiceList\Factory\Cache\PreferredChoice; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; +use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A set of convenient static methods to create cacheable choice list options. + * + * @author Jules Pietri + */ +final class ChoiceList +{ + /** + * Creates a cacheable loader from any callable providing iterable choices. + * + * @param callable $choices A callable that must return iterable choices or grouped choices + * @param mixed $vary Dynamic data used to compute a unique hash when caching the loader + */ + public static function lazy(FormTypeInterface|FormTypeExtensionInterface $formType, callable $choices, mixed $vary = null): ChoiceLoader + { + return self::loader($formType, new CallbackChoiceLoader($choices), $vary); + } + + /** + * Decorates a loader to make it cacheable. + * + * @param ChoiceLoaderInterface $loader A loader responsible for creating loading choices or grouped choices + * @param mixed $vary Dynamic data used to compute a unique hash when caching the loader + */ + public static function loader(FormTypeInterface|FormTypeExtensionInterface $formType, ChoiceLoaderInterface $loader, mixed $vary = null): ChoiceLoader + { + return new ChoiceLoader($formType, $loader, $vary); + } + + /** + * Decorates a "choice_value" callback to make it cacheable. + * + * @param callable|array $value Any pseudo callable to create a unique string value from a choice + * @param mixed $vary Dynamic data used to compute a unique hash when caching the callback + */ + public static function value(FormTypeInterface|FormTypeExtensionInterface $formType, callable|array $value, mixed $vary = null): ChoiceValue + { + return new ChoiceValue($formType, $value, $vary); + } + + /** + * @param callable|array $filter Any pseudo callable to filter a choice list + * @param mixed $vary Dynamic data used to compute a unique hash when caching the callback + */ + public static function filter(FormTypeInterface|FormTypeExtensionInterface $formType, callable|array $filter, mixed $vary = null): ChoiceFilter + { + return new ChoiceFilter($formType, $filter, $vary); + } + + /** + * Decorates a "choice_label" option to make it cacheable. + * + * @param callable|false $label Any pseudo callable to create a label from a choice or false to discard it + * @param mixed $vary Dynamic data used to compute a unique hash when caching the option + */ + public static function label(FormTypeInterface|FormTypeExtensionInterface $formType, callable|false $label, mixed $vary = null): ChoiceLabel + { + return new ChoiceLabel($formType, $label, $vary); + } + + /** + * Decorates a "choice_name" callback to make it cacheable. + * + * @param callable|array $fieldName Any pseudo callable to create a field name from a choice + * @param mixed $vary Dynamic data used to compute a unique hash when caching the callback + */ + public static function fieldName(FormTypeInterface|FormTypeExtensionInterface $formType, callable|array $fieldName, mixed $vary = null): ChoiceFieldName + { + return new ChoiceFieldName($formType, $fieldName, $vary); + } + + /** + * Decorates a "choice_attr" option to make it cacheable. + * + * @param callable|array $attr Any pseudo callable or array to create html attributes from a choice + * @param mixed $vary Dynamic data used to compute a unique hash when caching the option + */ + public static function attr(FormTypeInterface|FormTypeExtensionInterface $formType, callable|array $attr, mixed $vary = null): ChoiceAttr + { + return new ChoiceAttr($formType, $attr, $vary); + } + + /** + * Decorates a "choice_translation_parameters" option to make it cacheable. + * + * @param callable|array $translationParameters Any pseudo callable or array to create translation parameters from a choice + * @param mixed $vary Dynamic data used to compute a unique hash when caching the option + */ + public static function translationParameters(FormTypeInterface|FormTypeExtensionInterface $formType, callable|array $translationParameters, mixed $vary = null): ChoiceTranslationParameters + { + return new ChoiceTranslationParameters($formType, $translationParameters, $vary); + } + + /** + * Decorates a "group_by" callback to make it cacheable. + * + * @param callable|array $groupBy Any pseudo callable to return a group name from a choice + * @param mixed $vary Dynamic data used to compute a unique hash when caching the callback + */ + public static function groupBy(FormTypeInterface|FormTypeExtensionInterface $formType, callable|array $groupBy, mixed $vary = null): GroupBy + { + return new GroupBy($formType, $groupBy, $vary); + } + + /** + * Decorates a "preferred_choices" option to make it cacheable. + * + * @param callable|array $preferred Any pseudo callable or array to return a group name from a choice + * @param mixed $vary Dynamic data used to compute a unique hash when caching the option + */ + public static function preferred(FormTypeInterface|FormTypeExtensionInterface $formType, callable|array $preferred, mixed $vary = null): PreferredChoice + { + return new PreferredChoice($formType, $preferred, $vary); + } + + /** + * Should not be instantiated. + */ + private function __construct() + { + } +} diff --git a/lib/symfony/form/ChoiceList/ChoiceListInterface.php b/lib/symfony/form/ChoiceList/ChoiceListInterface.php new file mode 100644 index 000000000..e711a97ed --- /dev/null +++ b/lib/symfony/form/ChoiceList/ChoiceListInterface.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList; + +/** + * A list of choices that can be selected in a choice field. + * + * A choice list assigns unique string values to each of a list of choices. + * These string values are displayed in the "value" attributes in HTML and + * submitted back to the server. + * + * The acceptable data types for the choices depend on the implementation. + * Values must always be strings and (within the list) free of duplicates. + * + * @author Bernhard Schussek + */ +interface ChoiceListInterface +{ + /** + * Returns all selectable choices. + * + * @return array The selectable choices indexed by the corresponding values + */ + public function getChoices(): array; + + /** + * Returns the values for the choices. + * + * The values are strings that do not contain duplicates: + * + * $form->add('field', 'choice', [ + * 'choices' => [ + * 'Decided' => ['Yes' => true, 'No' => false], + * 'Undecided' => ['Maybe' => null], + * ], + * ]); + * + * In this example, the result of this method is: + * + * [ + * 'Yes' => '0', + * 'No' => '1', + * 'Maybe' => '2', + * ] + * + * Null and false MUST NOT conflict when being casted to string. + * For this some default incremented values SHOULD be computed. + * + * @return string[] + */ + public function getValues(): array; + + /** + * Returns the values in the structure originally passed to the list. + * + * Contrary to {@link getValues()}, the result is indexed by the original + * keys of the choices. If the original array contained nested arrays, these + * nested arrays are represented here as well: + * + * $form->add('field', 'choice', [ + * 'choices' => [ + * 'Decided' => ['Yes' => true, 'No' => false], + * 'Undecided' => ['Maybe' => null], + * ], + * ]); + * + * In this example, the result of this method is: + * + * [ + * 'Decided' => ['Yes' => '0', 'No' => '1'], + * 'Undecided' => ['Maybe' => '2'], + * ] + * + * Nested arrays do not make sense in a view format unless + * they are used as a convenient way of grouping. + * If the implementation does not intend to support grouped choices, + * this method SHOULD be equivalent to {@link getValues()}. + * The $groupBy callback parameter SHOULD be used instead. + * + * @return string[] + */ + public function getStructuredValues(): array; + + /** + * Returns the original keys of the choices. + * + * The original keys are the keys of the choice array that was passed in the + * "choice" option of the choice type. Note that this array may contain + * duplicates if the "choice" option contained choice groups: + * + * $form->add('field', 'choice', [ + * 'choices' => [ + * 'Decided' => [true, false], + * 'Undecided' => [null], + * ], + * ]); + * + * In this example, the original key 0 appears twice, once for `true` and + * once for `null`. + * + * @return int[]|string[] The original choice keys indexed by the + * corresponding choice values + */ + public function getOriginalKeys(): array; + + /** + * Returns the choices corresponding to the given values. + * + * The choices are returned with the same keys and in the same order as the + * corresponding values in the given array. + * + * @param string[] $values An array of choice values. Non-existing values in + * this array are ignored + */ + public function getChoicesForValues(array $values): array; + + /** + * Returns the values corresponding to the given choices. + * + * The values are returned with the same keys and in the same order as the + * corresponding choices in the given array. + * + * @param array $choices An array of choices. Non-existing choices in this + * array are ignored + * + * @return string[] + */ + public function getValuesForChoices(array $choices): array; +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/AbstractStaticOption.php b/lib/symfony/form/ChoiceList/Factory/Cache/AbstractStaticOption.php new file mode 100644 index 000000000..2686017c9 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/AbstractStaticOption.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\ChoiceList\Factory\CachingFactoryDecorator; +use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A template decorator for static {@see ChoiceType} options. + * + * Used as fly weight for {@see CachingFactoryDecorator}. + * + * @internal + * + * @author Jules Pietri + */ +abstract class AbstractStaticOption +{ + private static array $options = []; + + private bool|string|array|\Closure|ChoiceLoaderInterface $option; + + /** + * @param mixed $option Any pseudo callable, array, string or bool to define a choice list option + * @param mixed $vary Dynamic data used to compute a unique hash when caching the option + */ + final public function __construct(FormTypeInterface|FormTypeExtensionInterface $formType, mixed $option, mixed $vary = null) + { + $hash = CachingFactoryDecorator::generateHash([static::class, $formType, $vary]); + + $this->option = self::$options[$hash] ??= $option instanceof \Closure || \is_string($option) || \is_bool($option) || $option instanceof ChoiceLoaderInterface || !\is_callable($option) ? $option : $option(...); + } + + final public function getOption(): mixed + { + return $this->option; + } + + final public static function reset(): void + { + self::$options = []; + } +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceAttr.php b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceAttr.php new file mode 100644 index 000000000..8de6956d1 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceAttr.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_attr" option. + * + * @internal + * + * @author Jules Pietri + */ +final class ChoiceAttr extends AbstractStaticOption +{ +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceFieldName.php b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceFieldName.php new file mode 100644 index 000000000..0c71e2050 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceFieldName.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_name" callback. + * + * @internal + * + * @author Jules Pietri + */ +final class ChoiceFieldName extends AbstractStaticOption +{ +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceFilter.php b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceFilter.php new file mode 100644 index 000000000..13b8cd8ed --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceFilter.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_filter" option. + * + * @internal + * + * @author Jules Pietri + */ +final class ChoiceFilter extends AbstractStaticOption +{ +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceLabel.php b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceLabel.php new file mode 100644 index 000000000..664a09081 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceLabel.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_label" option. + * + * @internal + * + * @author Jules Pietri + */ +final class ChoiceLabel extends AbstractStaticOption +{ +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceLoader.php b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceLoader.php new file mode 100644 index 000000000..1d64f101c --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceLoader.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_loader" option. + * + * @internal + * + * @author Jules Pietri + */ +final class ChoiceLoader extends AbstractStaticOption implements ChoiceLoaderInterface +{ + public function loadChoiceList(?callable $value = null): ChoiceListInterface + { + return $this->getOption()->loadChoiceList($value); + } + + public function loadChoicesForValues(array $values, ?callable $value = null): array + { + return $this->getOption()->loadChoicesForValues($values, $value); + } + + public function loadValuesForChoices(array $choices, ?callable $value = null): array + { + return $this->getOption()->loadValuesForChoices($choices, $value); + } +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceTranslationParameters.php b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceTranslationParameters.php new file mode 100644 index 000000000..e9ab5c711 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceTranslationParameters.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_translation_parameters" option. + * + * @internal + * + * @author Vincent Langlet + */ +final class ChoiceTranslationParameters extends AbstractStaticOption +{ +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceValue.php b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceValue.php new file mode 100644 index 000000000..d96f1e9e8 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/ChoiceValue.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_value" callback. + * + * @internal + * + * @author Jules Pietri + */ +final class ChoiceValue extends AbstractStaticOption +{ +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/GroupBy.php b/lib/symfony/form/ChoiceList/Factory/Cache/GroupBy.php new file mode 100644 index 000000000..2ad492caf --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/GroupBy.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "group_by" callback. + * + * @internal + * + * @author Jules Pietri + */ +final class GroupBy extends AbstractStaticOption +{ +} diff --git a/lib/symfony/form/ChoiceList/Factory/Cache/PreferredChoice.php b/lib/symfony/form/ChoiceList/Factory/Cache/PreferredChoice.php new file mode 100644 index 000000000..4aefd69ab --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/Cache/PreferredChoice.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "preferred_choices" option. + * + * @internal + * + * @author Jules Pietri + */ +final class PreferredChoice extends AbstractStaticOption +{ +} diff --git a/lib/symfony/form/ChoiceList/Factory/CachingFactoryDecorator.php b/lib/symfony/form/ChoiceList/Factory/CachingFactoryDecorator.php new file mode 100644 index 000000000..03bdff5dc --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/CachingFactoryDecorator.php @@ -0,0 +1,232 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory; + +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; +use Symfony\Component\Form\ChoiceList\View\ChoiceListView; +use Symfony\Contracts\Service\ResetInterface; + +/** + * Caches the choice lists created by the decorated factory. + * + * To cache a list based on its options, arguments must be decorated + * by a {@see Cache\AbstractStaticOption} implementation. + * + * @author Bernhard Schussek + * @author Jules Pietri + */ +class CachingFactoryDecorator implements ChoiceListFactoryInterface, ResetInterface +{ + private ChoiceListFactoryInterface $decoratedFactory; + + /** + * @var ChoiceListInterface[] + */ + private array $lists = []; + + /** + * @var ChoiceListView[] + */ + private array $views = []; + + /** + * Generates a SHA-256 hash for the given value. + * + * Optionally, a namespace string can be passed. Calling this method will + * the same values, but different namespaces, will return different hashes. + * + * @return string The SHA-256 hash + * + * @internal + */ + public static function generateHash(mixed $value, string $namespace = ''): string + { + if (\is_object($value)) { + $value = spl_object_hash($value); + } elseif (\is_array($value)) { + array_walk_recursive($value, static function (&$v) { + if (\is_object($v)) { + $v = spl_object_hash($v); + } + }); + } + + return hash('sha256', $namespace.':'.serialize($value)); + } + + public function __construct(ChoiceListFactoryInterface $decoratedFactory) + { + $this->decoratedFactory = $decoratedFactory; + } + + /** + * Returns the decorated factory. + */ + public function getDecoratedFactory(): ChoiceListFactoryInterface + { + return $this->decoratedFactory; + } + + public function createListFromChoices(iterable $choices, mixed $value = null, mixed $filter = null): ChoiceListInterface + { + if ($choices instanceof \Traversable) { + $choices = iterator_to_array($choices); + } + + $cache = true; + // Only cache per value and filter when needed. The value is not validated on purpose. + // The decorated factory may decide which values to accept and which not. + if ($value instanceof Cache\ChoiceValue) { + $value = $value->getOption(); + } elseif ($value) { + $cache = false; + } + if ($filter instanceof Cache\ChoiceFilter) { + $filter = $filter->getOption(); + } elseif ($filter) { + $cache = false; + } + + if (!$cache) { + return $this->decoratedFactory->createListFromChoices($choices, $value, $filter); + } + + $hash = self::generateHash([$choices, $value, $filter], 'fromChoices'); + + if (!isset($this->lists[$hash])) { + $this->lists[$hash] = $this->decoratedFactory->createListFromChoices($choices, $value, $filter); + } + + return $this->lists[$hash]; + } + + public function createListFromLoader(ChoiceLoaderInterface $loader, mixed $value = null, mixed $filter = null): ChoiceListInterface + { + $cache = true; + + if ($loader instanceof Cache\ChoiceLoader) { + $loader = $loader->getOption(); + } else { + $cache = false; + } + + if ($value instanceof Cache\ChoiceValue) { + $value = $value->getOption(); + } elseif ($value) { + $cache = false; + } + + if ($filter instanceof Cache\ChoiceFilter) { + $filter = $filter->getOption(); + } elseif ($filter) { + $cache = false; + } + + if (!$cache) { + return $this->decoratedFactory->createListFromLoader($loader, $value, $filter); + } + + $hash = self::generateHash([$loader, $value, $filter], 'fromLoader'); + + if (!isset($this->lists[$hash])) { + $this->lists[$hash] = $this->decoratedFactory->createListFromLoader($loader, $value, $filter); + } + + return $this->lists[$hash]; + } + + /** + * @param bool $duplicatePreferredChoices + */ + public function createView(ChoiceListInterface $list, mixed $preferredChoices = null, mixed $label = null, mixed $index = null, mixed $groupBy = null, mixed $attr = null, mixed $labelTranslationParameters = []/* , bool $duplicatePreferredChoices = true */): ChoiceListView + { + $duplicatePreferredChoices = \func_num_args() > 7 ? func_get_arg(7) : true; + $cache = true; + + if ($preferredChoices instanceof Cache\PreferredChoice) { + $preferredChoices = $preferredChoices->getOption(); + } elseif ($preferredChoices) { + $cache = false; + } + + if ($label instanceof Cache\ChoiceLabel) { + $label = $label->getOption(); + } elseif (null !== $label) { + $cache = false; + } + + if ($index instanceof Cache\ChoiceFieldName) { + $index = $index->getOption(); + } elseif ($index) { + $cache = false; + } + + if ($groupBy instanceof Cache\GroupBy) { + $groupBy = $groupBy->getOption(); + } elseif ($groupBy) { + $cache = false; + } + + if ($attr instanceof Cache\ChoiceAttr) { + $attr = $attr->getOption(); + } elseif ($attr) { + $cache = false; + } + + if ($labelTranslationParameters instanceof Cache\ChoiceTranslationParameters) { + $labelTranslationParameters = $labelTranslationParameters->getOption(); + } elseif ([] !== $labelTranslationParameters) { + $cache = false; + } + + if (!$cache) { + return $this->decoratedFactory->createView( + $list, + $preferredChoices, + $label, + $index, + $groupBy, + $attr, + $labelTranslationParameters, + $duplicatePreferredChoices, + ); + } + + $hash = self::generateHash([$list, $preferredChoices, $label, $index, $groupBy, $attr, $labelTranslationParameters, $duplicatePreferredChoices]); + + if (!isset($this->views[$hash])) { + $this->views[$hash] = $this->decoratedFactory->createView( + $list, + $preferredChoices, + $label, + $index, + $groupBy, + $attr, + $labelTranslationParameters, + $duplicatePreferredChoices, + ); + } + + return $this->views[$hash]; + } + + /** + * @return void + */ + public function reset() + { + $this->lists = []; + $this->views = []; + Cache\AbstractStaticOption::reset(); + } +} diff --git a/lib/symfony/form/ChoiceList/Factory/ChoiceListFactoryInterface.php b/lib/symfony/form/ChoiceList/Factory/ChoiceListFactoryInterface.php new file mode 100644 index 000000000..7820af003 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/ChoiceListFactoryInterface.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory; + +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; +use Symfony\Component\Form\ChoiceList\View\ChoiceListView; + +/** + * Creates {@link ChoiceListInterface} instances. + * + * @author Bernhard Schussek + */ +interface ChoiceListFactoryInterface +{ + /** + * Creates a choice list for the given choices. + * + * The choices should be passed in the values of the choices array. + * + * Optionally, a callable can be passed for generating the choice values. + * The callable receives the choice as only argument. + * Null may be passed when the choice list contains the empty value. + * + * @param callable|null $filter The callable filtering the choices + */ + public function createListFromChoices(iterable $choices, ?callable $value = null, ?callable $filter = null): ChoiceListInterface; + + /** + * Creates a choice list that is loaded with the given loader. + * + * Optionally, a callable can be passed for generating the choice values. + * The callable receives the choice as only argument. + * Null may be passed when the choice list contains the empty value. + * + * @param callable|null $filter The callable filtering the choices + */ + public function createListFromLoader(ChoiceLoaderInterface $loader, ?callable $value = null, ?callable $filter = null): ChoiceListInterface; + + /** + * Creates a view for the given choice list. + * + * Callables may be passed for all optional arguments. The callables receive + * the choice as first and the array key as the second argument. + * + * * The callable for the label and the name should return the generated + * label/choice name. + * * The callable for the preferred choices should return true or false, + * depending on whether the choice should be preferred or not. + * * The callable for the grouping should return the group name or null if + * a choice should not be grouped. + * * The callable for the attributes should return an array of HTML + * attributes that will be inserted in the tag of the choice. + * + * If no callable is passed, the labels will be generated from the choice + * keys. The view indices will be generated using an incrementing integer + * by default. + * + * The preferred choices can also be passed as array. Each choice that is + * contained in that array will be marked as preferred. + * + * The attributes can be passed as multi-dimensional array. The keys should + * match the keys of the choices. The values should be arrays of HTML + * attributes that should be added to the respective choice. + * + * @param array|callable|null $preferredChoices The preferred choices + * @param callable|false|null $label The callable generating the choice labels; + * pass false to discard the label + * @param array|callable|null $attr The callable generating the HTML attributes + * @param array|callable $labelTranslationParameters The parameters used to translate the choice labels + * @param bool $duplicatePreferredChoices Whether the preferred choices should be duplicated + * on top of the list and in their original position + * or only in the top of the list + */ + public function createView(ChoiceListInterface $list, array|callable|null $preferredChoices = null, callable|false|null $label = null, ?callable $index = null, ?callable $groupBy = null, array|callable|null $attr = null, array|callable $labelTranslationParameters = []/* , bool $duplicatePreferredChoices = true */): ChoiceListView; +} diff --git a/lib/symfony/form/ChoiceList/Factory/DefaultChoiceListFactory.php b/lib/symfony/form/ChoiceList/Factory/DefaultChoiceListFactory.php new file mode 100644 index 000000000..849421f78 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/DefaultChoiceListFactory.php @@ -0,0 +1,307 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory; + +use Symfony\Component\Form\ChoiceList\ArrayChoiceList; +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\ChoiceList\LazyChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; +use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; +use Symfony\Component\Form\ChoiceList\Loader\FilterChoiceLoaderDecorator; +use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView; +use Symfony\Component\Form\ChoiceList\View\ChoiceListView; +use Symfony\Component\Form\ChoiceList\View\ChoiceView; +use Symfony\Contracts\Translation\TranslatableInterface; + +/** + * Default implementation of {@link ChoiceListFactoryInterface}. + * + * @author Bernhard Schussek + * @author Jules Pietri + */ +class DefaultChoiceListFactory implements ChoiceListFactoryInterface +{ + public function createListFromChoices(iterable $choices, ?callable $value = null, ?callable $filter = null): ChoiceListInterface + { + if ($filter) { + // filter the choice list lazily + return $this->createListFromLoader(new FilterChoiceLoaderDecorator( + new CallbackChoiceLoader(static fn () => $choices), + $filter + ), $value); + } + + return new ArrayChoiceList($choices, $value); + } + + public function createListFromLoader(ChoiceLoaderInterface $loader, ?callable $value = null, ?callable $filter = null): ChoiceListInterface + { + if ($filter) { + $loader = new FilterChoiceLoaderDecorator($loader, $filter); + } + + return new LazyChoiceList($loader, $value); + } + + /** + * @param bool $duplicatePreferredChoices + */ + public function createView(ChoiceListInterface $list, array|callable|null $preferredChoices = null, callable|false|null $label = null, ?callable $index = null, ?callable $groupBy = null, array|callable|null $attr = null, array|callable $labelTranslationParameters = []/* , bool $duplicatePreferredChoices = true */): ChoiceListView + { + $duplicatePreferredChoices = \func_num_args() > 7 ? func_get_arg(7) : true; + $preferredViews = []; + $preferredViewsOrder = []; + $otherViews = []; + $choices = $list->getChoices(); + $keys = $list->getOriginalKeys(); + + if (!\is_callable($preferredChoices)) { + if (!$preferredChoices) { + $preferredChoices = null; + } else { + // make sure we have keys that reflect order + $preferredChoices = array_values($preferredChoices); + $preferredChoices = static fn ($choice) => array_search($choice, $preferredChoices, true); + } + } + + // The names are generated from an incrementing integer by default + $index ??= 0; + + // If $groupBy is a callable returning a string + // choices are added to the group with the name returned by the callable. + // If $groupBy is a callable returning an array + // choices are added to the groups with names returned by the callable + // If the callable returns null, the choice is not added to any group + if (\is_callable($groupBy)) { + foreach ($choices as $value => $choice) { + self::addChoiceViewsGroupedByCallable( + $groupBy, + $choice, + $value, + $label, + $keys, + $index, + $attr, + $labelTranslationParameters, + $preferredChoices, + $preferredViews, + $preferredViewsOrder, + $otherViews, + $duplicatePreferredChoices, + ); + } + + // Remove empty group views that may have been created by + // addChoiceViewsGroupedByCallable() + foreach ($preferredViews as $key => $view) { + if ($view instanceof ChoiceGroupView && 0 === \count($view->choices)) { + unset($preferredViews[$key]); + } + } + + foreach ($otherViews as $key => $view) { + if ($view instanceof ChoiceGroupView && 0 === \count($view->choices)) { + unset($otherViews[$key]); + } + } + + foreach ($preferredViewsOrder as $key => $groupViewsOrder) { + if ($groupViewsOrder) { + $preferredViewsOrder[$key] = min($groupViewsOrder); + } else { + unset($preferredViewsOrder[$key]); + } + } + } else { + // Otherwise use the original structure of the choices + self::addChoiceViewsFromStructuredValues( + $list->getStructuredValues(), + $label, + $choices, + $keys, + $index, + $attr, + $labelTranslationParameters, + $preferredChoices, + $preferredViews, + $preferredViewsOrder, + $otherViews, + $duplicatePreferredChoices, + ); + } + + uksort($preferredViews, static fn ($a, $b) => isset($preferredViewsOrder[$a], $preferredViewsOrder[$b]) ? $preferredViewsOrder[$a] <=> $preferredViewsOrder[$b] : 0); + + return new ChoiceListView($otherViews, $preferredViews); + } + + private static function addChoiceView($choice, string $value, $label, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews, bool $duplicatePreferredChoices): void + { + // $value may be an integer or a string, since it's stored in the array + // keys. We want to guarantee it's a string though. + $key = $keys[$value]; + $nextIndex = \is_int($index) ? $index++ : $index($choice, $key, $value); + + // BC normalize label to accept a false value + if (null === $label) { + // If the labels are null, use the original choice key by default + $label = (string) $key; + } elseif (false !== $label) { + // If "choice_label" is set to false and "expanded" is true, the value false + // should be passed on to the "label" option of the checkboxes/radio buttons + $dynamicLabel = $label($choice, $key, $value); + + if (false === $dynamicLabel) { + $label = false; + } elseif ($dynamicLabel instanceof TranslatableInterface) { + $label = $dynamicLabel; + } else { + $label = (string) $dynamicLabel; + } + } + + $view = new ChoiceView( + $choice, + $value, + $label, + // The attributes may be a callable or a mapping from choice indices + // to nested arrays + \is_callable($attr) ? $attr($choice, $key, $value) : ($attr[$key] ?? []), + // The label translation parameters may be a callable or a mapping from choice indices + // to nested arrays + \is_callable($labelTranslationParameters) ? $labelTranslationParameters($choice, $key, $value) : ($labelTranslationParameters[$key] ?? []) + ); + + // $isPreferred may be null if no choices are preferred + if (null !== $isPreferred && false !== $preferredKey = $isPreferred($choice, $key, $value)) { + $preferredViews[$nextIndex] = $view; + $preferredViewsOrder[$nextIndex] = $preferredKey; + + if ($duplicatePreferredChoices) { + $otherViews[$nextIndex] = $view; + } + } else { + $otherViews[$nextIndex] = $view; + } + } + + private static function addChoiceViewsFromStructuredValues(array $values, $label, array $choices, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews, bool $duplicatePreferredChoices): void + { + foreach ($values as $key => $value) { + if (null === $value) { + continue; + } + + // Add the contents of groups to new ChoiceGroupView instances + if (\is_array($value)) { + $preferredViewsForGroup = []; + $otherViewsForGroup = []; + + self::addChoiceViewsFromStructuredValues( + $value, + $label, + $choices, + $keys, + $index, + $attr, + $labelTranslationParameters, + $isPreferred, + $preferredViewsForGroup, + $preferredViewsOrder, + $otherViewsForGroup, + $duplicatePreferredChoices, + ); + + if (\count($preferredViewsForGroup) > 0) { + $preferredViews[$key] = new ChoiceGroupView($key, $preferredViewsForGroup); + } + + if (\count($otherViewsForGroup) > 0) { + $otherViews[$key] = new ChoiceGroupView($key, $otherViewsForGroup); + } + + continue; + } + + // Add ungrouped items directly + self::addChoiceView( + $choices[$value], + $value, + $label, + $keys, + $index, + $attr, + $labelTranslationParameters, + $isPreferred, + $preferredViews, + $preferredViewsOrder, + $otherViews, + $duplicatePreferredChoices, + ); + } + } + + private static function addChoiceViewsGroupedByCallable(callable $groupBy, $choice, string $value, $label, array $keys, &$index, $attr, $labelTranslationParameters, ?callable $isPreferred, array &$preferredViews, array &$preferredViewsOrder, array &$otherViews, bool $duplicatePreferredChoices): void + { + $groupLabels = $groupBy($choice, $keys[$value], $value); + + if (null === $groupLabels) { + // If the callable returns null, don't group the choice + self::addChoiceView( + $choice, + $value, + $label, + $keys, + $index, + $attr, + $labelTranslationParameters, + $isPreferred, + $preferredViews, + $preferredViewsOrder, + $otherViews, + $duplicatePreferredChoices, + ); + + return; + } + + $groupLabels = \is_array($groupLabels) ? array_map('strval', $groupLabels) : [(string) $groupLabels]; + + foreach ($groupLabels as $groupLabel) { + // Initialize the group views if necessary. Unnecessarily built group + // views will be cleaned up at the end of createView() + if (!isset($preferredViews[$groupLabel])) { + $preferredViews[$groupLabel] = new ChoiceGroupView($groupLabel); + $otherViews[$groupLabel] = new ChoiceGroupView($groupLabel); + } + if (!isset($preferredViewsOrder[$groupLabel])) { + $preferredViewsOrder[$groupLabel] = []; + } + + self::addChoiceView( + $choice, + $value, + $label, + $keys, + $index, + $attr, + $labelTranslationParameters, + $isPreferred, + $preferredViews[$groupLabel]->choices, + $preferredViewsOrder[$groupLabel], + $otherViews[$groupLabel]->choices, + $duplicatePreferredChoices, + ); + } + } +} diff --git a/lib/symfony/form/ChoiceList/Factory/PropertyAccessDecorator.php b/lib/symfony/form/ChoiceList/Factory/PropertyAccessDecorator.php new file mode 100644 index 000000000..e27c60420 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Factory/PropertyAccessDecorator.php @@ -0,0 +1,193 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory; + +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; +use Symfony\Component\Form\ChoiceList\View\ChoiceListView; +use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * Adds property path support to a choice list factory. + * + * Pass the decorated factory to the constructor: + * + * $decorator = new PropertyAccessDecorator($factory); + * + * You can now pass property paths for generating choice values, labels, view + * indices, HTML attributes and for determining the preferred choices and the + * choice groups: + * + * // extract values from the $value property + * $list = $createListFromChoices($objects, 'value'); + * + * @author Bernhard Schussek + */ +class PropertyAccessDecorator implements ChoiceListFactoryInterface +{ + private ChoiceListFactoryInterface $decoratedFactory; + private PropertyAccessorInterface $propertyAccessor; + + public function __construct(ChoiceListFactoryInterface $decoratedFactory, ?PropertyAccessorInterface $propertyAccessor = null) + { + $this->decoratedFactory = $decoratedFactory; + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + + /** + * Returns the decorated factory. + */ + public function getDecoratedFactory(): ChoiceListFactoryInterface + { + return $this->decoratedFactory; + } + + public function createListFromChoices(iterable $choices, mixed $value = null, mixed $filter = null): ChoiceListInterface + { + if (\is_string($value)) { + $value = new PropertyPath($value); + } + + if ($value instanceof PropertyPathInterface) { + $accessor = $this->propertyAccessor; + // The callable may be invoked with a non-object/array value + // when such values are passed to + // ChoiceListInterface::getValuesForChoices(). Handle this case + // so that the call to getValue() doesn't break. + $value = static fn ($choice) => \is_object($choice) || \is_array($choice) ? $accessor->getValue($choice, $value) : null; + } + + if (\is_string($filter)) { + $filter = new PropertyPath($filter); + } + + if ($filter instanceof PropertyPath) { + $accessor = $this->propertyAccessor; + $filter = static fn ($choice) => (\is_object($choice) || \is_array($choice)) && $accessor->getValue($choice, $filter); + } + + return $this->decoratedFactory->createListFromChoices($choices, $value, $filter); + } + + public function createListFromLoader(ChoiceLoaderInterface $loader, mixed $value = null, mixed $filter = null): ChoiceListInterface + { + if (\is_string($value)) { + $value = new PropertyPath($value); + } + + if ($value instanceof PropertyPathInterface) { + $accessor = $this->propertyAccessor; + // The callable may be invoked with a non-object/array value + // when such values are passed to + // ChoiceListInterface::getValuesForChoices(). Handle this case + // so that the call to getValue() doesn't break. + $value = static fn ($choice) => \is_object($choice) || \is_array($choice) ? $accessor->getValue($choice, $value) : null; + } + + if (\is_string($filter)) { + $filter = new PropertyPath($filter); + } + + if ($filter instanceof PropertyPath) { + $accessor = $this->propertyAccessor; + $filter = static fn ($choice) => (\is_object($choice) || \is_array($choice)) && $accessor->getValue($choice, $filter); + } + + return $this->decoratedFactory->createListFromLoader($loader, $value, $filter); + } + + /** + * @param bool $duplicatePreferredChoices + */ + public function createView(ChoiceListInterface $list, mixed $preferredChoices = null, mixed $label = null, mixed $index = null, mixed $groupBy = null, mixed $attr = null, mixed $labelTranslationParameters = []/* , bool $duplicatePreferredChoices = true */): ChoiceListView + { + $duplicatePreferredChoices = \func_num_args() > 7 ? func_get_arg(7) : true; + $accessor = $this->propertyAccessor; + + if (\is_string($label)) { + $label = new PropertyPath($label); + } + + if ($label instanceof PropertyPathInterface) { + $label = static fn ($choice) => $accessor->getValue($choice, $label); + } + + if (\is_string($preferredChoices)) { + $preferredChoices = new PropertyPath($preferredChoices); + } + + if ($preferredChoices instanceof PropertyPathInterface) { + $preferredChoices = static function ($choice) use ($accessor, $preferredChoices) { + try { + return $accessor->getValue($choice, $preferredChoices); + } catch (UnexpectedTypeException) { + // Assume not preferred if not readable + return false; + } + }; + } + + if (\is_string($index)) { + $index = new PropertyPath($index); + } + + if ($index instanceof PropertyPathInterface) { + $index = static fn ($choice) => $accessor->getValue($choice, $index); + } + + if (\is_string($groupBy)) { + $groupBy = new PropertyPath($groupBy); + } + + if ($groupBy instanceof PropertyPathInterface) { + $groupBy = static function ($choice) use ($accessor, $groupBy) { + try { + return $accessor->getValue($choice, $groupBy); + } catch (UnexpectedTypeException) { + // Don't group if path is not readable + return null; + } + }; + } + + if (\is_string($attr)) { + $attr = new PropertyPath($attr); + } + + if ($attr instanceof PropertyPathInterface) { + $attr = static fn ($choice) => $accessor->getValue($choice, $attr); + } + + if (\is_string($labelTranslationParameters)) { + $labelTranslationParameters = new PropertyPath($labelTranslationParameters); + } + + if ($labelTranslationParameters instanceof PropertyPath) { + $labelTranslationParameters = static fn ($choice) => $accessor->getValue($choice, $labelTranslationParameters); + } + + return $this->decoratedFactory->createView( + $list, + $preferredChoices, + $label, + $index, + $groupBy, + $attr, + $labelTranslationParameters, + $duplicatePreferredChoices, + ); + } +} diff --git a/lib/symfony/form/ChoiceList/LazyChoiceList.php b/lib/symfony/form/ChoiceList/LazyChoiceList.php new file mode 100644 index 000000000..2f7918926 --- /dev/null +++ b/lib/symfony/form/ChoiceList/LazyChoiceList.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList; + +use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; + +/** + * A choice list that loads its choices lazily. + * + * The choices are fetched using a {@link ChoiceLoaderInterface} instance. + * If only {@link getChoicesForValues()} or {@link getValuesForChoices()} is + * called, the choice list is only loaded partially for improved performance. + * + * Once {@link getChoices()} or {@link getValues()} is called, the list is + * loaded fully. + * + * @author Bernhard Schussek + */ +class LazyChoiceList implements ChoiceListInterface +{ + private ChoiceLoaderInterface $loader; + + /** + * The callable creating string values for each choice. + * + * If null, choices are cast to strings. + */ + private ?\Closure $value; + + /** + * Creates a lazily-loaded list using the given loader. + * + * Optionally, a callable can be passed for generating the choice values. + * The callable receives the choice as first and the array key as the second + * argument. + * + * @param callable|null $value The callable generating the choice values + */ + public function __construct(ChoiceLoaderInterface $loader, ?callable $value = null) + { + $this->loader = $loader; + $this->value = null === $value ? null : $value(...); + } + + public function getChoices(): array + { + return $this->loader->loadChoiceList($this->value)->getChoices(); + } + + public function getValues(): array + { + return $this->loader->loadChoiceList($this->value)->getValues(); + } + + public function getStructuredValues(): array + { + return $this->loader->loadChoiceList($this->value)->getStructuredValues(); + } + + public function getOriginalKeys(): array + { + return $this->loader->loadChoiceList($this->value)->getOriginalKeys(); + } + + public function getChoicesForValues(array $values): array + { + return $this->loader->loadChoicesForValues($values, $this->value); + } + + public function getValuesForChoices(array $choices): array + { + return $this->loader->loadValuesForChoices($choices, $this->value); + } +} diff --git a/lib/symfony/form/ChoiceList/Loader/AbstractChoiceLoader.php b/lib/symfony/form/ChoiceList/Loader/AbstractChoiceLoader.php new file mode 100644 index 000000000..749e2fbce --- /dev/null +++ b/lib/symfony/form/ChoiceList/Loader/AbstractChoiceLoader.php @@ -0,0 +1,66 @@ + + * + * 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; + +/** + * @author Jules Pietri + */ +abstract class AbstractChoiceLoader implements ChoiceLoaderInterface +{ + private ?iterable $choices; + + /** + * @final + */ + public function loadChoiceList(?callable $value = null): ChoiceListInterface + { + return new ArrayChoiceList($this->choices ??= $this->loadChoices(), $value); + } + + public function loadChoicesForValues(array $values, ?callable $value = null): array + { + if (!$values) { + return []; + } + + return $this->doLoadChoicesForValues($values, $value); + } + + public function loadValuesForChoices(array $choices, ?callable $value = null): array + { + if (!$choices) { + return []; + } + + if ($value) { + // if a value callback exists, use it + return array_map(fn ($item) => (string) $value($item), $choices); + } + + return $this->doLoadValuesForChoices($choices); + } + + abstract protected function loadChoices(): iterable; + + protected function doLoadChoicesForValues(array $values, ?callable $value): array + { + return $this->loadChoiceList($value)->getChoicesForValues($values); + } + + protected function doLoadValuesForChoices(array $choices): array + { + return $this->loadChoiceList()->getValuesForChoices($choices); + } +} diff --git a/lib/symfony/form/ChoiceList/Loader/CallbackChoiceLoader.php b/lib/symfony/form/ChoiceList/Loader/CallbackChoiceLoader.php new file mode 100644 index 000000000..088f91dae --- /dev/null +++ b/lib/symfony/form/ChoiceList/Loader/CallbackChoiceLoader.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\ChoiceList\Loader; + +/** + * Loads an {@link ArrayChoiceList} instance from a callable returning iterable choices. + * + * @author Jules Pietri + */ +class CallbackChoiceLoader extends AbstractChoiceLoader +{ + private \Closure $callback; + + /** + * @param callable $callback The callable returning iterable choices + */ + public function __construct(callable $callback) + { + $this->callback = $callback(...); + } + + protected function loadChoices(): iterable + { + return ($this->callback)(); + } +} diff --git a/lib/symfony/form/ChoiceList/Loader/ChoiceLoaderInterface.php b/lib/symfony/form/ChoiceList/Loader/ChoiceLoaderInterface.php new file mode 100644 index 000000000..d5f803c77 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Loader/ChoiceLoaderInterface.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\ChoiceList\Loader; + +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; + +/** + * Loads a choice list. + * + * The methods {@link loadChoicesForValues()} and {@link loadValuesForChoices()} + * can be used to load the list only partially in cases where a fully-loaded + * list is not necessary. + * + * @author Bernhard Schussek + */ +interface ChoiceLoaderInterface +{ + /** + * Loads a list of choices. + * + * Optionally, a callable can be passed for generating the choice values. + * The callable receives the choice as only argument. + * Null may be passed when the choice list contains the empty value. + * + * @param callable|null $value The callable which generates the values + * from choices + */ + public function loadChoiceList(?callable $value = null): ChoiceListInterface; + + /** + * Loads the choices corresponding to the given values. + * + * The choices are returned with the same keys and in the same order as the + * corresponding values in the given array. + * + * Optionally, a callable can be passed for generating the choice values. + * The callable receives the choice as only argument. + * Null may be passed when the choice list contains the empty value. + * + * @param string[] $values An array of choice values. Non-existing + * values in this array are ignored + * @param callable|null $value The callable generating the choice values + */ + public function loadChoicesForValues(array $values, ?callable $value = null): array; + + /** + * Loads the values corresponding to the given choices. + * + * The values are returned with the same keys and in the same order as the + * corresponding choices in the given array. + * + * Optionally, a callable can be passed for generating the choice values. + * The callable receives the choice as only argument. + * Null may be passed when the choice list contains the empty value. + * + * @param array $choices An array of choices. Non-existing choices in + * this array are ignored + * @param callable|null $value The callable generating the choice values + * + * @return string[] + */ + public function loadValuesForChoices(array $choices, ?callable $value = null): array; +} diff --git a/lib/symfony/form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php b/lib/symfony/form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php new file mode 100644 index 000000000..393c73eba --- /dev/null +++ b/lib/symfony/form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php @@ -0,0 +1,64 @@ + + * + * 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; + +/** + * A decorator to filter choices only when they are loaded or partially loaded. + * + * @author Jules Pietri + */ +class FilterChoiceLoaderDecorator extends AbstractChoiceLoader +{ + private ChoiceLoaderInterface $decoratedLoader; + private \Closure $filter; + + public function __construct(ChoiceLoaderInterface $loader, callable $filter) + { + $this->decoratedLoader = $loader; + $this->filter = $filter(...); + } + + protected function loadChoices(): iterable + { + $list = $this->decoratedLoader->loadChoiceList(); + + if (array_values($list->getValues()) === array_values($structuredValues = $list->getStructuredValues())) { + return array_filter(array_combine($list->getOriginalKeys(), $list->getChoices()), $this->filter); + } + + foreach ($structuredValues as $group => $values) { + if (\is_array($values)) { + if ($values && $filtered = array_filter($list->getChoicesForValues($values), $this->filter)) { + $choices[$group] = $filtered; + } + continue; + // filter empty groups + } + + if ($filtered = array_filter($list->getChoicesForValues([$values]), $this->filter)) { + $choices[$group] = $filtered[0]; + } + } + + return $choices ?? []; + } + + public function loadChoicesForValues(array $values, ?callable $value = null): array + { + return array_filter($this->decoratedLoader->loadChoicesForValues($values, $value), $this->filter); + } + + public function loadValuesForChoices(array $choices, ?callable $value = null): array + { + return $this->decoratedLoader->loadValuesForChoices(array_filter($choices, $this->filter), $value); + } +} diff --git a/lib/symfony/form/ChoiceList/Loader/IntlCallbackChoiceLoader.php b/lib/symfony/form/ChoiceList/Loader/IntlCallbackChoiceLoader.php new file mode 100644 index 000000000..0931d3ef5 --- /dev/null +++ b/lib/symfony/form/ChoiceList/Loader/IntlCallbackChoiceLoader.php @@ -0,0 +1,38 @@ + + * + * 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; + +/** + * Callback choice loader optimized for Intl choice types. + * + * @author Jules Pietri + * @author Yonel Ceruto + */ +class IntlCallbackChoiceLoader extends CallbackChoiceLoader +{ + public function loadChoicesForValues(array $values, ?callable $value = null): array + { + return parent::loadChoicesForValues(array_filter($values), $value); + } + + public function loadValuesForChoices(array $choices, ?callable $value = null): array + { + $choices = array_filter($choices); + + // If no callable is set, choices are the same as values + if (null === $value) { + return $choices; + } + + return parent::loadValuesForChoices($choices, $value); + } +} diff --git a/lib/symfony/form/ChoiceList/View/ChoiceGroupView.php b/lib/symfony/form/ChoiceList/View/ChoiceGroupView.php new file mode 100644 index 000000000..64fe3baec --- /dev/null +++ b/lib/symfony/form/ChoiceList/View/ChoiceGroupView.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\View; + +/** + * Represents a group of choices in templates. + * + * @author Bernhard Schussek + * + * @implements \IteratorAggregate + */ +class ChoiceGroupView implements \IteratorAggregate +{ + public $label; + public $choices; + + /** + * Creates a new choice group view. + * + * @param array $choices the choice views in the group + */ + public function __construct(string $label, array $choices = []) + { + $this->label = $label; + $this->choices = $choices; + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->choices); + } +} diff --git a/lib/symfony/form/ChoiceList/View/ChoiceListView.php b/lib/symfony/form/ChoiceList/View/ChoiceListView.php new file mode 100644 index 000000000..949174e3a --- /dev/null +++ b/lib/symfony/form/ChoiceList/View/ChoiceListView.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\View; + +/** + * Represents a choice list in templates. + * + * A choice list contains choices and optionally preferred choices which are + * displayed in the very beginning of the list. Both choices and preferred + * choices may be grouped in {@link ChoiceGroupView} instances. + * + * @author Bernhard Schussek + */ +class ChoiceListView +{ + public $choices; + public $preferredChoices; + + /** + * Creates a new choice list view. + * + * @param array $choices The choice views + * @param array $preferredChoices the preferred choice views + */ + public function __construct(array $choices = [], array $preferredChoices = []) + { + $this->choices = $choices; + $this->preferredChoices = $preferredChoices; + } + + /** + * Returns whether a placeholder is in the choices. + * + * A placeholder must be the first child element, not be in a group and have an empty value. + */ + public function hasPlaceholder(): bool + { + if ($this->preferredChoices) { + $firstChoice = reset($this->preferredChoices); + + return $firstChoice instanceof ChoiceView && '' === $firstChoice->value; + } + + $firstChoice = reset($this->choices); + + return $firstChoice instanceof ChoiceView && '' === $firstChoice->value; + } +} diff --git a/lib/symfony/form/ChoiceList/View/ChoiceView.php b/lib/symfony/form/ChoiceList/View/ChoiceView.php new file mode 100644 index 000000000..050d8ed24 --- /dev/null +++ b/lib/symfony/form/ChoiceList/View/ChoiceView.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\ChoiceList\View; + +use Symfony\Contracts\Translation\TranslatableInterface; + +/** + * Represents a choice in templates. + * + * @author Bernhard Schussek + */ +class ChoiceView +{ + public $label; + public $value; + public $data; + + /** + * Additional attributes for the HTML tag. + */ + public $attr; + + /** + * Additional parameters used to translate the label. + */ + public $labelTranslationParameters; + + /** + * Creates a new choice view. + * + * @param mixed $data The original choice + * @param string $value The view representation of the choice + * @param string|TranslatableInterface|false $label The label displayed to humans; pass false to discard the label + * @param array $attr Additional attributes for the HTML tag + * @param array $labelTranslationParameters Additional parameters used to translate the label + */ + public function __construct(mixed $data, string $value, string|TranslatableInterface|false $label, array $attr = [], array $labelTranslationParameters = []) + { + $this->data = $data; + $this->value = $value; + $this->label = $label; + $this->attr = $attr; + $this->labelTranslationParameters = $labelTranslationParameters; + } +} diff --git a/lib/symfony/form/ClearableErrorsInterface.php b/lib/symfony/form/ClearableErrorsInterface.php new file mode 100644 index 000000000..a05ece05a --- /dev/null +++ b/lib/symfony/form/ClearableErrorsInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * A form element whose errors can be cleared. + * + * @author Colin O'Dell + */ +interface ClearableErrorsInterface +{ + /** + * Removes all the errors of this form. + * + * @param bool $deep Whether to remove errors from child forms as well + * + * @return $this + */ + public function clearErrors(bool $deep = false): static; +} diff --git a/lib/symfony/form/ClickableInterface.php b/lib/symfony/form/ClickableInterface.php new file mode 100644 index 000000000..9be7de0ce --- /dev/null +++ b/lib/symfony/form/ClickableInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * A clickable form element. + * + * @author Bernhard Schussek + */ +interface ClickableInterface +{ + /** + * Returns whether this element was clicked. + */ + public function isClicked(): bool; +} diff --git a/lib/symfony/form/Command/DebugCommand.php b/lib/symfony/form/Command/DebugCommand.php new file mode 100644 index 000000000..551fbc316 --- /dev/null +++ b/lib/symfony/form/Command/DebugCommand.php @@ -0,0 +1,291 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\Form\Console\Helper\DescriptorHelper; +use Symfony\Component\Form\Extension\Core\CoreExtension; +use Symfony\Component\Form\FormRegistryInterface; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\HttpKernel\Debug\FileLinkFormatter as LegacyFileLinkFormatter; + +/** + * A console command for retrieving information about form types. + * + * @author Yonel Ceruto + */ +#[AsCommand(name: 'debug:form', description: 'Display form type information')] +class DebugCommand extends Command +{ + private FormRegistryInterface $formRegistry; + private array $namespaces; + private array $types; + private array $extensions; + private array $guessers; + private FileLinkFormatter|LegacyFileLinkFormatter|null $fileLinkFormatter; + + public function __construct(FormRegistryInterface $formRegistry, array $namespaces = ['Symfony\Component\Form\Extension\Core\Type'], array $types = [], array $extensions = [], array $guessers = [], FileLinkFormatter|LegacyFileLinkFormatter|null $fileLinkFormatter = null) + { + parent::__construct(); + + $this->formRegistry = $formRegistry; + $this->namespaces = $namespaces; + $this->types = $types; + $this->extensions = $extensions; + $this->guessers = $guessers; + $this->fileLinkFormatter = $fileLinkFormatter; + } + + /** + * @return void + */ + protected function configure() + { + $this + ->setDefinition([ + new InputArgument('class', InputArgument::OPTIONAL, 'The form type class'), + new InputArgument('option', InputArgument::OPTIONAL, 'The form type option'), + new InputOption('show-deprecated', null, InputOption::VALUE_NONE, 'Display deprecated options in form types'), + new InputOption('format', null, InputOption::VALUE_REQUIRED, sprintf('The output format ("%s")', implode('", "', $this->getAvailableFormatOptions())), 'txt'), + ]) + ->setHelp(<<<'EOF' +The %command.name% command displays information about form types. + + php %command.full_name% + +The command lists all built-in types, services types, type extensions and +guessers currently available. + + php %command.full_name% Symfony\Component\Form\Extension\Core\Type\ChoiceType + php %command.full_name% ChoiceType + +The command lists all defined options that contains the given form type, +as well as their parents and type extensions. + + php %command.full_name% ChoiceType choice_value + +Use the --show-deprecated option to display form types with +deprecated options or the deprecated options of the given form type: + + php %command.full_name% --show-deprecated + php %command.full_name% ChoiceType --show-deprecated + +The command displays the definition of the given option name. + + php %command.full_name% --format=json + +The command lists everything in a machine readable json format. +EOF + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (null === $class = $input->getArgument('class')) { + $object = null; + $options['core_types'] = $this->getCoreTypes(); + $options['service_types'] = array_values(array_diff($this->types, $options['core_types'])); + if ($input->getOption('show-deprecated')) { + $options['core_types'] = $this->filterTypesByDeprecated($options['core_types']); + $options['service_types'] = $this->filterTypesByDeprecated($options['service_types']); + } + $options['extensions'] = $this->extensions; + $options['guessers'] = $this->guessers; + foreach ($options as $k => $list) { + sort($options[$k]); + } + } else { + if (!class_exists($class) || !is_subclass_of($class, FormTypeInterface::class)) { + $class = $this->getFqcnTypeClass($input, $io, $class); + } + $resolvedType = $this->formRegistry->getType($class); + + if ($option = $input->getArgument('option')) { + $object = $resolvedType->getOptionsResolver(); + + if (!$object->isDefined($option)) { + $message = sprintf('Option "%s" is not defined in "%s".', $option, $resolvedType->getInnerType()::class); + + if ($alternatives = $this->findAlternatives($option, $object->getDefinedOptions())) { + if (1 === \count($alternatives)) { + $message .= "\n\nDid you mean this?\n "; + } else { + $message .= "\n\nDid you mean one of these?\n "; + } + $message .= implode("\n ", $alternatives); + } + + throw new InvalidArgumentException($message); + } + + $options['type'] = $resolvedType->getInnerType(); + $options['option'] = $option; + } else { + $object = $resolvedType; + } + } + + $helper = new DescriptorHelper($this->fileLinkFormatter); + $options['format'] = $input->getOption('format'); + $options['show_deprecated'] = $input->getOption('show-deprecated'); + $helper->describe($io, $object, $options); + + return 0; + } + + private function getFqcnTypeClass(InputInterface $input, SymfonyStyle $io, string $shortClassName): string + { + $classes = $this->getFqcnTypeClasses($shortClassName); + + 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); + if ($alternatives = $this->findAlternatives($shortClassName, $allTypes)) { + if (1 === \count($alternatives)) { + $message .= "\n\nDid you mean this?\n "; + } else { + $message .= "\n\nDid you mean one of these?\n "; + } + $message .= implode("\n ", $alternatives); + } + + throw new InvalidArgumentException($message); + } + if (1 === $count) { + return $classes[0]; + } + if (!$input->isInteractive()) { + throw new InvalidArgumentException(sprintf("The type \"%s\" is ambiguous.\n\nDid you mean one of these?\n %s.", $shortClassName, implode("\n ", $classes))); + } + + return $io->choice(sprintf("The type \"%s\" is ambiguous.\n\nSelect one of the following form types to display its information:", $shortClassName), $classes, $classes[0]); + } + + private function getFqcnTypeClasses(string $shortClassName): array + { + $classes = []; + sort($this->namespaces); + foreach ($this->namespaces as $namespace) { + if (class_exists($fqcn = $namespace.'\\'.$shortClassName)) { + $classes[] = $fqcn; + } elseif (class_exists($fqcn = $namespace.'\\'.ucfirst($shortClassName))) { + $classes[] = $fqcn; + } elseif (class_exists($fqcn = $namespace.'\\'.ucfirst($shortClassName).'Type')) { + $classes[] = $fqcn; + } elseif (str_ends_with($shortClassName, 'type') && class_exists($fqcn = $namespace.'\\'.ucfirst(substr($shortClassName, 0, -4).'Type'))) { + $classes[] = $fqcn; + } + } + + return $classes; + } + + private function getCoreTypes(): array + { + $coreExtension = new CoreExtension(); + $loadTypesRefMethod = (new \ReflectionObject($coreExtension))->getMethod('loadTypes'); + $coreTypes = $loadTypesRefMethod->invoke($coreExtension); + $coreTypes = array_map(static fn (FormTypeInterface $type) => $type::class, $coreTypes); + sort($coreTypes); + + return $coreTypes; + } + + private function filterTypesByDeprecated(array $types): array + { + $typesWithDeprecatedOptions = []; + foreach ($types as $class) { + $optionsResolver = $this->formRegistry->getType($class)->getOptionsResolver(); + foreach ($optionsResolver->getDefinedOptions() as $option) { + if ($optionsResolver->isDeprecated($option)) { + $typesWithDeprecatedOptions[] = $class; + break; + } + } + } + + return $typesWithDeprecatedOptions; + } + + private function findAlternatives(string $name, array $collection): array + { + $alternatives = []; + foreach ($collection as $item) { + $lev = levenshtein($name, $item); + if ($lev <= \strlen($name) / 3 || str_contains($item, $name)) { + $alternatives[$item] = isset($alternatives[$item]) ? $alternatives[$item] - $lev : $lev; + } + } + + $threshold = 1e3; + $alternatives = array_filter($alternatives, static fn ($lev) => $lev < 2 * $threshold); + ksort($alternatives, \SORT_NATURAL | \SORT_FLAG_CASE); + + return array_keys($alternatives); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('class')) { + $suggestions->suggestValues(array_merge($this->getCoreTypes(), $this->types)); + + return; + } + + if ($input->mustSuggestArgumentValuesFor('option') && null !== $class = $input->getArgument('class')) { + $this->completeOptions($class, $suggestions); + + return; + } + + if ($input->mustSuggestOptionValuesFor('format')) { + $suggestions->suggestValues($this->getAvailableFormatOptions()); + } + } + + private function completeOptions(string $class, CompletionSuggestions $suggestions): void + { + if (!class_exists($class) || !is_subclass_of($class, FormTypeInterface::class)) { + $classes = $this->getFqcnTypeClasses($class); + + if (1 === \count($classes)) { + $class = $classes[0]; + } + } + + if (!$this->formRegistry->hasType($class)) { + return; + } + + $resolvedType = $this->formRegistry->getType($class); + $suggestions->suggestValues($resolvedType->getOptionsResolver()->getDefinedOptions()); + } + + private function getAvailableFormatOptions(): array + { + return (new DescriptorHelper())->getFormats(); + } +} diff --git a/lib/symfony/form/Console/Descriptor/Descriptor.php b/lib/symfony/form/Console/Descriptor/Descriptor.php new file mode 100644 index 000000000..b8d0399ee --- /dev/null +++ b/lib/symfony/form/Console/Descriptor/Descriptor.php @@ -0,0 +1,195 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Console\Descriptor; + +use Symfony\Component\Console\Descriptor\DescriptorInterface; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\OutputStyle; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Form\ResolvedFormTypeInterface; +use Symfony\Component\Form\Util\OptionsResolverWrapper; +use Symfony\Component\OptionsResolver\Debug\OptionsResolverIntrospector; +use Symfony\Component\OptionsResolver\Exception\NoConfigurationException; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Yonel Ceruto + * + * @internal + */ +abstract class Descriptor implements DescriptorInterface +{ + protected OutputStyle $output; + protected array $ownOptions = []; + protected array $overriddenOptions = []; + protected array $parentOptions = []; + protected array $extensionOptions = []; + protected array $requiredOptions = []; + protected array $parents = []; + protected array $extensions = []; + + public function describe(OutputInterface $output, ?object $object, array $options = []): void + { + $this->output = $output instanceof OutputStyle ? $output : new SymfonyStyle(new ArrayInput([]), $output); + + match (true) { + null === $object => $this->describeDefaults($options), + $object instanceof ResolvedFormTypeInterface => $this->describeResolvedFormType($object, $options), + $object instanceof OptionsResolver => $this->describeOption($object, $options), + default => throw new \InvalidArgumentException(sprintf('Object of type "%s" is not describable.', get_debug_type($object))), + }; + } + + abstract protected function describeDefaults(array $options): void; + + abstract protected function describeResolvedFormType(ResolvedFormTypeInterface $resolvedFormType, array $options = []): void; + + abstract protected function describeOption(OptionsResolver $optionsResolver, array $options): void; + + protected function collectOptions(ResolvedFormTypeInterface $type): void + { + $this->parents = []; + $this->extensions = []; + + if (null !== $type->getParent()) { + $optionsResolver = clone $this->getParentOptionsResolver($type->getParent()); + } else { + $optionsResolver = new OptionsResolver(); + } + + $type->getInnerType()->configureOptions($ownOptionsResolver = new OptionsResolverWrapper()); + $this->ownOptions = array_diff($ownOptionsResolver->getDefinedOptions(), $optionsResolver->getDefinedOptions()); + $overriddenOptions = array_intersect(array_merge($ownOptionsResolver->getDefinedOptions(), $ownOptionsResolver->getUndefinedOptions()), $optionsResolver->getDefinedOptions()); + + $this->parentOptions = []; + foreach ($this->parents as $class => $parentOptions) { + $this->overriddenOptions[$class] = array_intersect($overriddenOptions, $parentOptions); + $this->parentOptions[$class] = array_diff($parentOptions, $overriddenOptions); + } + + $type->getInnerType()->configureOptions($optionsResolver); + $this->collectTypeExtensionsOptions($type, $optionsResolver); + $this->extensionOptions = []; + foreach ($this->extensions as $class => $extensionOptions) { + $this->overriddenOptions[$class] = array_intersect($overriddenOptions, $extensionOptions); + $this->extensionOptions[$class] = array_diff($extensionOptions, $overriddenOptions); + } + + $this->overriddenOptions = array_filter($this->overriddenOptions); + $this->parentOptions = array_filter($this->parentOptions); + $this->extensionOptions = array_filter($this->extensionOptions); + $this->requiredOptions = $optionsResolver->getRequiredOptions(); + + $this->parents = array_keys($this->parents); + $this->extensions = array_keys($this->extensions); + } + + protected function getOptionDefinition(OptionsResolver $optionsResolver, string $option): array + { + $definition = []; + + if ($info = $optionsResolver->getInfo($option)) { + $definition = [ + 'info' => $info, + ]; + } + + $definition += [ + 'required' => $optionsResolver->isRequired($option), + 'deprecated' => $optionsResolver->isDeprecated($option), + ]; + + $introspector = new OptionsResolverIntrospector($optionsResolver); + + $map = [ + 'default' => 'getDefault', + 'lazy' => 'getLazyClosures', + 'allowedTypes' => 'getAllowedTypes', + 'allowedValues' => 'getAllowedValues', + 'normalizers' => 'getNormalizers', + 'deprecation' => 'getDeprecation', + ]; + + foreach ($map as $key => $method) { + try { + $definition[$key] = $introspector->{$method}($option); + } catch (NoConfigurationException) { + // noop + } + } + + if (isset($definition['deprecation']) && isset($definition['deprecation']['message']) && \is_string($definition['deprecation']['message'])) { + $definition['deprecationMessage'] = strtr($definition['deprecation']['message'], ['%name%' => $option]); + $definition['deprecationPackage'] = $definition['deprecation']['package']; + $definition['deprecationVersion'] = $definition['deprecation']['version']; + } + + return $definition; + } + + protected function filterOptionsByDeprecated(ResolvedFormTypeInterface $type): void + { + $deprecatedOptions = []; + $resolver = $type->getOptionsResolver(); + foreach ($resolver->getDefinedOptions() as $option) { + if ($resolver->isDeprecated($option)) { + $deprecatedOptions[] = $option; + } + } + + $filterByDeprecated = static function (array $options) use ($deprecatedOptions) { + foreach ($options as $class => $opts) { + if ($deprecated = array_intersect($deprecatedOptions, $opts)) { + $options[$class] = $deprecated; + } else { + unset($options[$class]); + } + } + + return $options; + }; + + $this->ownOptions = array_intersect($deprecatedOptions, $this->ownOptions); + $this->overriddenOptions = $filterByDeprecated($this->overriddenOptions); + $this->parentOptions = $filterByDeprecated($this->parentOptions); + $this->extensionOptions = $filterByDeprecated($this->extensionOptions); + } + + private function getParentOptionsResolver(ResolvedFormTypeInterface $type): OptionsResolver + { + $this->parents[$class = $type->getInnerType()::class] = []; + + if (null !== $type->getParent()) { + $optionsResolver = clone $this->getParentOptionsResolver($type->getParent()); + } else { + $optionsResolver = new OptionsResolver(); + } + + $inheritedOptions = $optionsResolver->getDefinedOptions(); + $type->getInnerType()->configureOptions($optionsResolver); + $this->parents[$class] = array_diff($optionsResolver->getDefinedOptions(), $inheritedOptions); + + $this->collectTypeExtensionsOptions($type, $optionsResolver); + + return $optionsResolver; + } + + private function collectTypeExtensionsOptions(ResolvedFormTypeInterface $type, OptionsResolver $optionsResolver): void + { + foreach ($type->getTypeExtensions() as $extension) { + $inheritedOptions = $optionsResolver->getDefinedOptions(); + $extension->configureOptions($optionsResolver); + $this->extensions[$extension::class] = array_diff($optionsResolver->getDefinedOptions(), $inheritedOptions); + } + } +} diff --git a/lib/symfony/form/Console/Descriptor/JsonDescriptor.php b/lib/symfony/form/Console/Descriptor/JsonDescriptor.php new file mode 100644 index 000000000..1f5c7bfa5 --- /dev/null +++ b/lib/symfony/form/Console/Descriptor/JsonDescriptor.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Console\Descriptor; + +use Symfony\Component\Form\ResolvedFormTypeInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Yonel Ceruto + * + * @internal + */ +class JsonDescriptor extends Descriptor +{ + protected function describeDefaults(array $options): void + { + $data['builtin_form_types'] = $options['core_types']; + $data['service_form_types'] = $options['service_types']; + if (!$options['show_deprecated']) { + $data['type_extensions'] = $options['extensions']; + $data['type_guessers'] = $options['guessers']; + } + + $this->writeData($data, $options); + } + + protected function describeResolvedFormType(ResolvedFormTypeInterface $resolvedFormType, array $options = []): void + { + $this->collectOptions($resolvedFormType); + + if ($options['show_deprecated']) { + $this->filterOptionsByDeprecated($resolvedFormType); + } + + $formOptions = [ + 'own' => $this->ownOptions, + 'overridden' => $this->overriddenOptions, + 'parent' => $this->parentOptions, + 'extension' => $this->extensionOptions, + 'required' => $this->requiredOptions, + ]; + $this->sortOptions($formOptions); + + $data = [ + 'class' => $resolvedFormType->getInnerType()::class, + 'block_prefix' => $resolvedFormType->getInnerType()->getBlockPrefix(), + 'options' => $formOptions, + 'parent_types' => $this->parents, + 'type_extensions' => $this->extensions, + ]; + + $this->writeData($data, $options); + } + + protected function describeOption(OptionsResolver $optionsResolver, array $options): void + { + $definition = $this->getOptionDefinition($optionsResolver, $options['option']); + + $map = []; + if ($definition['deprecated']) { + $map['deprecated'] = 'deprecated'; + if (\is_string($definition['deprecationMessage'])) { + $map['deprecation_message'] = 'deprecationMessage'; + } + } + $map += [ + 'info' => 'info', + 'required' => 'required', + 'default' => 'default', + 'allowed_types' => 'allowedTypes', + 'allowed_values' => 'allowedValues', + ]; + foreach ($map as $label => $name) { + if (\array_key_exists($name, $definition)) { + $data[$label] = $definition[$name]; + + if ('default' === $name) { + $data['is_lazy'] = isset($definition['lazy']); + } + } + } + $data['has_normalizer'] = isset($definition['normalizers']); + + $this->writeData($data, $options); + } + + private function writeData(array $data, array $options): void + { + $flags = $options['json_encoding'] ?? 0; + + $this->output->write(json_encode($data, $flags | \JSON_PRETTY_PRINT)."\n"); + } + + private function sortOptions(array &$options): void + { + foreach ($options as &$opts) { + $sorted = false; + foreach ($opts as &$opt) { + if (\is_array($opt)) { + sort($opt); + $sorted = true; + } + } + if (!$sorted) { + sort($opts); + } + } + } +} diff --git a/lib/symfony/form/Console/Descriptor/TextDescriptor.php b/lib/symfony/form/Console/Descriptor/TextDescriptor.php new file mode 100644 index 000000000..c57a5a7c2 --- /dev/null +++ b/lib/symfony/form/Console/Descriptor/TextDescriptor.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Console\Descriptor; + +use Symfony\Component\Console\Helper\Dumper; +use Symfony\Component\Console\Helper\TableSeparator; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\Form\ResolvedFormTypeInterface; +use Symfony\Component\HttpKernel\Debug\FileLinkFormatter as LegacyFileLinkFormatter; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Yonel Ceruto + * + * @internal + */ +class TextDescriptor extends Descriptor +{ + private FileLinkFormatter|LegacyFileLinkFormatter|null $fileLinkFormatter; + + public function __construct(FileLinkFormatter|LegacyFileLinkFormatter|null $fileLinkFormatter = null) + { + $this->fileLinkFormatter = $fileLinkFormatter; + } + + protected function describeDefaults(array $options): void + { + if ($options['core_types']) { + $this->output->section('Built-in form types (Symfony\Component\Form\Extension\Core\Type)'); + $shortClassNames = array_map(fn ($fqcn) => $this->formatClassLink($fqcn, \array_slice(explode('\\', $fqcn), -1)[0]), $options['core_types']); + for ($i = 0, $loopsMax = \count($shortClassNames); $i * 5 < $loopsMax; ++$i) { + $this->output->writeln(' '.implode(', ', \array_slice($shortClassNames, $i * 5, 5))); + } + } + + if ($options['service_types']) { + $this->output->section('Service form types'); + $this->output->listing(array_map($this->formatClassLink(...), $options['service_types'])); + } + + if (!$options['show_deprecated']) { + if ($options['extensions']) { + $this->output->section('Type extensions'); + $this->output->listing(array_map($this->formatClassLink(...), $options['extensions'])); + } + + if ($options['guessers']) { + $this->output->section('Type guessers'); + $this->output->listing(array_map($this->formatClassLink(...), $options['guessers'])); + } + } + } + + protected function describeResolvedFormType(ResolvedFormTypeInterface $resolvedFormType, array $options = []): void + { + $this->collectOptions($resolvedFormType); + + if ($options['show_deprecated']) { + $this->filterOptionsByDeprecated($resolvedFormType); + } + + $formOptions = $this->normalizeAndSortOptionsColumns(array_filter([ + 'own' => $this->ownOptions, + 'overridden' => $this->overriddenOptions, + 'parent' => $this->parentOptions, + 'extension' => $this->extensionOptions, + ])); + + // setting headers and column order + $tableHeaders = array_intersect_key([ + 'own' => 'Options', + 'overridden' => 'Overridden options', + 'parent' => 'Parent options', + 'extension' => 'Extension options', + ], $formOptions); + + $this->output->title(sprintf('%s (Block prefix: "%s")', $resolvedFormType->getInnerType()::class, $resolvedFormType->getInnerType()->getBlockPrefix())); + + if ($formOptions) { + $this->output->table($tableHeaders, $this->buildTableRows($tableHeaders, $formOptions)); + } + + if ($this->parents) { + $this->output->section('Parent types'); + $this->output->listing(array_map($this->formatClassLink(...), $this->parents)); + } + + if ($this->extensions) { + $this->output->section('Type extensions'); + $this->output->listing(array_map($this->formatClassLink(...), $this->extensions)); + } + } + + protected function describeOption(OptionsResolver $optionsResolver, array $options): void + { + $definition = $this->getOptionDefinition($optionsResolver, $options['option']); + + $dump = new Dumper($this->output); + $map = []; + if ($definition['deprecated']) { + $map = [ + 'Deprecated' => 'deprecated', + 'Deprecation package' => 'deprecationPackage', + 'Deprecation version' => 'deprecationVersion', + 'Deprecation message' => 'deprecationMessage', + ]; + } + $map += [ + 'Info' => 'info', + 'Required' => 'required', + 'Default' => 'default', + 'Allowed types' => 'allowedTypes', + 'Allowed values' => 'allowedValues', + 'Normalizers' => 'normalizers', + ]; + $rows = []; + foreach ($map as $label => $name) { + $value = \array_key_exists($name, $definition) ? $dump($definition[$name]) : '-'; + if ('default' === $name && isset($definition['lazy'])) { + $value = "Value: $value\n\nClosure(s): ".$dump($definition['lazy']); + } + + $rows[] = ["$label", $value]; + $rows[] = new TableSeparator(); + } + array_pop($rows); + + $this->output->title(sprintf('%s (%s)', $options['type']::class, $options['option'])); + $this->output->table([], $rows); + } + + private function buildTableRows(array $headers, array $options): array + { + $tableRows = []; + $count = \count(max($options)); + for ($i = 0; $i < $count; ++$i) { + $cells = []; + foreach (array_keys($headers) as $group) { + $option = $options[$group][$i] ?? null; + if (\is_string($option) && \in_array($option, $this->requiredOptions, true)) { + $option .= ' (required)'; + } + $cells[] = $option; + } + $tableRows[] = $cells; + } + + return $tableRows; + } + + private function normalizeAndSortOptionsColumns(array $options): array + { + foreach ($options as $group => $opts) { + $sorted = false; + foreach ($opts as $class => $opt) { + if (\is_string($class)) { + unset($options[$group][$class]); + } + + if (!\is_array($opt) || 0 === \count($opt)) { + continue; + } + + if (!$sorted) { + $options[$group] = []; + } else { + $options[$group][] = null; + } + $options[$group][] = sprintf('%s', (new \ReflectionClass($class))->getShortName()); + $options[$group][] = new TableSeparator(); + + sort($opt); + $sorted = true; + $options[$group] = array_merge($options[$group], $opt); + } + + if (!$sorted) { + sort($options[$group]); + } + } + + return $options; + } + + private function formatClassLink(string $class, ?string $text = null): string + { + $text ??= $class; + + if ('' === $fileLink = $this->getFileLink($class)) { + return $text; + } + + return sprintf('%s', $fileLink, $text); + } + + private function getFileLink(string $class): string + { + if (null === $this->fileLinkFormatter) { + return ''; + } + + try { + $r = new \ReflectionClass($class); + } catch (\ReflectionException) { + return ''; + } + + return (string) $this->fileLinkFormatter->format($r->getFileName(), $r->getStartLine()); + } +} diff --git a/lib/symfony/form/Console/Helper/DescriptorHelper.php b/lib/symfony/form/Console/Helper/DescriptorHelper.php new file mode 100644 index 000000000..8f782ca6b --- /dev/null +++ b/lib/symfony/form/Console/Helper/DescriptorHelper.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\Console\Helper; + +use Symfony\Component\Console\Helper\DescriptorHelper as BaseDescriptorHelper; +use Symfony\Component\ErrorHandler\ErrorRenderer\FileLinkFormatter; +use Symfony\Component\Form\Console\Descriptor\JsonDescriptor; +use Symfony\Component\Form\Console\Descriptor\TextDescriptor; +use Symfony\Component\HttpKernel\Debug\FileLinkFormatter as LegacyFileLinkFormatter; + +/** + * @author Yonel Ceruto + * + * @internal + */ +class DescriptorHelper extends BaseDescriptorHelper +{ + public function __construct(FileLinkFormatter|LegacyFileLinkFormatter|null $fileLinkFormatter = null) + { + $this + ->register('txt', new TextDescriptor($fileLinkFormatter)) + ->register('json', new JsonDescriptor()) + ; + } +} diff --git a/lib/symfony/form/DataAccessorInterface.php b/lib/symfony/form/DataAccessorInterface.php new file mode 100644 index 000000000..a0aea7e0e --- /dev/null +++ b/lib/symfony/form/DataAccessorInterface.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * Writes and reads values to/from an object or array bound to a form. + * + * @author Yonel Ceruto + */ +interface DataAccessorInterface +{ + /** + * Returns the value at the end of the property of the object graph. + * + * @throws Exception\AccessException If unable to read from the given form data + */ + public function getValue(object|array $viewData, FormInterface $form): mixed; + + /** + * Sets the value at the end of the property of the object graph. + * + * @throws Exception\AccessException If unable to write the given value + */ + public function setValue(object|array &$viewData, mixed $value, FormInterface $form): void; + + /** + * Returns whether a value can be read from an object graph. + * + * Whenever this method returns true, {@link getValue()} is guaranteed not + * to throw an exception when called with the same arguments. + */ + public function isReadable(object|array $viewData, FormInterface $form): bool; + + /** + * Returns whether a value can be written at a given object graph. + * + * Whenever this method returns true, {@link setValue()} is guaranteed not + * to throw an exception when called with the same arguments. + */ + public function isWritable(object|array $viewData, FormInterface $form): bool; +} diff --git a/lib/symfony/form/DataMapperInterface.php b/lib/symfony/form/DataMapperInterface.php new file mode 100644 index 000000000..f04137aec --- /dev/null +++ b/lib/symfony/form/DataMapperInterface.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * @author Bernhard Schussek + */ +interface DataMapperInterface +{ + /** + * Maps the view data of a compound form to its children. + * + * The method is responsible for calling {@link FormInterface::setData()} + * on the children of compound forms, defining their underlying model data. + * + * @param mixed $viewData View data of the compound form being initialized + * @param \Traversable $forms A list of {@link FormInterface} instances + * + * @return void + * + * @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported + */ + public function mapDataToForms(mixed $viewData, \Traversable $forms); + + /** + * Maps the model data of a list of children forms into the view data of their parent. + * + * This is the internal cascade call of FormInterface::submit for compound forms, since they + * cannot be bound to any input nor the request as scalar, but their children may: + * + * $compoundForm->submit($arrayOfChildrenViewData) + * // inside: + * $childForm->submit($childViewData); + * // for each entry, do the same and/or reverse transform + * $this->dataMapper->mapFormsToData($compoundForm, $compoundInitialViewData) + * // then reverse transform + * + * When a simple form is submitted the following is happening: + * + * $simpleForm->submit($submittedViewData) + * // inside: + * $this->viewData = $submittedViewData + * // then reverse transform + * + * The model data can be an array or an object, so this second argument is always passed + * by reference. + * + * @param \Traversable $forms A list of {@link FormInterface} instances + * @param mixed &$viewData The compound form's view data that get mapped + * its children model data + * + * @return void + * + * @throws Exception\UnexpectedTypeException if the type of the data parameter is not supported + */ + public function mapFormsToData(\Traversable $forms, mixed &$viewData); +} diff --git a/lib/symfony/form/DataTransformerInterface.php b/lib/symfony/form/DataTransformerInterface.php new file mode 100644 index 000000000..85fb99d21 --- /dev/null +++ b/lib/symfony/form/DataTransformerInterface.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms a value between different representations. + * + * @author Bernhard Schussek + * + * @template TValue + * @template TTransformedValue + */ +interface DataTransformerInterface +{ + /** + * Transforms a value from the original representation to a transformed representation. + * + * This method is called when the form field is initialized with its default data, on + * two occasions for two types of transformers: + * + * 1. Model transformers which normalize the model data. + * This is mainly useful when the same form type (the same configuration) + * has to handle different kind of underlying data, e.g The DateType can + * deal with strings or \DateTime objects as input. + * + * 2. View transformers which adapt the normalized data to the view format. + * a/ When the form is simple, the value returned by convention is used + * directly in the view and thus can only be a string or an array. In + * this case the data class should be null. + * + * b/ When the form is compound the returned value should be an array or + * an object to be mapped to the children. Each property of the compound + * data will be used as model data by each child and will be transformed + * too. In this case data class should be the class of the object, or null + * when it is an array. + * + * All transformers are called in a configured order from model data to view value. + * At the end of this chain the view data will be validated against the data class + * setting. + * + * This method must be able to deal with empty values. Usually this will + * be NULL, but depending on your implementation other empty values are + * possible as well (such as empty strings). The reasoning behind this is + * that data transformers must be chainable. If the transform() method + * of the first data transformer outputs NULL, the second must be able to + * process that value. + * + * @param TValue|null $value The value in the original representation + * + * @return mixed + * + * @psalm-return TTransformedValue|null + * + * @throws TransformationFailedException when the transformation fails + */ + public function transform(mixed $value); + + /** + * Transforms a value from the transformed representation to its original + * representation. + * + * This method is called when {@link Form::submit()} is called to transform the requests tainted data + * into an acceptable format. + * + * The same transformers are called in the reverse order so the responsibility is to + * return one of the types that would be expected as input of transform(). + * + * This method must be able to deal with empty values. Usually this will + * be an empty string, but depending on your implementation other empty + * values are possible as well (such as NULL). The reasoning behind + * this is that value transformers must be chainable. If the + * reverseTransform() method of the first value transformer outputs an + * empty string, the second value transformer must be able to process that + * value. + * + * By convention, reverseTransform() should return NULL if an empty string + * is passed. + * + * @param TTransformedValue|null $value The value in the transformed representation + * + * @return mixed + * + * @psalm-return TValue|null + * + * @throws TransformationFailedException when the transformation fails + */ + public function reverseTransform(mixed $value); +} diff --git a/lib/symfony/form/DependencyInjection/FormPass.php b/lib/symfony/form/DependencyInjection/FormPass.php new file mode 100644 index 000000000..efb6d5c8b --- /dev/null +++ b/lib/symfony/form/DependencyInjection/FormPass.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\DependencyInjection; + +use Symfony\Component\DependencyInjection\Argument\ArgumentInterface; +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Adds all services with the tags "form.type", "form.type_extension" and + * "form.type_guesser" as arguments of the "form.extension" service. + * + * @author Bernhard Schussek + */ +class FormPass implements CompilerPassInterface +{ + use PriorityTaggedServiceTrait; + + /** + * @return void + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition('form.extension')) { + return; + } + + $definition = $container->getDefinition('form.extension'); + $definition->replaceArgument(0, $this->processFormTypes($container)); + $definition->replaceArgument(1, $this->processFormTypeExtensions($container)); + $definition->replaceArgument(2, $this->processFormTypeGuessers($container)); + } + + private function processFormTypes(ContainerBuilder $container): Reference + { + // Get service locator argument + $servicesMap = []; + $namespaces = ['Symfony\Component\Form\Extension\Core\Type' => true]; + + // Builds an array with fully-qualified type class names as keys and service IDs as values + foreach ($container->findTaggedServiceIds('form.type', true) as $serviceId => $tag) { + // Add form type service to the service locator + $serviceDefinition = $container->getDefinition($serviceId); + $servicesMap[$formType = $serviceDefinition->getClass()] = new Reference($serviceId); + $namespaces[substr($formType, 0, strrpos($formType, '\\'))] = 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)); + } + + return ServiceLocatorTagPass::register($container, $servicesMap); + } + + private function processFormTypeExtensions(ContainerBuilder $container): array + { + $typeExtensions = []; + $typeExtensionsClasses = []; + foreach ($this->findAndSortTaggedServices('form.type_extension', $container) as $reference) { + $serviceId = (string) $reference; + $serviceDefinition = $container->getDefinition($serviceId); + + $tag = $serviceDefinition->getTag('form.type_extension'); + $typeExtensionClass = $container->getParameterBag()->resolveValue($serviceDefinition->getClass()); + + if (isset($tag[0]['extended_type'])) { + $typeExtensions[$tag[0]['extended_type']][] = new Reference($serviceId); + $typeExtensionsClasses[] = $typeExtensionClass; + } else { + $extendsTypes = false; + + $typeExtensionsClasses[] = $typeExtensionClass; + foreach ($typeExtensionClass::getExtendedTypes() as $extendedType) { + $typeExtensions[$extendedType][] = new Reference($serviceId); + $extendsTypes = true; + } + + if (!$extendsTypes) { + throw new InvalidArgumentException(sprintf('The getExtendedTypes() method for service "%s" does not return any extended types.', $serviceId)); + } + } + } + + foreach ($typeExtensions as $extendedType => $extensions) { + $typeExtensions[$extendedType] = new IteratorArgument($extensions); + } + + if ($container->hasDefinition('console.command.form_debug')) { + $commandDefinition = $container->getDefinition('console.command.form_debug'); + $commandDefinition->setArgument(3, $typeExtensionsClasses); + } + + return $typeExtensions; + } + + private function processFormTypeGuessers(ContainerBuilder $container): ArgumentInterface + { + $guessers = []; + $guessersClasses = []; + foreach ($container->findTaggedServiceIds('form.type_guesser', true) as $serviceId => $tags) { + $guessers[] = new Reference($serviceId); + + $serviceDefinition = $container->getDefinition($serviceId); + $guessersClasses[] = $serviceDefinition->getClass(); + } + + if ($container->hasDefinition('console.command.form_debug')) { + $commandDefinition = $container->getDefinition('console.command.form_debug'); + $commandDefinition->setArgument(4, $guessersClasses); + } + + return new IteratorArgument($guessers); + } +} diff --git a/lib/symfony/form/Event/PostSetDataEvent.php b/lib/symfony/form/Event/PostSetDataEvent.php new file mode 100644 index 000000000..7d551f8b5 --- /dev/null +++ b/lib/symfony/form/Event/PostSetDataEvent.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Event; + +use Symfony\Component\Form\FormEvent; + +/** + * This event is dispatched at the end of the Form::setData() method. + * + * It can be used to modify a form depending on the populated data (adding or + * removing fields dynamically). + */ +final class PostSetDataEvent extends FormEvent +{ + /** + * @deprecated since Symfony 6.4, it will throw an exception in 7.0. + */ + public function setData(mixed $data): void + { + trigger_deprecation('symfony/form', '6.4', 'Calling "%s()" will throw an exception as of 7.0, listen to "form.pre_set_data" instead.', __METHOD__); + // throw new BadMethodCallException('Form data cannot be changed during "form.post_set_data", you should use "form.pre_set_data" instead.'); + parent::setData($data); + } +} diff --git a/lib/symfony/form/Event/PostSubmitEvent.php b/lib/symfony/form/Event/PostSubmitEvent.php new file mode 100644 index 000000000..5ce6d8ecb --- /dev/null +++ b/lib/symfony/form/Event/PostSubmitEvent.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Event; + +use Symfony\Component\Form\FormEvent; + +/** + * This event is dispatched after the Form::submit() + * once the model and view data have been denormalized. + * + * It can be used to fetch data after denormalization. + */ +final class PostSubmitEvent extends FormEvent +{ + /** + * @deprecated since Symfony 6.4, it will throw an exception in 7.0. + */ + public function setData(mixed $data): void + { + trigger_deprecation('symfony/form', '6.4', 'Calling "%s()" will throw an exception as of 7.0, listen to "form.pre_submit" or "form.submit" instead.', __METHOD__); + // throw new BadMethodCallException('Form data cannot be changed during "form.post_submit", you should use "form.pre_submit" or "form.submit" instead.'); + parent::setData($data); + } +} diff --git a/lib/symfony/form/Event/PreSetDataEvent.php b/lib/symfony/form/Event/PreSetDataEvent.php new file mode 100644 index 000000000..2644fda19 --- /dev/null +++ b/lib/symfony/form/Event/PreSetDataEvent.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\Form\Event; + +use Symfony\Component\Form\FormEvent; + +/** + * This event is dispatched at the beginning of the Form::setData() method. + * + * It can be used to modify the data given during pre-population. + */ +final class PreSetDataEvent extends FormEvent +{ +} diff --git a/lib/symfony/form/Event/PreSubmitEvent.php b/lib/symfony/form/Event/PreSubmitEvent.php new file mode 100644 index 000000000..a72ac5d16 --- /dev/null +++ b/lib/symfony/form/Event/PreSubmitEvent.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Event; + +use Symfony\Component\Form\FormEvent; + +/** + * This event is dispatched at the beginning of the Form::submit() method. + * + * It can be used to: + * - Change data from the request, before submitting the data to the form. + * - Add or remove form fields, before submitting the data to the form. + */ +final class PreSubmitEvent extends FormEvent +{ +} diff --git a/lib/symfony/form/Event/SubmitEvent.php b/lib/symfony/form/Event/SubmitEvent.php new file mode 100644 index 000000000..71d3b06d4 --- /dev/null +++ b/lib/symfony/form/Event/SubmitEvent.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Event; + +use Symfony\Component\Form\FormEvent; + +/** + * This event is dispatched just before the Form::submit() method + * transforms back the normalized data to the model and view data. + * + * It can be used to change data from the normalized representation of the data. + */ +final class SubmitEvent extends FormEvent +{ +} diff --git a/lib/symfony/form/Exception/AccessException.php b/lib/symfony/form/Exception/AccessException.php new file mode 100644 index 000000000..ac712cc3d --- /dev/null +++ b/lib/symfony/form/Exception/AccessException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +class AccessException extends RuntimeException +{ +} diff --git a/lib/symfony/form/Exception/AlreadySubmittedException.php b/lib/symfony/form/Exception/AlreadySubmittedException.php new file mode 100644 index 000000000..5e8c30526 --- /dev/null +++ b/lib/symfony/form/Exception/AlreadySubmittedException.php @@ -0,0 +1,22 @@ + + * + * 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 operation is called that is not acceptable after submitting + * a form. + * + * @author Bernhard Schussek + */ +class AlreadySubmittedException extends LogicException +{ +} diff --git a/lib/symfony/form/Exception/BadMethodCallException.php b/lib/symfony/form/Exception/BadMethodCallException.php new file mode 100644 index 000000000..27649dd02 --- /dev/null +++ b/lib/symfony/form/Exception/BadMethodCallException.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; + +/** + * Base BadMethodCallException for the Form component. + * + * @author Bernhard Schussek + */ +class BadMethodCallException extends \BadMethodCallException implements ExceptionInterface +{ +} diff --git a/lib/symfony/form/Exception/ErrorMappingException.php b/lib/symfony/form/Exception/ErrorMappingException.php new file mode 100644 index 000000000..a69684926 --- /dev/null +++ b/lib/symfony/form/Exception/ErrorMappingException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +class ErrorMappingException extends RuntimeException +{ +} diff --git a/lib/symfony/form/Exception/ExceptionInterface.php b/lib/symfony/form/Exception/ExceptionInterface.php new file mode 100644 index 000000000..69145f0bc --- /dev/null +++ b/lib/symfony/form/Exception/ExceptionInterface.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; + +/** + * Base ExceptionInterface for the Form component. + * + * @author Bernhard Schussek + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/lib/symfony/form/Exception/InvalidArgumentException.php b/lib/symfony/form/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..a270e0ce9 --- /dev/null +++ b/lib/symfony/form/Exception/InvalidArgumentException.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; + +/** + * Base InvalidArgumentException for the Form component. + * + * @author Bernhard Schussek + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/lib/symfony/form/Exception/InvalidConfigurationException.php b/lib/symfony/form/Exception/InvalidConfigurationException.php new file mode 100644 index 000000000..daa0c42f5 --- /dev/null +++ b/lib/symfony/form/Exception/InvalidConfigurationException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +class InvalidConfigurationException extends InvalidArgumentException +{ +} diff --git a/lib/symfony/form/Exception/LogicException.php b/lib/symfony/form/Exception/LogicException.php new file mode 100644 index 000000000..848780215 --- /dev/null +++ b/lib/symfony/form/Exception/LogicException.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; + +/** + * Base LogicException for Form component. + * + * @author Alexander Kotynia + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/lib/symfony/form/Exception/OutOfBoundsException.php b/lib/symfony/form/Exception/OutOfBoundsException.php new file mode 100644 index 000000000..44d311663 --- /dev/null +++ b/lib/symfony/form/Exception/OutOfBoundsException.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; + +/** + * Base OutOfBoundsException for Form component. + * + * @author Alexander Kotynia + */ +class OutOfBoundsException extends \OutOfBoundsException implements ExceptionInterface +{ +} diff --git a/lib/symfony/form/Exception/RuntimeException.php b/lib/symfony/form/Exception/RuntimeException.php new file mode 100644 index 000000000..0af48a4a2 --- /dev/null +++ b/lib/symfony/form/Exception/RuntimeException.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; + +/** + * Base RuntimeException for the Form component. + * + * @author Bernhard Schussek + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/lib/symfony/form/Exception/StringCastException.php b/lib/symfony/form/Exception/StringCastException.php new file mode 100644 index 000000000..f9b51d604 --- /dev/null +++ b/lib/symfony/form/Exception/StringCastException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +class StringCastException extends RuntimeException +{ +} diff --git a/lib/symfony/form/Exception/TransformationFailedException.php b/lib/symfony/form/Exception/TransformationFailedException.php new file mode 100644 index 000000000..8388a0ba6 --- /dev/null +++ b/lib/symfony/form/Exception/TransformationFailedException.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +/** + * Indicates a value transformation error. + * + * @author Bernhard Schussek + */ +class TransformationFailedException extends RuntimeException +{ + private ?string $invalidMessage; + private array $invalidMessageParameters; + + public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, ?string $invalidMessage = null, array $invalidMessageParameters = []) + { + parent::__construct($message, $code, $previous); + + $this->setInvalidMessage($invalidMessage, $invalidMessageParameters); + } + + /** + * Sets the message that will be shown to the user. + * + * @param string|null $invalidMessage The message or message key + * @param array $invalidMessageParameters Data to be passed into the translator + */ + public function setInvalidMessage(?string $invalidMessage = null, array $invalidMessageParameters = []): void + { + if (1 > \func_num_args()) { + trigger_deprecation('symfony/form', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); + } + $this->invalidMessage = $invalidMessage; + $this->invalidMessageParameters = $invalidMessageParameters; + } + + public function getInvalidMessage(): ?string + { + return $this->invalidMessage; + } + + public function getInvalidMessageParameters(): array + { + return $this->invalidMessageParameters; + } +} diff --git a/lib/symfony/form/Exception/UnexpectedTypeException.php b/lib/symfony/form/Exception/UnexpectedTypeException.php new file mode 100644 index 000000000..7a4dc295c --- /dev/null +++ b/lib/symfony/form/Exception/UnexpectedTypeException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +class UnexpectedTypeException extends InvalidArgumentException +{ + public function __construct(mixed $value, string $expectedType) + { + parent::__construct(sprintf('Expected argument of type "%s", "%s" given', $expectedType, get_debug_type($value))); + } +} diff --git a/lib/symfony/form/Extension/Core/CoreExtension.php b/lib/symfony/form/Extension/Core/CoreExtension.php new file mode 100644 index 000000000..d6c3ff080 --- /dev/null +++ b/lib/symfony/form/Extension/Core/CoreExtension.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core; + +use Symfony\Component\Form\AbstractExtension; +use Symfony\Component\Form\ChoiceList\Factory\CachingFactoryDecorator; +use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface; +use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory; +use Symfony\Component\Form\ChoiceList\Factory\PropertyAccessDecorator; +use Symfony\Component\Form\Extension\Core\Type\TransformationFailureExtension; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Represents the main form extension, which loads the core functionality. + * + * @author Bernhard Schussek + */ +class CoreExtension extends AbstractExtension +{ + private PropertyAccessorInterface $propertyAccessor; + private ChoiceListFactoryInterface $choiceListFactory; + private ?TranslatorInterface $translator; + + public function __construct(?PropertyAccessorInterface $propertyAccessor = null, ?ChoiceListFactoryInterface $choiceListFactory = null, ?TranslatorInterface $translator = null) + { + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + $this->choiceListFactory = $choiceListFactory ?? new CachingFactoryDecorator(new PropertyAccessDecorator(new DefaultChoiceListFactory(), $this->propertyAccessor)); + $this->translator = $translator; + } + + protected function loadTypes(): array + { + return [ + new Type\FormType($this->propertyAccessor), + new Type\BirthdayType(), + new Type\CheckboxType(), + new Type\ChoiceType($this->choiceListFactory, $this->translator), + new Type\CollectionType(), + new Type\CountryType(), + new Type\DateIntervalType(), + new Type\DateType(), + new Type\DateTimeType(), + new Type\EmailType(), + new Type\HiddenType(), + new Type\IntegerType(), + new Type\LanguageType(), + new Type\LocaleType(), + new Type\MoneyType(), + new Type\NumberType(), + new Type\PasswordType(), + new Type\PercentType(), + new Type\RadioType(), + new Type\RangeType(), + new Type\RepeatedType(), + new Type\SearchType(), + new Type\TextareaType(), + new Type\TextType(), + new Type\TimeType(), + new Type\TimezoneType(), + new Type\UrlType(), + new Type\FileType($this->translator), + new Type\ButtonType(), + new Type\SubmitType(), + new Type\ResetType(), + new Type\CurrencyType(), + new Type\TelType(), + new Type\ColorType($this->translator), + new Type\WeekType(), + ]; + } + + protected function loadTypeExtensions(): array + { + return [ + new TransformationFailureExtension($this->translator), + ]; + } +} diff --git a/lib/symfony/form/Extension/Core/DataAccessor/CallbackAccessor.php b/lib/symfony/form/Extension/Core/DataAccessor/CallbackAccessor.php new file mode 100644 index 000000000..a7d5bb13f --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataAccessor/CallbackAccessor.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\Extension\Core\DataAccessor; + +use Symfony\Component\Form\DataAccessorInterface; +use Symfony\Component\Form\Exception\AccessException; +use Symfony\Component\Form\FormInterface; + +/** + * Writes and reads values to/from an object or array using callback functions. + * + * @author Yonel Ceruto + */ +class CallbackAccessor implements DataAccessorInterface +{ + public function getValue(object|array $data, FormInterface $form): mixed + { + if (null === $getter = $form->getConfig()->getOption('getter')) { + throw new AccessException('Unable to read from the given form data as no getter is defined.'); + } + + return ($getter)($data, $form); + } + + public function setValue(object|array &$data, mixed $value, FormInterface $form): void + { + if (null === $setter = $form->getConfig()->getOption('setter')) { + throw new AccessException('Unable to write the given value as no setter is defined.'); + } + + ($setter)($data, $form->getData(), $form); + } + + public function isReadable(object|array $data, FormInterface $form): bool + { + return null !== $form->getConfig()->getOption('getter'); + } + + public function isWritable(object|array $data, FormInterface $form): bool + { + return null !== $form->getConfig()->getOption('setter'); + } +} diff --git a/lib/symfony/form/Extension/Core/DataAccessor/ChainAccessor.php b/lib/symfony/form/Extension/Core/DataAccessor/ChainAccessor.php new file mode 100644 index 000000000..ac600f16f --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataAccessor/ChainAccessor.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataAccessor; + +use Symfony\Component\Form\DataAccessorInterface; +use Symfony\Component\Form\Exception\AccessException; +use Symfony\Component\Form\FormInterface; + +/** + * @author Yonel Ceruto + */ +class ChainAccessor implements DataAccessorInterface +{ + private iterable $accessors; + + /** + * @param DataAccessorInterface[]|iterable $accessors + */ + public function __construct(iterable $accessors) + { + $this->accessors = $accessors; + } + + public function getValue(object|array $data, FormInterface $form): mixed + { + foreach ($this->accessors as $accessor) { + if ($accessor->isReadable($data, $form)) { + return $accessor->getValue($data, $form); + } + } + + throw new AccessException('Unable to read from the given form data as no accessor in the chain is able to read the data.'); + } + + public function setValue(object|array &$data, mixed $value, FormInterface $form): void + { + foreach ($this->accessors as $accessor) { + if ($accessor->isWritable($data, $form)) { + $accessor->setValue($data, $value, $form); + + return; + } + } + + throw new AccessException('Unable to write the given value as no accessor in the chain is able to set the data.'); + } + + public function isReadable(object|array $data, FormInterface $form): bool + { + foreach ($this->accessors as $accessor) { + if ($accessor->isReadable($data, $form)) { + return true; + } + } + + return false; + } + + public function isWritable(object|array $data, FormInterface $form): bool + { + foreach ($this->accessors as $accessor) { + if ($accessor->isWritable($data, $form)) { + return true; + } + } + + return false; + } +} diff --git a/lib/symfony/form/Extension/Core/DataAccessor/PropertyPathAccessor.php b/lib/symfony/form/Extension/Core/DataAccessor/PropertyPathAccessor.php new file mode 100644 index 000000000..f5c25dfc1 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataAccessor/PropertyPathAccessor.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataAccessor; + +use Symfony\Component\Form\DataAccessorInterface; +use Symfony\Component\Form\DataMapperInterface; +use Symfony\Component\Form\Exception\AccessException; +use Symfony\Component\Form\Extension\Core\DataMapper\DataMapper; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\PropertyAccess\Exception\AccessException as PropertyAccessException; +use Symfony\Component\PropertyAccess\Exception\NoSuchIndexException; +use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * Writes and reads values to/from an object or array using property path. + * + * @author Yonel Ceruto + * @author Bernhard Schussek + */ +class PropertyPathAccessor implements DataAccessorInterface +{ + private PropertyAccessorInterface $propertyAccessor; + + public function __construct(?PropertyAccessorInterface $propertyAccessor = null) + { + $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); + } + + public function getValue(object|array $data, FormInterface $form): mixed + { + if (null === $propertyPath = $form->getPropertyPath()) { + throw new AccessException('Unable to read from the given form data as no property path is defined.'); + } + + return $this->getPropertyValue($data, $propertyPath); + } + + public function setValue(object|array &$data, mixed $value, FormInterface $form): void + { + if (null === $propertyPath = $form->getPropertyPath()) { + throw new AccessException('Unable to write the given value as no property path is defined.'); + } + + $getValue = function () use ($data, $form, $propertyPath) { + $dataMapper = $this->getDataMapper($form); + + if ($dataMapper instanceof DataMapper && null !== $dataAccessor = $dataMapper->getDataAccessor()) { + return $dataAccessor->getValue($data, $form); + } + + return $this->getPropertyValue($data, $propertyPath); + }; + + // If the field is of type DateTimeInterface and the data is the same skip the update to + // keep the original object hash + if ($value instanceof \DateTimeInterface && $value == $getValue()) { + return; + } + + // If the data is identical to the value in $data, we are + // dealing with a reference + if (!\is_object($data) || !$form->getConfig()->getByReference() || $value !== $getValue()) { + $this->propertyAccessor->setValue($data, $propertyPath, $value); + } + } + + public function isReadable(object|array $data, FormInterface $form): bool + { + return null !== $form->getPropertyPath(); + } + + public function isWritable(object|array $data, FormInterface $form): bool + { + return null !== $form->getPropertyPath(); + } + + private function getPropertyValue(object|array $data, PropertyPathInterface $propertyPath): mixed + { + try { + return $this->propertyAccessor->getValue($data, $propertyPath); + } catch (PropertyAccessException $e) { + if (\is_array($data) && $e instanceof NoSuchIndexException) { + return null; + } + + if (!$e instanceof UninitializedPropertyException + // For versions without UninitializedPropertyException check the exception message + && (class_exists(UninitializedPropertyException::class) || !str_contains($e->getMessage(), 'You should initialize it')) + ) { + throw $e; + } + + return null; + } + } + + private function getDataMapper(FormInterface $form): ?DataMapperInterface + { + do { + $dataMapper = $form->getConfig()->getDataMapper(); + } while (null === $dataMapper && null !== $form = $form->getParent()); + + return $dataMapper; + } +} diff --git a/lib/symfony/form/Extension/Core/DataMapper/CheckboxListMapper.php b/lib/symfony/form/Extension/Core/DataMapper/CheckboxListMapper.php new file mode 100644 index 000000000..119c81107 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataMapper/CheckboxListMapper.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataMapper; + +use Symfony\Component\Form\DataMapperInterface; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * Maps choices to/from checkbox forms. + * + * A {@link ChoiceListInterface} implementation is used to find the + * corresponding string values for the choices. Each checkbox form whose "value" + * option corresponds to any of the selected values is marked as selected. + * + * @author Bernhard Schussek + */ +class CheckboxListMapper implements DataMapperInterface +{ + /** + * @return void + */ + public function mapDataToForms(mixed $choices, \Traversable $checkboxes) + { + if (!\is_array($choices ??= [])) { + throw new UnexpectedTypeException($choices, 'array'); + } + + foreach ($checkboxes as $checkbox) { + $value = $checkbox->getConfig()->getOption('value'); + $checkbox->setData(\in_array($value, $choices, true)); + } + } + + /** + * @return void + */ + public function mapFormsToData(\Traversable $checkboxes, mixed &$choices) + { + if (!\is_array($choices)) { + throw new UnexpectedTypeException($choices, 'array'); + } + + $values = []; + + foreach ($checkboxes as $checkbox) { + if ($checkbox->getData()) { + // construct an array of choice values + $values[] = $checkbox->getConfig()->getOption('value'); + } + } + + $choices = $values; + } +} diff --git a/lib/symfony/form/Extension/Core/DataMapper/DataMapper.php b/lib/symfony/form/Extension/Core/DataMapper/DataMapper.php new file mode 100644 index 000000000..a7bf98032 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataMapper/DataMapper.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataMapper; + +use Symfony\Component\Form\DataAccessorInterface; +use Symfony\Component\Form\DataMapperInterface; +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\Extension\Core\DataAccessor\CallbackAccessor; +use Symfony\Component\Form\Extension\Core\DataAccessor\ChainAccessor; +use Symfony\Component\Form\Extension\Core\DataAccessor\PropertyPathAccessor; + +/** + * Maps arrays/objects to/from forms using data accessors. + * + * @author Bernhard Schussek + */ +class DataMapper implements DataMapperInterface +{ + private DataAccessorInterface $dataAccessor; + + public function __construct(?DataAccessorInterface $dataAccessor = null) + { + $this->dataAccessor = $dataAccessor ?? new ChainAccessor([ + new CallbackAccessor(), + new PropertyPathAccessor(), + ]); + } + + public function mapDataToForms(mixed $data, \Traversable $forms): void + { + $empty = null === $data || [] === $data; + + if (!$empty && !\is_array($data) && !\is_object($data)) { + throw new UnexpectedTypeException($data, 'object, array or empty'); + } + + foreach ($forms as $form) { + $config = $form->getConfig(); + + if (!$empty && $config->getMapped() && $this->dataAccessor->isReadable($data, $form)) { + $form->setData($this->dataAccessor->getValue($data, $form)); + } else { + $form->setData($config->getData()); + } + } + } + + public function mapFormsToData(\Traversable $forms, mixed &$data): void + { + if (null === $data) { + return; + } + + if (!\is_array($data) && !\is_object($data)) { + throw new UnexpectedTypeException($data, 'object, array or empty'); + } + + foreach ($forms as $form) { + $config = $form->getConfig(); + + // Write-back is disabled if the form is not synchronized (transformation failed), + // if the form was not submitted and if the form is disabled (modification not allowed) + if ($config->getMapped() && $form->isSubmitted() && $form->isSynchronized() && !$form->isDisabled() && $this->dataAccessor->isWritable($data, $form)) { + $this->dataAccessor->setValue($data, $form->getData(), $form); + } + } + } + + /** + * @internal + */ + public function getDataAccessor(): DataAccessorInterface + { + return $this->dataAccessor; + } +} diff --git a/lib/symfony/form/Extension/Core/DataMapper/RadioListMapper.php b/lib/symfony/form/Extension/Core/DataMapper/RadioListMapper.php new file mode 100644 index 000000000..37fdba0c3 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataMapper/RadioListMapper.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataMapper; + +use Symfony\Component\Form\DataMapperInterface; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * Maps choices to/from radio forms. + * + * A {@link ChoiceListInterface} implementation is used to find the + * corresponding string values for the choices. The radio form whose "value" + * option corresponds to the selected value is marked as selected. + * + * @author Bernhard Schussek + */ +class RadioListMapper implements DataMapperInterface +{ + /** + * @return void + */ + public function mapDataToForms(mixed $choice, \Traversable $radios) + { + if (!\is_string($choice)) { + throw new UnexpectedTypeException($choice, 'string'); + } + + foreach ($radios as $radio) { + $value = $radio->getConfig()->getOption('value'); + $radio->setData($choice === $value); + } + } + + /** + * @return void + */ + public function mapFormsToData(\Traversable $radios, mixed &$choice) + { + if (null !== $choice && !\is_string($choice)) { + throw new UnexpectedTypeException($choice, 'null or string'); + } + + $choice = null; + + foreach ($radios as $radio) { + if ($radio->getData()) { + if ('placeholder' === $radio->getName()) { + return; + } + + $choice = $radio->getConfig()->getOption('value'); + + return; + } + } + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/ArrayToPartsTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/ArrayToPartsTransformer.php new file mode 100644 index 000000000..9256c0a09 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/ArrayToPartsTransformer.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * @author Bernhard Schussek + * + * @implements DataTransformerInterface + */ +class ArrayToPartsTransformer implements DataTransformerInterface +{ + private array $partMapping; + + public function __construct(array $partMapping) + { + $this->partMapping = $partMapping; + } + + public function transform(mixed $array): mixed + { + if (!\is_array($array ??= [])) { + throw new TransformationFailedException('Expected an array.'); + } + + $result = []; + + foreach ($this->partMapping as $partKey => $originalKeys) { + if (!$array) { + $result[$partKey] = null; + } else { + $result[$partKey] = array_intersect_key($array, array_flip($originalKeys)); + } + } + + return $result; + } + + public function reverseTransform(mixed $array): mixed + { + if (!\is_array($array)) { + throw new TransformationFailedException('Expected an array.'); + } + + $result = []; + $emptyKeys = []; + + foreach ($this->partMapping as $partKey => $originalKeys) { + if (!empty($array[$partKey])) { + foreach ($originalKeys as $originalKey) { + if (isset($array[$partKey][$originalKey])) { + $result[$originalKey] = $array[$partKey][$originalKey]; + } + } + } else { + $emptyKeys[] = $partKey; + } + } + + if (\count($emptyKeys) > 0) { + if (\count($emptyKeys) === \count($this->partMapping)) { + // All parts empty + return null; + } + + throw new TransformationFailedException(sprintf('The keys "%s" should not be empty.', implode('", "', $emptyKeys))); + } + + return $result; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/BaseDateTimeTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/BaseDateTimeTransformer.php new file mode 100644 index 000000000..a432e43f1 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/BaseDateTimeTransformer.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\InvalidArgumentException; + +/** + * @template TTransformedValue + * + * @implements DataTransformerInterface<\DateTimeInterface, TTransformedValue> + */ +abstract class BaseDateTimeTransformer implements DataTransformerInterface +{ + protected static $formats = [ + \IntlDateFormatter::NONE, + \IntlDateFormatter::FULL, + \IntlDateFormatter::LONG, + \IntlDateFormatter::MEDIUM, + \IntlDateFormatter::SHORT, + ]; + + protected $inputTimezone; + + protected $outputTimezone; + + /** + * @param string|null $inputTimezone The name of the input timezone + * @param string|null $outputTimezone The name of the output timezone + * + * @throws InvalidArgumentException if a timezone is not valid + */ + public function __construct(?string $inputTimezone = null, ?string $outputTimezone = null) + { + $this->inputTimezone = $inputTimezone ?: date_default_timezone_get(); + $this->outputTimezone = $outputTimezone ?: date_default_timezone_get(); + + // Check if input and output timezones are valid + try { + new \DateTimeZone($this->inputTimezone); + } catch (\Exception $e) { + throw new InvalidArgumentException(sprintf('Input timezone is invalid: "%s".', $this->inputTimezone), $e->getCode(), $e); + } + + try { + new \DateTimeZone($this->outputTimezone); + } catch (\Exception $e) { + throw new InvalidArgumentException(sprintf('Output timezone is invalid: "%s".', $this->outputTimezone), $e->getCode(), $e); + } + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/BooleanToStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/BooleanToStringTransformer.php new file mode 100644 index 000000000..e91bdb4db --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/BooleanToStringTransformer.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a Boolean and a string. + * + * @author Bernhard Schussek + * @author Florian Eckerstorfer + * + * @implements DataTransformerInterface + */ +class BooleanToStringTransformer implements DataTransformerInterface +{ + private string $trueValue; + + private array $falseValues; + + /** + * @param string $trueValue The value emitted upon transform if the input is true + */ + public function __construct(string $trueValue, array $falseValues = [null]) + { + $this->trueValue = $trueValue; + $this->falseValues = $falseValues; + if (\in_array($this->trueValue, $this->falseValues, true)) { + throw new InvalidArgumentException('The specified "true" value is contained in the false-values.'); + } + } + + /** + * Transforms a Boolean into a string. + * + * @param bool $value Boolean value + * + * @throws TransformationFailedException if the given value is not a Boolean + */ + public function transform(mixed $value): ?string + { + if (null === $value) { + return null; + } + + if (!\is_bool($value)) { + throw new TransformationFailedException('Expected a Boolean.'); + } + + return $value ? $this->trueValue : null; + } + + /** + * Transforms a string into a Boolean. + * + * @param string $value String value + * + * @throws TransformationFailedException if the given value is not a string + */ + public function reverseTransform(mixed $value): bool + { + if (\in_array($value, $this->falseValues, true)) { + return false; + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + return true; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/ChoiceToValueTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/ChoiceToValueTransformer.php new file mode 100644 index 000000000..daec9d719 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/ChoiceToValueTransformer.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * @author Bernhard Schussek + * + * @implements DataTransformerInterface + */ +class ChoiceToValueTransformer implements DataTransformerInterface +{ + private ChoiceListInterface $choiceList; + + public function __construct(ChoiceListInterface $choiceList) + { + $this->choiceList = $choiceList; + } + + public function transform(mixed $choice): mixed + { + return (string) current($this->choiceList->getValuesForChoices([$choice])); + } + + public function reverseTransform(mixed $value): mixed + { + if (null !== $value && !\is_string($value)) { + throw new TransformationFailedException('Expected a string or null.'); + } + + $choices = $this->choiceList->getChoicesForValues([(string) $value]); + + if (1 !== \count($choices)) { + if (null === $value || '' === $value) { + return null; + } + + throw new TransformationFailedException(sprintf('The choice "%s" does not exist or is not unique.', $value)); + } + + return current($choices); + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/ChoicesToValuesTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/ChoicesToValuesTransformer.php new file mode 100644 index 000000000..f284ff34f --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/ChoicesToValuesTransformer.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\Core\DataTransformer; + +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * @author Bernhard Schussek + * + * @implements DataTransformerInterface + */ +class ChoicesToValuesTransformer implements DataTransformerInterface +{ + private ChoiceListInterface $choiceList; + + public function __construct(ChoiceListInterface $choiceList) + { + $this->choiceList = $choiceList; + } + + /** + * @throws TransformationFailedException if the given value is not an array + */ + public function transform(mixed $array): array + { + if (null === $array) { + return []; + } + + if (!\is_array($array)) { + throw new TransformationFailedException('Expected an array.'); + } + + return $this->choiceList->getValuesForChoices($array); + } + + /** + * @throws TransformationFailedException if the given value is not an array + * or if no matching choice could be + * found for some given value + */ + public function reverseTransform(mixed $array): array + { + if (null === $array) { + return []; + } + + if (!\is_array($array)) { + throw new TransformationFailedException('Expected an array.'); + } + + $choices = $this->choiceList->getChoicesForValues($array); + + if (\count($choices) !== \count($array)) { + throw new TransformationFailedException('Could not find all matching choices for the given values.'); + } + + return $choices; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DataTransformerChain.php b/lib/symfony/form/Extension/Core/DataTransformer/DataTransformerChain.php new file mode 100644 index 000000000..41b93e56a --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DataTransformerChain.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Passes a value through multiple value transformers. + * + * @author Bernhard Schussek + */ +class DataTransformerChain implements DataTransformerInterface +{ + protected $transformers; + + /** + * Uses the given value transformers to transform values. + * + * @param DataTransformerInterface[] $transformers + */ + public function __construct(array $transformers) + { + $this->transformers = $transformers; + } + + /** + * Passes the value through the transform() method of all nested transformers. + * + * The transformers receive the value in the same order as they were passed + * to the constructor. Each transformer receives the result of the previous + * transformer as input. The output of the last transformer is returned + * by this method. + * + * @param mixed $value The original value + * + * @throws TransformationFailedException + */ + public function transform(mixed $value): mixed + { + foreach ($this->transformers as $transformer) { + $value = $transformer->transform($value); + } + + return $value; + } + + /** + * Passes the value through the reverseTransform() method of all nested + * transformers. + * + * The transformers receive the value in the reverse order as they were passed + * to the constructor. Each transformer receives the result of the previous + * transformer as input. The output of the last transformer is returned + * by this method. + * + * @param mixed $value The transformed value + * + * @throws TransformationFailedException + */ + public function reverseTransform(mixed $value): mixed + { + for ($i = \count($this->transformers) - 1; $i >= 0; --$i) { + $value = $this->transformers[$i]->reverseTransform($value); + } + + return $value; + } + + /** + * @return DataTransformerInterface[] + */ + public function getTransformers(): array + { + return $this->transformers; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateIntervalToArrayTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateIntervalToArrayTransformer.php new file mode 100644 index 000000000..7018749d2 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateIntervalToArrayTransformer.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * Transforms between a normalized date interval and an interval string/array. + * + * @author Steffen Roßkamp + * + * @implements DataTransformerInterface<\DateInterval, array> + */ +class DateIntervalToArrayTransformer implements DataTransformerInterface +{ + public const YEARS = 'years'; + public const MONTHS = 'months'; + public const DAYS = 'days'; + public const HOURS = 'hours'; + public const MINUTES = 'minutes'; + public const SECONDS = 'seconds'; + public const INVERT = 'invert'; + + private const AVAILABLE_FIELDS = [ + self::YEARS => 'y', + self::MONTHS => 'm', + self::DAYS => 'd', + self::HOURS => 'h', + self::MINUTES => 'i', + self::SECONDS => 's', + self::INVERT => 'r', + ]; + private array $fields; + private bool $pad; + + /** + * @param string[]|null $fields The date fields + * @param bool $pad Whether to use padding + */ + public function __construct(?array $fields = null, bool $pad = false) + { + $this->fields = $fields ?? ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'invert']; + $this->pad = $pad; + } + + /** + * Transforms a normalized date interval into an interval array. + * + * @param \DateInterval $dateInterval Normalized date interval + * + * @throws UnexpectedTypeException if the given value is not a \DateInterval instance + */ + public function transform(mixed $dateInterval): array + { + if (null === $dateInterval) { + return array_intersect_key( + [ + 'years' => '', + 'months' => '', + 'weeks' => '', + 'days' => '', + 'hours' => '', + 'minutes' => '', + 'seconds' => '', + 'invert' => false, + ], + array_flip($this->fields) + ); + } + if (!$dateInterval instanceof \DateInterval) { + throw new UnexpectedTypeException($dateInterval, \DateInterval::class); + } + $result = []; + foreach (self::AVAILABLE_FIELDS as $field => $char) { + $result[$field] = $dateInterval->format('%'.($this->pad ? strtoupper($char) : $char)); + } + if (\in_array('weeks', $this->fields, true)) { + $result['weeks'] = '0'; + if (isset($result['days']) && (int) $result['days'] >= 7) { + $result['weeks'] = (string) floor($result['days'] / 7); + $result['days'] = (string) ($result['days'] % 7); + } + } + $result['invert'] = '-' === $result['invert']; + $result = array_intersect_key($result, array_flip($this->fields)); + + return $result; + } + + /** + * Transforms an interval array into a normalized date interval. + * + * @param array $value Interval array + * + * @throws UnexpectedTypeException if the given value is not an array + * @throws TransformationFailedException if the value could not be transformed + */ + public function reverseTransform(mixed $value): ?\DateInterval + { + if (null === $value) { + return null; + } + if (!\is_array($value)) { + throw new UnexpectedTypeException($value, 'array'); + } + if ('' === implode('', $value)) { + return null; + } + $emptyFields = []; + foreach ($this->fields as $field) { + if (!isset($value[$field])) { + $emptyFields[] = $field; + } + } + if (\count($emptyFields) > 0) { + throw new TransformationFailedException(sprintf('The fields "%s" should not be empty.', implode('", "', $emptyFields))); + } + if (isset($value['invert']) && !\is_bool($value['invert'])) { + throw new TransformationFailedException('The value of "invert" must be boolean.'); + } + foreach (self::AVAILABLE_FIELDS as $field => $char) { + if ('invert' !== $field && isset($value[$field]) && !ctype_digit((string) $value[$field])) { + throw new TransformationFailedException(sprintf('This amount of "%s" is invalid.', $field)); + } + } + try { + if (!empty($value['weeks'])) { + $interval = sprintf( + 'P%sY%sM%sWT%sH%sM%sS', + empty($value['years']) ? '0' : $value['years'], + empty($value['months']) ? '0' : $value['months'], + empty($value['weeks']) ? '0' : $value['weeks'], + empty($value['hours']) ? '0' : $value['hours'], + empty($value['minutes']) ? '0' : $value['minutes'], + empty($value['seconds']) ? '0' : $value['seconds'] + ); + } else { + $interval = sprintf( + 'P%sY%sM%sDT%sH%sM%sS', + empty($value['years']) ? '0' : $value['years'], + empty($value['months']) ? '0' : $value['months'], + empty($value['days']) ? '0' : $value['days'], + empty($value['hours']) ? '0' : $value['hours'], + empty($value['minutes']) ? '0' : $value['minutes'], + empty($value['seconds']) ? '0' : $value['seconds'] + ); + } + $dateInterval = new \DateInterval($interval); + if (isset($value['invert'])) { + $dateInterval->invert = $value['invert'] ? 1 : 0; + } + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + return $dateInterval; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateIntervalToStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateIntervalToStringTransformer.php new file mode 100644 index 000000000..4160f8f34 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateIntervalToStringTransformer.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * Transforms between a date string and a DateInterval object. + * + * @author Steffen Roßkamp + * + * @implements DataTransformerInterface<\DateInterval, string> + */ +class DateIntervalToStringTransformer implements DataTransformerInterface +{ + private string $format; + + /** + * Transforms a \DateInterval instance to a string. + * + * @see \DateInterval::format() for supported formats + * + * @param string $format The date format + */ + public function __construct(string $format = 'P%yY%mM%dDT%hH%iM%sS') + { + $this->format = $format; + } + + /** + * Transforms a DateInterval object into a date string with the configured format. + * + * @param \DateInterval|null $value A DateInterval object + * + * @throws UnexpectedTypeException if the given value is not a \DateInterval instance + */ + public function transform(mixed $value): string + { + if (null === $value) { + return ''; + } + if (!$value instanceof \DateInterval) { + throw new UnexpectedTypeException($value, \DateInterval::class); + } + + return $value->format($this->format); + } + + /** + * Transforms a date string in the configured format into a DateInterval object. + * + * @param string $value An ISO 8601 or date string like date interval presentation + * + * @throws UnexpectedTypeException if the given value is not a string + * @throws TransformationFailedException if the date interval could not be parsed + */ + public function reverseTransform(mixed $value): ?\DateInterval + { + if (null === $value) { + return null; + } + if (!\is_string($value)) { + throw new UnexpectedTypeException($value, 'string'); + } + if ('' === $value) { + return null; + } + if (!$this->isISO8601($value)) { + throw new TransformationFailedException('Non ISO 8601 date strings are not supported yet.'); + } + $valuePattern = '/^'.preg_replace('/%([yYmMdDhHiIsSwW])(\w)/', '(?P<$1>\d+)$2', $this->format).'$/'; + if (!preg_match($valuePattern, $value)) { + throw new TransformationFailedException(sprintf('Value "%s" contains intervals not accepted by format "%s".', $value, $this->format)); + } + try { + $dateInterval = new \DateInterval($value); + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + return $dateInterval; + } + + private function isISO8601(string $string): bool + { + return preg_match('/^P(?=\w*(?:\d|%\w))(?:\d+Y|%[yY]Y)?(?:\d+M|%[mM]M)?(?:(?:\d+D|%[dD]D)|(?:\d+W|%[wW]W))?(?:T(?:\d+H|[hH]H)?(?:\d+M|[iI]M)?(?:\d+S|[sS]S)?)?$/', $string); + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateTimeImmutableToDateTimeTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeImmutableToDateTimeTransformer.php new file mode 100644 index 000000000..3f285b4a3 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeImmutableToDateTimeTransformer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a DateTimeImmutable object and a DateTime object. + * + * @author Valentin Udaltsov + * + * @implements DataTransformerInterface<\DateTimeImmutable, \DateTime> + */ +final class DateTimeImmutableToDateTimeTransformer implements DataTransformerInterface +{ + /** + * Transforms a DateTimeImmutable into a DateTime object. + * + * @param \DateTimeImmutable|null $value A DateTimeImmutable object + * + * @throws TransformationFailedException If the given value is not a \DateTimeImmutable + */ + public function transform(mixed $value): ?\DateTime + { + if (null === $value) { + return null; + } + + if (!$value instanceof \DateTimeImmutable) { + throw new TransformationFailedException('Expected a \DateTimeImmutable.'); + } + + return \DateTime::createFromImmutable($value); + } + + /** + * Transforms a DateTime object into a DateTimeImmutable object. + * + * @param \DateTime|null $value A DateTime object + * + * @throws TransformationFailedException If the given value is not a \DateTime + */ + public function reverseTransform(mixed $value): ?\DateTimeImmutable + { + if (null === $value) { + return null; + } + + if (!$value instanceof \DateTime) { + throw new TransformationFailedException('Expected a \DateTime.'); + } + + return \DateTimeImmutable::createFromMutable($value); + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToArrayTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToArrayTransformer.php new file mode 100644 index 000000000..c40e176cb --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToArrayTransformer.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a normalized time and a localized time string/array. + * + * @author Bernhard Schussek + * @author Florian Eckerstorfer + * + * @extends BaseDateTimeTransformer + */ +class DateTimeToArrayTransformer extends BaseDateTimeTransformer +{ + private bool $pad; + private array $fields; + private \DateTimeInterface $referenceDate; + + /** + * @param string|null $inputTimezone The input timezone + * @param string|null $outputTimezone The output timezone + * @param string[]|null $fields The date fields + * @param bool $pad Whether to use padding + */ + public function __construct(?string $inputTimezone = null, ?string $outputTimezone = null, ?array $fields = null, bool $pad = false, ?\DateTimeInterface $referenceDate = null) + { + parent::__construct($inputTimezone, $outputTimezone); + + $this->fields = $fields ?? ['year', 'month', 'day', 'hour', 'minute', 'second']; + $this->pad = $pad; + $this->referenceDate = $referenceDate ?? new \DateTimeImmutable('1970-01-01 00:00:00'); + } + + /** + * Transforms a normalized date into a localized date. + * + * @param \DateTimeInterface $dateTime A DateTimeInterface object + * + * @throws TransformationFailedException If the given value is not a \DateTimeInterface + */ + public function transform(mixed $dateTime): array + { + if (null === $dateTime) { + return array_intersect_key([ + 'year' => '', + 'month' => '', + 'day' => '', + 'hour' => '', + 'minute' => '', + 'second' => '', + ], array_flip($this->fields)); + } + + if (!$dateTime instanceof \DateTimeInterface) { + throw new TransformationFailedException('Expected a \DateTimeInterface.'); + } + + if ($this->inputTimezone !== $this->outputTimezone) { + $dateTime = \DateTimeImmutable::createFromInterface($dateTime); + $dateTime = $dateTime->setTimezone(new \DateTimeZone($this->outputTimezone)); + } + + $result = array_intersect_key([ + 'year' => $dateTime->format('Y'), + 'month' => $dateTime->format('m'), + 'day' => $dateTime->format('d'), + 'hour' => $dateTime->format('H'), + 'minute' => $dateTime->format('i'), + 'second' => $dateTime->format('s'), + ], array_flip($this->fields)); + + if (!$this->pad) { + foreach ($result as &$entry) { + // remove leading zeros + $entry = (string) (int) $entry; + } + // unset reference to keep scope clear + unset($entry); + } + + return $result; + } + + /** + * Transforms a localized date into a normalized date. + * + * @param array $value Localized date + * + * @throws TransformationFailedException If the given value is not an array, + * if the value could not be transformed + */ + public function reverseTransform(mixed $value): ?\DateTime + { + if (null === $value) { + return null; + } + + if (!\is_array($value)) { + throw new TransformationFailedException('Expected an array.'); + } + + if ('' === implode('', $value)) { + return null; + } + + $emptyFields = []; + + foreach ($this->fields as $field) { + if (!isset($value[$field])) { + $emptyFields[] = $field; + } + } + + if (\count($emptyFields) > 0) { + throw new TransformationFailedException(sprintf('The fields "%s" should not be empty.', implode('", "', $emptyFields))); + } + + if (isset($value['month']) && !ctype_digit((string) $value['month'])) { + throw new TransformationFailedException('This month is invalid.'); + } + + if (isset($value['day']) && !ctype_digit((string) $value['day'])) { + throw new TransformationFailedException('This day is invalid.'); + } + + if (isset($value['year']) && !ctype_digit((string) $value['year'])) { + throw new TransformationFailedException('This year is invalid.'); + } + + if (!empty($value['month']) && !empty($value['day']) && !empty($value['year']) && false === checkdate($value['month'], $value['day'], $value['year'])) { + throw new TransformationFailedException('This is an invalid date.'); + } + + if (isset($value['hour']) && !ctype_digit((string) $value['hour'])) { + throw new TransformationFailedException('This hour is invalid.'); + } + + if (isset($value['minute']) && !ctype_digit((string) $value['minute'])) { + throw new TransformationFailedException('This minute is invalid.'); + } + + if (isset($value['second']) && !ctype_digit((string) $value['second'])) { + throw new TransformationFailedException('This second is invalid.'); + } + + try { + $dateTime = new \DateTime(sprintf( + '%s-%s-%s %s:%s:%s', + empty($value['year']) ? $this->referenceDate->format('Y') : $value['year'], + empty($value['month']) ? $this->referenceDate->format('m') : $value['month'], + empty($value['day']) ? $this->referenceDate->format('d') : $value['day'], + $value['hour'] ?? $this->referenceDate->format('H'), + $value['minute'] ?? $this->referenceDate->format('i'), + $value['second'] ?? $this->referenceDate->format('s') + ), + new \DateTimeZone($this->outputTimezone) + ); + + if ($this->inputTimezone !== $this->outputTimezone) { + $dateTime->setTimezone(new \DateTimeZone($this->inputTimezone)); + } + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + return $dateTime; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToHtml5LocalDateTimeTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToHtml5LocalDateTimeTransformer.php new file mode 100644 index 000000000..855b22a49 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToHtml5LocalDateTimeTransformer.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * @author Franz Wilding + * @author Bernhard Schussek + * @author Fred Cox + * + * @extends BaseDateTimeTransformer + */ +class DateTimeToHtml5LocalDateTimeTransformer extends BaseDateTimeTransformer +{ + public const HTML5_FORMAT = 'Y-m-d\\TH:i:s'; + public const HTML5_FORMAT_NO_SECONDS = 'Y-m-d\\TH:i'; + + public function __construct(?string $inputTimezone = null, ?string $outputTimezone = null, private bool $withSeconds = false) + { + parent::__construct($inputTimezone, $outputTimezone); + } + + /** + * Transforms a \DateTime into a local date and time string. + * + * According to the HTML standard, the input string of a datetime-local + * input is an RFC3339 date followed by 'T', followed by an RFC3339 time. + * https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-local-date-and-time-string + * + * @param \DateTimeInterface $dateTime + * + * @throws TransformationFailedException If the given value is not an + * instance of \DateTime or \DateTimeInterface + */ + public function transform(mixed $dateTime): string + { + if (null === $dateTime) { + return ''; + } + + if (!$dateTime instanceof \DateTimeInterface) { + throw new TransformationFailedException('Expected a \DateTimeInterface.'); + } + + if ($this->inputTimezone !== $this->outputTimezone) { + $dateTime = \DateTimeImmutable::createFromInterface($dateTime); + $dateTime = $dateTime->setTimezone(new \DateTimeZone($this->outputTimezone)); + } + + return $dateTime->format($this->withSeconds ? self::HTML5_FORMAT : self::HTML5_FORMAT_NO_SECONDS); + } + + /** + * Transforms a local date and time string into a \DateTime. + * + * When transforming back to DateTime the regex is slightly laxer, taking into + * account rules for parsing a local date and time string + * https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#parse-a-local-date-and-time-string + * + * @param string $dateTimeLocal Formatted string + * + * @throws TransformationFailedException If the given value is not a string, + * if the value could not be transformed + */ + public function reverseTransform(mixed $dateTimeLocal): ?\DateTime + { + if (!\is_string($dateTimeLocal)) { + throw new TransformationFailedException('Expected a string.'); + } + + if ('' === $dateTimeLocal) { + return null; + } + + // to maintain backwards compatibility we do not strictly validate the submitted date + // see https://github.com/symfony/symfony/issues/28699 + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})[T ]\d{2}:\d{2}(?::\d{2})?/', $dateTimeLocal, $matches)) { + throw new TransformationFailedException(sprintf('The date "%s" is not a valid date.', $dateTimeLocal)); + } + + try { + $dateTime = new \DateTime($dateTimeLocal, new \DateTimeZone($this->outputTimezone)); + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + if ($this->inputTimezone !== $dateTime->getTimezone()->getName()) { + $dateTime->setTimezone(new \DateTimeZone($this->inputTimezone)); + } + + if (!checkdate($matches[2], $matches[3], $matches[1])) { + throw new TransformationFailedException(sprintf('The date "%s-%s-%s" is not a valid date.', $matches[1], $matches[2], $matches[3])); + } + + return $dateTime; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToLocalizedStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToLocalizedStringTransformer.php new file mode 100644 index 000000000..7bb79f3a1 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToLocalizedStringTransformer.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * Transforms between a normalized time and a localized time string. + * + * @author Bernhard Schussek + * @author Florian Eckerstorfer + * + * @extends BaseDateTimeTransformer + */ +class DateTimeToLocalizedStringTransformer extends BaseDateTimeTransformer +{ + private int $dateFormat; + private int $timeFormat; + private ?string $pattern; + private int $calendar; + + /** + * @see BaseDateTimeTransformer::formats for available format options + * + * @param string|null $inputTimezone The name of the input timezone + * @param string|null $outputTimezone The name of the output timezone + * @param int|null $dateFormat The date format + * @param int|null $timeFormat The time format + * @param int $calendar One of the \IntlDateFormatter calendar constants + * @param string|null $pattern A pattern to pass to \IntlDateFormatter + * + * @throws UnexpectedTypeException If a format is not supported or if a timezone is not a string + */ + public function __construct(?string $inputTimezone = null, ?string $outputTimezone = null, ?int $dateFormat = null, ?int $timeFormat = null, int $calendar = \IntlDateFormatter::GREGORIAN, ?string $pattern = null) + { + parent::__construct($inputTimezone, $outputTimezone); + + $dateFormat ??= \IntlDateFormatter::MEDIUM; + $timeFormat ??= \IntlDateFormatter::SHORT; + + if (!\in_array($dateFormat, self::$formats, true)) { + throw new UnexpectedTypeException($dateFormat, implode('", "', self::$formats)); + } + + if (!\in_array($timeFormat, self::$formats, true)) { + throw new UnexpectedTypeException($timeFormat, implode('", "', self::$formats)); + } + + $this->dateFormat = $dateFormat; + $this->timeFormat = $timeFormat; + $this->calendar = $calendar; + $this->pattern = $pattern; + } + + /** + * Transforms a normalized date into a localized date string/array. + * + * @param \DateTimeInterface $dateTime A DateTimeInterface object + * + * @throws TransformationFailedException if the given value is not a \DateTimeInterface + * or if the date could not be transformed + */ + public function transform(mixed $dateTime): string + { + if (null === $dateTime) { + return ''; + } + + if (!$dateTime instanceof \DateTimeInterface) { + throw new TransformationFailedException('Expected a \DateTimeInterface.'); + } + + $value = $this->getIntlDateFormatter()->format($dateTime->getTimestamp()); + + if (0 != intl_get_error_code()) { + throw new TransformationFailedException(intl_get_error_message()); + } + + return $value; + } + + /** + * Transforms a localized date string/array into a normalized date. + * + * @param string $value Localized date string + * + * @throws TransformationFailedException if the given value is not a string, + * if the date could not be parsed + */ + public function reverseTransform(mixed $value): ?\DateTime + { + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + if ('' === $value) { + return null; + } + + // date-only patterns require parsing to be done in UTC, as midnight might not exist in the local timezone due + // to DST changes + $dateOnly = $this->isPatternDateOnly(); + $dateFormatter = $this->getIntlDateFormatter($dateOnly); + + try { + $timestamp = @$dateFormatter->parse($value); + } catch (\IntlException $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + if (0 != intl_get_error_code()) { + throw new TransformationFailedException(intl_get_error_message(), intl_get_error_code()); + } elseif ($timestamp > 253402214400) { + // This timestamp represents UTC midnight of 9999-12-31 to prevent 5+ digit years + throw new TransformationFailedException('Years beyond 9999 are not supported.'); + } elseif (false === $timestamp) { + // the value couldn't be parsed but the Intl extension didn't report an error code, this + // could be the case when the Intl polyfill is used which always returns 0 as the error code + throw new TransformationFailedException(sprintf('"%s" could not be parsed as a date.', $value)); + } + + try { + if ($dateOnly) { + // we only care about year-month-date, which has been delivered as a timestamp pointing to UTC midnight + $dateTime = new \DateTime(gmdate('Y-m-d', $timestamp), new \DateTimeZone($this->outputTimezone)); + } else { + // read timestamp into DateTime object - the formatter delivers a timestamp + $dateTime = new \DateTime(sprintf('@%s', $timestamp)); + } + // set timezone separately, as it would be ignored if set via the constructor, + // see https://php.net/datetime.construct + $dateTime->setTimezone(new \DateTimeZone($this->outputTimezone)); + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + if ($this->outputTimezone !== $this->inputTimezone) { + $dateTime->setTimezone(new \DateTimeZone($this->inputTimezone)); + } + + return $dateTime; + } + + /** + * Returns a preconfigured IntlDateFormatter instance. + * + * @param bool $ignoreTimezone Use UTC regardless of the configured timezone + * + * @throws TransformationFailedException in case the date formatter cannot be constructed + */ + protected function getIntlDateFormatter(bool $ignoreTimezone = false): \IntlDateFormatter + { + $dateFormat = $this->dateFormat; + $timeFormat = $this->timeFormat; + $timezone = new \DateTimeZone($ignoreTimezone ? 'UTC' : $this->outputTimezone); + + $calendar = $this->calendar; + $pattern = $this->pattern; + + $intlDateFormatter = new \IntlDateFormatter(\Locale::getDefault(), $dateFormat, $timeFormat, $timezone, $calendar, $pattern ?? ''); + + // new \intlDateFormatter may return null instead of false in case of failure, see https://bugs.php.net/66323 + if (!$intlDateFormatter) { + throw new TransformationFailedException(intl_get_error_message(), intl_get_error_code()); + } + + $intlDateFormatter->setLenient(false); + + return $intlDateFormatter; + } + + /** + * Checks if the pattern contains only a date. + */ + protected function isPatternDateOnly(): bool + { + if (null === $this->pattern) { + return false; + } + + // strip escaped text + $pattern = preg_replace("#'(.*?)'#", '', $this->pattern); + + // check for the absence of time-related placeholders + return 0 === preg_match('#[ahHkKmsSAzZOvVxX]#', $pattern); + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToRfc3339Transformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToRfc3339Transformer.php new file mode 100644 index 000000000..41e63e57c --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToRfc3339Transformer.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * @author Bernhard Schussek + * + * @extends BaseDateTimeTransformer + */ +class DateTimeToRfc3339Transformer extends BaseDateTimeTransformer +{ + /** + * Transforms a normalized date into a localized date. + * + * @param \DateTimeInterface $dateTime A DateTimeInterface object + * + * @throws TransformationFailedException If the given value is not a \DateTimeInterface + */ + public function transform(mixed $dateTime): string + { + if (null === $dateTime) { + return ''; + } + + if (!$dateTime instanceof \DateTimeInterface) { + throw new TransformationFailedException('Expected a \DateTimeInterface.'); + } + + if ($this->inputTimezone !== $this->outputTimezone) { + $dateTime = \DateTimeImmutable::createFromInterface($dateTime); + $dateTime = $dateTime->setTimezone(new \DateTimeZone($this->outputTimezone)); + } + + return preg_replace('/\+00:00$/', 'Z', $dateTime->format('c')); + } + + /** + * Transforms a formatted string following RFC 3339 into a normalized date. + * + * @param string $rfc3339 Formatted string + * + * @throws TransformationFailedException If the given value is not a string, + * if the value could not be transformed + */ + public function reverseTransform(mixed $rfc3339): ?\DateTime + { + if (!\is_string($rfc3339)) { + throw new TransformationFailedException('Expected a string.'); + } + + if ('' === $rfc3339) { + return null; + } + + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|(?:(?:\+|-)\d{2}:\d{2}))$/', $rfc3339, $matches)) { + throw new TransformationFailedException(sprintf('The date "%s" is not a valid date.', $rfc3339)); + } + + try { + $dateTime = new \DateTime($rfc3339); + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + if ($this->inputTimezone !== $dateTime->getTimezone()->getName()) { + $dateTime->setTimezone(new \DateTimeZone($this->inputTimezone)); + } + + if (!checkdate($matches[2], $matches[3], $matches[1])) { + throw new TransformationFailedException(sprintf('The date "%s-%s-%s" is not a valid date.', $matches[1], $matches[2], $matches[3])); + } + + return $dateTime; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToStringTransformer.php new file mode 100644 index 000000000..96bdc7c0d --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToStringTransformer.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a date string and a DateTime object. + * + * @author Bernhard Schussek + * @author Florian Eckerstorfer + * + * @extends BaseDateTimeTransformer + */ +class DateTimeToStringTransformer extends BaseDateTimeTransformer +{ + /** + * Format used for generating strings. + */ + private string $generateFormat; + + /** + * Format used for parsing strings. + * + * Different than the {@link $generateFormat} because formats for parsing + * support additional characters in PHP that are not supported for + * generating strings. + */ + private string $parseFormat; + + /** + * Transforms a \DateTime instance to a string. + * + * @see \DateTime::format() for supported formats + * + * @param string|null $inputTimezone The name of the input timezone + * @param string|null $outputTimezone The name of the output timezone + * @param string $format The date format + * @param string|null $parseFormat The parse format when different from $format + */ + public function __construct(?string $inputTimezone = null, ?string $outputTimezone = null, string $format = 'Y-m-d H:i:s', ?string $parseFormat = null) + { + parent::__construct($inputTimezone, $outputTimezone); + + $this->generateFormat = $format; + $this->parseFormat = $parseFormat ?? $format; + + // See https://php.net/datetime.createfromformat + // The character "|" in the format makes sure that the parts of a date + // that are *not* specified in the format are reset to the corresponding + // values from 1970-01-01 00:00:00 instead of the current time. + // Without "|" and "Y-m-d", "2010-02-03" becomes "2010-02-03 12:32:47", + // where the time corresponds to the current server time. + // With "|" and "Y-m-d", "2010-02-03" becomes "2010-02-03 00:00:00", + // which is at least deterministic and thus used here. + if (!str_contains($this->parseFormat, '|')) { + $this->parseFormat .= '|'; + } + } + + /** + * Transforms a DateTime object into a date string with the configured format + * and timezone. + * + * @param \DateTimeInterface $dateTime A DateTimeInterface object + * + * @throws TransformationFailedException If the given value is not a \DateTimeInterface + */ + public function transform(mixed $dateTime): string + { + if (null === $dateTime) { + return ''; + } + + if (!$dateTime instanceof \DateTimeInterface) { + throw new TransformationFailedException('Expected a \DateTimeInterface.'); + } + + $dateTime = \DateTimeImmutable::createFromInterface($dateTime); + $dateTime = $dateTime->setTimezone(new \DateTimeZone($this->outputTimezone)); + + return $dateTime->format($this->generateFormat); + } + + /** + * Transforms a date string in the configured timezone into a DateTime object. + * + * @param string $value A value as produced by PHP's date() function + * + * @throws TransformationFailedException If the given value is not a string, + * or could not be transformed + */ + public function reverseTransform(mixed $value): ?\DateTime + { + if (empty($value)) { + return null; + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + if (str_contains($value, "\0")) { + throw new TransformationFailedException('Null bytes not allowed'); + } + + $outputTz = new \DateTimeZone($this->outputTimezone); + $dateTime = \DateTime::createFromFormat($this->parseFormat, $value, $outputTz); + + $lastErrors = \DateTime::getLastErrors() ?: ['error_count' => 0, 'warning_count' => 0]; + + if (0 < $lastErrors['warning_count'] || 0 < $lastErrors['error_count']) { + throw new TransformationFailedException(implode(', ', array_merge(array_values($lastErrors['warnings']), array_values($lastErrors['errors'])))); + } + + try { + if ($this->inputTimezone !== $this->outputTimezone) { + $dateTime->setTimezone(new \DateTimeZone($this->inputTimezone)); + } + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + return $dateTime; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToTimestampTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToTimestampTransformer.php new file mode 100644 index 000000000..33c1b1d59 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeToTimestampTransformer.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a timestamp and a DateTime object. + * + * @author Bernhard Schussek + * @author Florian Eckerstorfer + * + * @extends BaseDateTimeTransformer + */ +class DateTimeToTimestampTransformer extends BaseDateTimeTransformer +{ + /** + * Transforms a DateTime object into a timestamp in the configured timezone. + * + * @param \DateTimeInterface $dateTime A DateTimeInterface object + * + * @throws TransformationFailedException If the given value is not a \DateTimeInterface + */ + public function transform(mixed $dateTime): ?int + { + if (null === $dateTime) { + return null; + } + + if (!$dateTime instanceof \DateTimeInterface) { + throw new TransformationFailedException('Expected a \DateTimeInterface.'); + } + + return $dateTime->getTimestamp(); + } + + /** + * Transforms a timestamp in the configured timezone into a DateTime object. + * + * @param string $value A timestamp + * + * @throws TransformationFailedException If the given value is not a timestamp + * or if the given timestamp is invalid + */ + public function reverseTransform(mixed $value): ?\DateTime + { + if (null === $value) { + return null; + } + + if (!is_numeric($value)) { + throw new TransformationFailedException('Expected a numeric.'); + } + + try { + $dateTime = new \DateTime(); + $dateTime->setTimezone(new \DateTimeZone($this->outputTimezone)); + $dateTime->setTimestamp($value); + + if ($this->inputTimezone !== $this->outputTimezone) { + $dateTime->setTimezone(new \DateTimeZone($this->inputTimezone)); + } + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + return $dateTime; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/DateTimeZoneToStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeZoneToStringTransformer.php new file mode 100644 index 000000000..f7bda1751 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/DateTimeZoneToStringTransformer.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a timezone identifier string and a DateTimeZone object. + * + * @author Roland Franssen + * + * @implements DataTransformerInterface<\DateTimeZone|array<\DateTimeZone>, string|array> + */ +class DateTimeZoneToStringTransformer implements DataTransformerInterface +{ + private bool $multiple; + + public function __construct(bool $multiple = false) + { + $this->multiple = $multiple; + } + + public function transform(mixed $dateTimeZone): mixed + { + if (null === $dateTimeZone) { + return null; + } + + if ($this->multiple) { + if (!\is_array($dateTimeZone)) { + throw new TransformationFailedException('Expected an array of \DateTimeZone objects.'); + } + + return array_map([new self(), 'transform'], $dateTimeZone); + } + + if (!$dateTimeZone instanceof \DateTimeZone) { + throw new TransformationFailedException('Expected a \DateTimeZone object.'); + } + + return $dateTimeZone->getName(); + } + + public function reverseTransform(mixed $value): mixed + { + if (null === $value) { + return null; + } + + if ($this->multiple) { + if (!\is_array($value)) { + throw new TransformationFailedException('Expected an array of timezone identifier strings.'); + } + + return array_map([new self(), 'reverseTransform'], $value); + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a timezone identifier string.'); + } + + try { + return new \DateTimeZone($value); + } catch (\Exception $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/IntegerToLocalizedStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/IntegerToLocalizedStringTransformer.php new file mode 100644 index 000000000..eb5a2d6ff --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/IntegerToLocalizedStringTransformer.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between an integer and a localized number with grouping + * (each thousand) and comma separators. + * + * @author Bernhard Schussek + */ +class IntegerToLocalizedStringTransformer extends NumberToLocalizedStringTransformer +{ + /** + * Constructs a transformer. + * + * @param bool $grouping Whether thousands should be grouped + * @param int|null $roundingMode One of the ROUND_ constants in this class + * @param string|null $locale locale used for transforming + */ + public function __construct(?bool $grouping = false, ?int $roundingMode = \NumberFormatter::ROUND_DOWN, ?string $locale = null) + { + parent::__construct(0, $grouping, $roundingMode, $locale); + } + + public function reverseTransform(mixed $value): int|float|null + { + $decimalSeparator = $this->getNumberFormatter()->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); + + if (\is_string($value) && str_contains($value, $decimalSeparator)) { + throw new TransformationFailedException(sprintf('The value "%s" is not a valid integer.', $value)); + } + + $result = parent::reverseTransform($value); + + return null !== $result ? (int) $result : null; + } + + /** + * @internal + */ + protected function castParsedValue(int|float $value): int|float + { + return $value; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformer.php new file mode 100644 index 000000000..d379164a7 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/IntlTimeZoneToStringTransformer.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a timezone identifier string and a IntlTimeZone object. + * + * @author Roland Franssen + * + * @implements DataTransformerInterface<\IntlTimeZone|array<\IntlTimeZone>, string|array> + */ +class IntlTimeZoneToStringTransformer implements DataTransformerInterface +{ + private bool $multiple; + + public function __construct(bool $multiple = false) + { + $this->multiple = $multiple; + } + + public function transform(mixed $intlTimeZone): mixed + { + if (null === $intlTimeZone) { + return null; + } + + if ($this->multiple) { + if (!\is_array($intlTimeZone)) { + throw new TransformationFailedException('Expected an array of \IntlTimeZone objects.'); + } + + return array_map([new self(), 'transform'], $intlTimeZone); + } + + if (!$intlTimeZone instanceof \IntlTimeZone) { + throw new TransformationFailedException('Expected a \IntlTimeZone object.'); + } + + return $intlTimeZone->getID(); + } + + public function reverseTransform(mixed $value): mixed + { + if (null === $value) { + return null; + } + + if ($this->multiple) { + if (!\is_array($value)) { + throw new TransformationFailedException('Expected an array of timezone identifier strings.'); + } + + return array_map([new self(), 'reverseTransform'], $value); + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a timezone identifier string.'); + } + + $intlTimeZone = \IntlTimeZone::createTimeZone($value); + + if ('Etc/Unknown' === $intlTimeZone->getID()) { + throw new TransformationFailedException(sprintf('Unknown timezone identifier "%s".', $value)); + } + + return $intlTimeZone; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/MoneyToLocalizedStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/MoneyToLocalizedStringTransformer.php new file mode 100644 index 000000000..7a8aacac6 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/MoneyToLocalizedStringTransformer.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a normalized format and a localized money string. + * + * @author Bernhard Schussek + * @author Florian Eckerstorfer + */ +class MoneyToLocalizedStringTransformer extends NumberToLocalizedStringTransformer +{ + private int $divisor; + + public function __construct(?int $scale = 2, ?bool $grouping = true, ?int $roundingMode = \NumberFormatter::ROUND_HALFUP, ?int $divisor = 1, ?string $locale = null) + { + parent::__construct($scale ?? 2, $grouping ?? true, $roundingMode, $locale); + + $this->divisor = $divisor ?? 1; + } + + /** + * Transforms a normalized format into a localized money string. + * + * @param int|float|null $value Normalized number + * + * @throws TransformationFailedException if the given value is not numeric or + * if the value cannot be transformed + */ + public function transform(mixed $value): string + { + if (null !== $value && 1 !== $this->divisor) { + if (!is_numeric($value)) { + throw new TransformationFailedException('Expected a numeric.'); + } + $value /= $this->divisor; + } + + return parent::transform($value); + } + + /** + * Transforms a localized money string into a normalized format. + * + * @param string $value Localized money string + * + * @throws TransformationFailedException if the given value is not a string + * or if the value cannot be transformed + */ + public function reverseTransform(mixed $value): int|float|null + { + $value = parent::reverseTransform($value); + if (null !== $value && 1 !== $this->divisor) { + $value = (float) (string) ($value * $this->divisor); + } + + return $value; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/NumberToLocalizedStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/NumberToLocalizedStringTransformer.php new file mode 100644 index 000000000..71d225e58 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/NumberToLocalizedStringTransformer.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between a number type and a localized number with grouping + * (each thousand) and comma separators. + * + * @author Bernhard Schussek + * @author Florian Eckerstorfer + * + * @implements DataTransformerInterface + */ +class NumberToLocalizedStringTransformer implements DataTransformerInterface +{ + protected $grouping; + + protected $roundingMode; + + private ?int $scale; + private ?string $locale; + + public function __construct(?int $scale = null, ?bool $grouping = false, ?int $roundingMode = \NumberFormatter::ROUND_HALFUP, ?string $locale = null) + { + $this->scale = $scale; + $this->grouping = $grouping ?? false; + $this->roundingMode = $roundingMode ?? \NumberFormatter::ROUND_HALFUP; + $this->locale = $locale; + } + + /** + * Transforms a number type into localized number. + * + * @param int|float|null $value Number value + * + * @throws TransformationFailedException if the given value is not numeric + * or if the value cannot be transformed + */ + public function transform(mixed $value): string + { + if (null === $value) { + return ''; + } + + if (!is_numeric($value)) { + throw new TransformationFailedException('Expected a numeric.'); + } + + $formatter = $this->getNumberFormatter(); + $value = $formatter->format($value); + + if (intl_is_failure($formatter->getErrorCode())) { + throw new TransformationFailedException($formatter->getErrorMessage()); + } + + // Convert non-breaking and narrow non-breaking spaces to normal ones + $value = str_replace(["\xc2\xa0", "\xe2\x80\xaf"], ' ', $value); + + return $value; + } + + /** + * Transforms a localized number into an integer or float. + * + * @param string $value The localized value + * + * @throws TransformationFailedException if the given value is not a string + * or if the value cannot be transformed + */ + public function reverseTransform(mixed $value): int|float|null + { + if (null !== $value && !\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + if (null === $value || '' === $value) { + return null; + } + + if (\in_array($value, ['NaN', 'NAN', 'nan'], true)) { + throw new TransformationFailedException('"NaN" is not a valid number.'); + } + + $position = 0; + $formatter = $this->getNumberFormatter(); + $groupSep = $formatter->getSymbol(\NumberFormatter::GROUPING_SEPARATOR_SYMBOL); + $decSep = $formatter->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); + + if ('.' !== $decSep && (!$this->grouping || '.' !== $groupSep)) { + $value = str_replace('.', $decSep, $value); + } + + if (',' !== $decSep && (!$this->grouping || ',' !== $groupSep)) { + $value = str_replace(',', $decSep, $value); + } + + // If the value is in exponential notation with a negative exponent, we end up with a float value too + if (str_contains($value, $decSep) || false !== stripos($value, 'e-')) { + $type = \NumberFormatter::TYPE_DOUBLE; + } else { + $type = \PHP_INT_SIZE === 8 + ? \NumberFormatter::TYPE_INT64 + : \NumberFormatter::TYPE_INT32; + } + + try { + $result = @$formatter->parse($value, $type, $position); + } catch (\IntlException $e) { + throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e); + } + + if (intl_is_failure($formatter->getErrorCode())) { + throw new TransformationFailedException($formatter->getErrorMessage(), $formatter->getErrorCode()); + } + + if ($result >= \PHP_INT_MAX || $result <= -\PHP_INT_MAX) { + throw new TransformationFailedException('I don\'t have a clear idea what infinity looks like.'); + } + + $result = $this->castParsedValue($result); + + if (false !== $encoding = mb_detect_encoding($value, null, true)) { + $length = mb_strlen($value, $encoding); + $remainder = mb_substr($value, $position, $length, $encoding); + } else { + $length = \strlen($value); + $remainder = substr($value, $position, $length); + } + + // After parsing, position holds the index of the character where the + // parsing stopped + if ($position < $length) { + // Check if there are unrecognized characters at the end of the + // number (excluding whitespace characters) + $remainder = trim($remainder, " \t\n\r\0\x0b\xc2\xa0"); + + if ('' !== $remainder) { + throw new TransformationFailedException(sprintf('The number contains unrecognized characters: "%s".', $remainder)); + } + } + + // NumberFormatter::parse() does not round + return $this->round($result); + } + + /** + * Returns a preconfigured \NumberFormatter instance. + */ + protected function getNumberFormatter(): \NumberFormatter + { + $formatter = new \NumberFormatter($this->locale ?? \Locale::getDefault(), \NumberFormatter::DECIMAL); + + if (null !== $this->scale) { + $formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $this->scale); + $formatter->setAttribute(\NumberFormatter::ROUNDING_MODE, $this->roundingMode); + } + + $formatter->setAttribute(\NumberFormatter::GROUPING_USED, $this->grouping); + + return $formatter; + } + + /** + * @internal + */ + protected function castParsedValue(int|float $value): int|float + { + if (\is_int($value) && $value === (int) $float = (float) $value) { + return $float; + } + + return $value; + } + + /** + * Rounds a number according to the configured scale and rounding mode. + */ + private function round(int|float $number): int|float + { + if (null !== $this->scale && null !== $this->roundingMode) { + // shift number to maintain the correct scale during rounding + $roundingCoef = 10 ** $this->scale; + // string representation to avoid rounding errors, similar to bcmul() + $number = (string) ($number * $roundingCoef); + + switch ($this->roundingMode) { + case \NumberFormatter::ROUND_CEILING: + $number = ceil($number); + break; + case \NumberFormatter::ROUND_FLOOR: + $number = floor($number); + break; + case \NumberFormatter::ROUND_UP: + $number = $number > 0 ? ceil($number) : floor($number); + break; + case \NumberFormatter::ROUND_DOWN: + $number = $number > 0 ? floor($number) : ceil($number); + break; + case \NumberFormatter::ROUND_HALFEVEN: + $number = round($number, 0, \PHP_ROUND_HALF_EVEN); + break; + case \NumberFormatter::ROUND_HALFUP: + $number = round($number, 0, \PHP_ROUND_HALF_UP); + break; + case \NumberFormatter::ROUND_HALFDOWN: + $number = round($number, 0, \PHP_ROUND_HALF_DOWN); + break; + } + + $number = 1 === $roundingCoef ? (int) $number : $number / $roundingCoef; + } + + return $number; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/PercentToLocalizedStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/PercentToLocalizedStringTransformer.php new file mode 100644 index 000000000..16cd4e399 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/PercentToLocalizedStringTransformer.php @@ -0,0 +1,239 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * Transforms between a normalized format (integer or float) and a percentage value. + * + * @author Bernhard Schussek + * @author Florian Eckerstorfer + * + * @implements DataTransformerInterface + */ +class PercentToLocalizedStringTransformer implements DataTransformerInterface +{ + public const FRACTIONAL = 'fractional'; + public const INTEGER = 'integer'; + + protected static $types = [ + self::FRACTIONAL, + self::INTEGER, + ]; + + private int $roundingMode; + private string $type; + private int $scale; + private bool $html5Format; + + /** + * @see self::$types for a list of supported types + * + * @param int $roundingMode A value from \NumberFormatter, such as \NumberFormatter::ROUND_HALFUP + * @param bool $html5Format Use an HTML5 specific format, see https://www.w3.org/TR/html51/sec-forms.html#date-time-and-number-formats + * + * @throws UnexpectedTypeException if the given value of type is unknown + */ + public function __construct(?int $scale = null, ?string $type = null, int $roundingMode = \NumberFormatter::ROUND_HALFUP, bool $html5Format = false) + { + $type ??= self::FRACTIONAL; + + if (!\in_array($type, self::$types, true)) { + throw new UnexpectedTypeException($type, implode('", "', self::$types)); + } + + $this->type = $type; + $this->scale = $scale ?? 0; + $this->roundingMode = $roundingMode; + $this->html5Format = $html5Format; + } + + /** + * Transforms between a normalized format (integer or float) into a percentage value. + * + * @param int|float $value Normalized value + * + * @throws TransformationFailedException if the given value is not numeric or + * if the value could not be transformed + */ + public function transform(mixed $value): string + { + if (null === $value) { + return ''; + } + + if (!is_numeric($value)) { + throw new TransformationFailedException('Expected a numeric.'); + } + + if (self::FRACTIONAL == $this->type) { + $value *= 100; + } + + $formatter = $this->getNumberFormatter(); + $value = $formatter->format($value); + + if (intl_is_failure($formatter->getErrorCode())) { + throw new TransformationFailedException($formatter->getErrorMessage()); + } + + // replace the UTF-8 non break spaces + return $value; + } + + /** + * Transforms between a percentage value into a normalized format (integer or float). + * + * @param string $value Percentage value + * + * @throws TransformationFailedException if the given value is not a string or + * if the value could not be transformed + */ + public function reverseTransform(mixed $value): int|float|null + { + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + if ('' === $value) { + return null; + } + + $position = 0; + $formatter = $this->getNumberFormatter(); + $groupSep = $formatter->getSymbol(\NumberFormatter::GROUPING_SEPARATOR_SYMBOL); + $decSep = $formatter->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); + $grouping = $formatter->getAttribute(\NumberFormatter::GROUPING_USED); + + if ('.' !== $decSep && (!$grouping || '.' !== $groupSep)) { + $value = str_replace('.', $decSep, $value); + } + + if (',' !== $decSep && (!$grouping || ',' !== $groupSep)) { + $value = str_replace(',', $decSep, $value); + } + + if (str_contains($value, $decSep)) { + $type = \NumberFormatter::TYPE_DOUBLE; + } else { + $type = \PHP_INT_SIZE === 8 ? \NumberFormatter::TYPE_INT64 : \NumberFormatter::TYPE_INT32; + } + + try { + // replace normal spaces so that the formatter can read them + $result = @$formatter->parse(str_replace(' ', "\xc2\xa0", $value), $type, $position); + } catch (\IntlException $e) { + throw new TransformationFailedException($e->getMessage(), 0, $e); + } + + if (intl_is_failure($formatter->getErrorCode())) { + throw new TransformationFailedException($formatter->getErrorMessage(), $formatter->getErrorCode()); + } + + if (self::FRACTIONAL == $this->type) { + $result /= 100; + } + + if (\function_exists('mb_detect_encoding') && false !== $encoding = mb_detect_encoding($value, null, true)) { + $length = mb_strlen($value, $encoding); + $remainder = mb_substr($value, $position, $length, $encoding); + } else { + $length = \strlen($value); + $remainder = substr($value, $position, $length); + } + + // After parsing, position holds the index of the character where the + // parsing stopped + if ($position < $length) { + // Check if there are unrecognized characters at the end of the + // number (excluding whitespace characters) + $remainder = trim($remainder, " \t\n\r\0\x0b\xc2\xa0"); + + if ('' !== $remainder) { + throw new TransformationFailedException(sprintf('The number contains unrecognized characters: "%s".', $remainder)); + } + } + + return $this->round($result); + } + + /** + * Returns a preconfigured \NumberFormatter instance. + */ + protected function getNumberFormatter(): \NumberFormatter + { + // Values used in HTML5 number inputs should be formatted as in "1234.5", ie. 'en' format without grouping, + // according to https://www.w3.org/TR/html51/sec-forms.html#date-time-and-number-formats + $formatter = new \NumberFormatter($this->html5Format ? 'en' : \Locale::getDefault(), \NumberFormatter::DECIMAL); + + if ($this->html5Format) { + $formatter->setAttribute(\NumberFormatter::GROUPING_USED, 0); + } + + $formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $this->scale); + + if (null !== $this->roundingMode) { + $formatter->setAttribute(\NumberFormatter::ROUNDING_MODE, $this->roundingMode); + } + + return $formatter; + } + + /** + * Rounds a number according to the configured scale and rounding mode. + */ + private function round(int|float $number): int|float + { + if (null !== $this->scale && null !== $this->roundingMode) { + // shift number to maintain the correct scale during rounding + $roundingCoef = 10 ** $this->scale; + + if (self::FRACTIONAL == $this->type) { + $roundingCoef *= 100; + } + + // string representation to avoid rounding errors, similar to bcmul() + $number = (string) ($number * $roundingCoef); + + switch ($this->roundingMode) { + case \NumberFormatter::ROUND_CEILING: + $number = ceil($number); + break; + case \NumberFormatter::ROUND_FLOOR: + $number = floor($number); + break; + case \NumberFormatter::ROUND_UP: + $number = $number > 0 ? ceil($number) : floor($number); + break; + case \NumberFormatter::ROUND_DOWN: + $number = $number > 0 ? floor($number) : ceil($number); + break; + case \NumberFormatter::ROUND_HALFEVEN: + $number = round($number, 0, \PHP_ROUND_HALF_EVEN); + break; + case \NumberFormatter::ROUND_HALFUP: + $number = round($number, 0, \PHP_ROUND_HALF_UP); + break; + case \NumberFormatter::ROUND_HALFDOWN: + $number = round($number, 0, \PHP_ROUND_HALF_DOWN); + break; + } + + $number = 1 === $roundingCoef ? (int) $number : $number / $roundingCoef; + } + + return $number; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/StringToFloatTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/StringToFloatTransformer.php new file mode 100644 index 000000000..49b4ea98a --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/StringToFloatTransformer.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * @implements DataTransformerInterface + */ +class StringToFloatTransformer implements DataTransformerInterface +{ + private ?int $scale; + + public function __construct(?int $scale = null) + { + $this->scale = $scale; + } + + public function transform(mixed $value): ?float + { + if (null === $value) { + return null; + } + + if (!\is_string($value) || !is_numeric($value)) { + throw new TransformationFailedException('Expected a numeric string.'); + } + + return (float) $value; + } + + public function reverseTransform(mixed $value): ?string + { + if (null === $value) { + return null; + } + + if (!\is_int($value) && !\is_float($value)) { + throw new TransformationFailedException('Expected a numeric.'); + } + + if ($this->scale > 0) { + return number_format((float) $value, $this->scale, '.', ''); + } + + return (string) $value; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/UlidToStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/UlidToStringTransformer.php new file mode 100644 index 000000000..7ace73ad0 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/UlidToStringTransformer.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Uid\Ulid; + +/** + * Transforms between a ULID string and a Ulid object. + * + * @author Pavel Dyakonov + * + * @implements DataTransformerInterface + */ +class UlidToStringTransformer implements DataTransformerInterface +{ + /** + * Transforms a Ulid object into a string. + * + * @param Ulid $value A Ulid object + * + * @throws TransformationFailedException If the given value is not a Ulid object + */ + public function transform(mixed $value): ?string + { + if (null === $value) { + return null; + } + + if (!$value instanceof Ulid) { + throw new TransformationFailedException('Expected a Ulid.'); + } + + return (string) $value; + } + + /** + * Transforms a ULID string into a Ulid object. + * + * @param string $value A ULID string + * + * @throws TransformationFailedException If the given value is not a string, + * or could not be transformed + */ + public function reverseTransform(mixed $value): ?Ulid + { + if (null === $value || '' === $value) { + return null; + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + try { + $ulid = new Ulid($value); + } catch (\InvalidArgumentException $e) { + throw new TransformationFailedException(sprintf('The value "%s" is not a valid ULID.', $value), $e->getCode(), $e); + } + + return $ulid; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/UuidToStringTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/UuidToStringTransformer.php new file mode 100644 index 000000000..cc794a024 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/UuidToStringTransformer.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Uid\Uuid; + +/** + * Transforms between a UUID string and a Uuid object. + * + * @author Pavel Dyakonov + * + * @implements DataTransformerInterface + */ +class UuidToStringTransformer implements DataTransformerInterface +{ + /** + * Transforms a Uuid object into a string. + * + * @param Uuid $value A Uuid object + * + * @throws TransformationFailedException If the given value is not a Uuid object + */ + public function transform(mixed $value): ?string + { + if (null === $value) { + return null; + } + + if (!$value instanceof Uuid) { + throw new TransformationFailedException('Expected a Uuid.'); + } + + return (string) $value; + } + + /** + * Transforms a UUID string into a Uuid object. + * + * @param string $value A UUID string + * + * @throws TransformationFailedException If the given value is not a string, + * or could not be transformed + */ + public function reverseTransform(mixed $value): ?Uuid + { + if (null === $value || '' === $value) { + return null; + } + + if (!\is_string($value)) { + throw new TransformationFailedException('Expected a string.'); + } + + if (!Uuid::isValid($value)) { + throw new TransformationFailedException(sprintf('The value "%s" is not a valid UUID.', $value)); + } + + try { + return Uuid::fromString($value); + } catch (\InvalidArgumentException $e) { + throw new TransformationFailedException(sprintf('The value "%s" is not a valid UUID.', $value), $e->getCode(), $e); + } + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/ValueToDuplicatesTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/ValueToDuplicatesTransformer.php new file mode 100644 index 000000000..083397bb4 --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/ValueToDuplicatesTransformer.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * @author Bernhard Schussek + * + * @implements DataTransformerInterface + */ +class ValueToDuplicatesTransformer implements DataTransformerInterface +{ + private array $keys; + + public function __construct(array $keys) + { + $this->keys = $keys; + } + + /** + * Duplicates the given value through the array. + */ + public function transform(mixed $value): array + { + $result = []; + + foreach ($this->keys as $key) { + $result[$key] = $value; + } + + return $result; + } + + /** + * Extracts the duplicated value from an array. + * + * @throws TransformationFailedException if the given value is not an array or + * if the given array cannot be transformed + */ + public function reverseTransform(mixed $array): mixed + { + if (!\is_array($array)) { + throw new TransformationFailedException('Expected an array.'); + } + + $result = current($array); + $emptyKeys = []; + + foreach ($this->keys as $key) { + if (isset($array[$key]) && false !== $array[$key] && [] !== $array[$key]) { + if ($array[$key] !== $result) { + throw new TransformationFailedException('All values in the array should be the same.'); + } + } else { + $emptyKeys[] = $key; + } + } + + if (\count($emptyKeys) > 0) { + if (\count($emptyKeys) == \count($this->keys)) { + // All keys empty + return null; + } + + throw new TransformationFailedException(sprintf('The keys "%s" should not be empty.', implode('", "', $emptyKeys))); + } + + return $result; + } +} diff --git a/lib/symfony/form/Extension/Core/DataTransformer/WeekToArrayTransformer.php b/lib/symfony/form/Extension/Core/DataTransformer/WeekToArrayTransformer.php new file mode 100644 index 000000000..c10bc735f --- /dev/null +++ b/lib/symfony/form/Extension/Core/DataTransformer/WeekToArrayTransformer.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\DataTransformer; + +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Exception\TransformationFailedException; + +/** + * Transforms between an ISO 8601 week date string and an array. + * + * @author Damien Fayet + * + * @implements DataTransformerInterface + */ +class WeekToArrayTransformer implements DataTransformerInterface +{ + /** + * Transforms a string containing an ISO 8601 week date into an array. + * + * @param string|null $value A week date string + * + * @return array{year: int|null, week: int|null} + * + * @throws TransformationFailedException If the given value is not a string, + * or if the given value does not follow the right format + */ + public function transform(mixed $value): array + { + if (null === $value) { + return ['year' => null, 'week' => null]; + } + + if (!\is_string($value)) { + throw new TransformationFailedException(sprintf('Value is expected to be a string but was "%s".', get_debug_type($value))); + } + + if (0 === preg_match('/^(?P\d{4})-W(?P\d{2})$/', $value, $matches)) { + throw new TransformationFailedException('Given data does not follow the date format "Y-\WW".'); + } + + return [ + 'year' => (int) $matches['year'], + 'week' => (int) $matches['week'], + ]; + } + + /** + * Transforms an array into a week date string. + * + * @param array{year: int|null, week: int|null} $value + * + * @return string|null A week date string following the format Y-\WW + * + * @throws TransformationFailedException If the given value cannot be merged in a valid week date string, + * or if the obtained week date does not exists + */ + public function reverseTransform(mixed $value): ?string + { + if (null === $value || [] === $value) { + return null; + } + + if (!\is_array($value)) { + throw new TransformationFailedException(sprintf('Value is expected to be an array, but was "%s".', get_debug_type($value))); + } + + if (!\array_key_exists('year', $value)) { + throw new TransformationFailedException('Key "year" is missing.'); + } + + if (!\array_key_exists('week', $value)) { + throw new TransformationFailedException('Key "week" is missing.'); + } + + if ($additionalKeys = array_diff(array_keys($value), ['year', 'week'])) { + throw new TransformationFailedException(sprintf('Expected only keys "year" and "week" to be present, but also got ["%s"].', implode('", "', $additionalKeys))); + } + + if (null === $value['year'] && null === $value['week']) { + return null; + } + + if (!\is_int($value['year'])) { + throw new TransformationFailedException(sprintf('Year is expected to be an integer, but was "%s".', get_debug_type($value['year']))); + } + + if (!\is_int($value['week'])) { + throw new TransformationFailedException(sprintf('Week is expected to be an integer, but was "%s".', get_debug_type($value['week']))); + } + + // The 28th December is always in the last week of the year + if (date('W', strtotime('28th December '.$value['year'])) < $value['week']) { + throw new TransformationFailedException(sprintf('Week "%d" does not exist for year "%d".', $value['week'], $value['year'])); + } + + return sprintf('%d-W%02d', $value['year'], $value['week']); + } +} diff --git a/lib/symfony/form/Extension/Core/EventListener/FixUrlProtocolListener.php b/lib/symfony/form/Extension/Core/EventListener/FixUrlProtocolListener.php new file mode 100644 index 000000000..718997754 --- /dev/null +++ b/lib/symfony/form/Extension/Core/EventListener/FixUrlProtocolListener.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; + +/** + * Adds a protocol to a URL if it doesn't already have one. + * + * @author Bernhard Schussek + */ +class FixUrlProtocolListener implements EventSubscriberInterface +{ + private ?string $defaultProtocol; + + /** + * @param string|null $defaultProtocol The URL scheme to add when there is none or null to not modify the data + */ + public function __construct(?string $defaultProtocol = 'http') + { + $this->defaultProtocol = $defaultProtocol; + } + + /** + * @return void + */ + public function onSubmit(FormEvent $event) + { + $data = $event->getData(); + + if ($this->defaultProtocol && $data && \is_string($data) && !preg_match('~^(?:[/.]|[\w+.-]+://|[^:/?@#]++@)~', $data)) { + $event->setData($this->defaultProtocol.'://'.$data); + } + } + + public static function getSubscribedEvents(): array + { + return [FormEvents::SUBMIT => 'onSubmit']; + } +} diff --git a/lib/symfony/form/Extension/Core/EventListener/MergeCollectionListener.php b/lib/symfony/form/Extension/Core/EventListener/MergeCollectionListener.php new file mode 100644 index 000000000..62cd0a42a --- /dev/null +++ b/lib/symfony/form/Extension/Core/EventListener/MergeCollectionListener.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; + +/** + * @author Bernhard Schussek + */ +class MergeCollectionListener implements EventSubscriberInterface +{ + private bool $allowAdd; + private bool $allowDelete; + + /** + * @param bool $allowAdd Whether values might be added to the collection + * @param bool $allowDelete Whether values might be removed from the collection + */ + public function __construct(bool $allowAdd = false, bool $allowDelete = false) + { + $this->allowAdd = $allowAdd; + $this->allowDelete = $allowDelete; + } + + public static function getSubscribedEvents(): array + { + return [ + FormEvents::SUBMIT => 'onSubmit', + ]; + } + + /** + * @return void + */ + public function onSubmit(FormEvent $event) + { + $dataToMergeInto = $event->getForm()->getNormData(); + $data = $event->getData() ?? []; + + if (!\is_array($data) && !($data instanceof \Traversable && $data instanceof \ArrayAccess)) { + throw new UnexpectedTypeException($data, 'array or (\Traversable and \ArrayAccess)'); + } + + if (null !== $dataToMergeInto && !\is_array($dataToMergeInto) && !($dataToMergeInto instanceof \Traversable && $dataToMergeInto instanceof \ArrayAccess)) { + throw new UnexpectedTypeException($dataToMergeInto, 'array or (\Traversable and \ArrayAccess)'); + } + + // If we are not allowed to change anything, return immediately + if ($data === $dataToMergeInto || (!$this->allowAdd && !$this->allowDelete)) { + $event->setData($dataToMergeInto); + + return; + } + + if (null === $dataToMergeInto) { + // No original data was set. Set it if allowed + if ($this->allowAdd) { + $dataToMergeInto = $data; + } + } else { + // Calculate delta + $itemsToAdd = \is_object($data) ? clone $data : $data; + $itemsToDelete = []; + + foreach ($dataToMergeInto as $beforeKey => $beforeItem) { + foreach ($data as $afterKey => $afterItem) { + if ($afterItem === $beforeItem) { + // Item found, next original item + unset($itemsToAdd[$afterKey]); + continue 2; + } + } + + // Item not found, remember for deletion + $itemsToDelete[] = $beforeKey; + } + + // Remove deleted items before adding to free keys that are to be + // replaced + if ($this->allowDelete) { + foreach ($itemsToDelete as $key) { + unset($dataToMergeInto[$key]); + } + } + + // Add remaining items + if ($this->allowAdd) { + foreach ($itemsToAdd as $key => $item) { + if (!isset($dataToMergeInto[$key])) { + $dataToMergeInto[$key] = $item; + } else { + $dataToMergeInto[] = $item; + } + } + } + } + + $event->setData($dataToMergeInto); + } +} diff --git a/lib/symfony/form/Extension/Core/EventListener/ResizeFormListener.php b/lib/symfony/form/Extension/Core/EventListener/ResizeFormListener.php new file mode 100644 index 000000000..63b09266a --- /dev/null +++ b/lib/symfony/form/Extension/Core/EventListener/ResizeFormListener.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; + +/** + * Resize a collection form element based on the data sent from the client. + * + * @author Bernhard Schussek + */ +class ResizeFormListener implements EventSubscriberInterface +{ + protected $type; + protected $options; + protected $prototypeOptions; + protected $allowAdd; + protected $allowDelete; + + private \Closure|bool $deleteEmpty; + + public function __construct(string $type, array $options = [], bool $allowAdd = false, bool $allowDelete = false, bool|callable $deleteEmpty = false, ?array $prototypeOptions = null) + { + $this->type = $type; + $this->allowAdd = $allowAdd; + $this->allowDelete = $allowDelete; + $this->options = $options; + $this->deleteEmpty = \is_bool($deleteEmpty) ? $deleteEmpty : $deleteEmpty(...); + $this->prototypeOptions = $prototypeOptions ?? $options; + } + + public static function getSubscribedEvents(): array + { + return [ + FormEvents::PRE_SET_DATA => 'preSetData', + FormEvents::PRE_SUBMIT => 'preSubmit', + // (MergeCollectionListener, MergeDoctrineCollectionListener) + FormEvents::SUBMIT => ['onSubmit', 50], + ]; + } + + /** + * @return void + */ + public function preSetData(FormEvent $event) + { + $form = $event->getForm(); + $data = $event->getData() ?? []; + + if (!\is_array($data) && !($data instanceof \Traversable && $data instanceof \ArrayAccess)) { + throw new UnexpectedTypeException($data, 'array or (\Traversable and \ArrayAccess)'); + } + + // First remove all rows + foreach ($form as $name => $child) { + $form->remove($name); + } + + // Then add all rows again in the correct order + foreach ($data as $name => $value) { + $form->add($name, $this->type, array_replace([ + 'property_path' => '['.$name.']', + ], $this->options)); + } + } + + /** + * @return void + */ + public function preSubmit(FormEvent $event) + { + $form = $event->getForm(); + $data = $event->getData(); + + if (!\is_array($data)) { + $data = []; + } + + // Remove all empty rows + if ($this->allowDelete) { + foreach ($form as $name => $child) { + if (!isset($data[$name])) { + $form->remove($name); + } + } + } + + // Add all additional rows + if ($this->allowAdd) { + foreach ($data as $name => $value) { + if (!$form->has($name)) { + $form->add($name, $this->type, array_replace([ + 'property_path' => '['.$name.']', + ], $this->prototypeOptions)); + } + } + } + } + + /** + * @return void + */ + public function onSubmit(FormEvent $event) + { + $form = $event->getForm(); + $data = $event->getData() ?? []; + + // At this point, $data is an array or an array-like object that already contains the + // new entries, which were added by the data mapper. The data mapper ignores existing + // entries, so we need to manually unset removed entries in the collection. + + if (!\is_array($data) && !($data instanceof \Traversable && $data instanceof \ArrayAccess)) { + throw new UnexpectedTypeException($data, 'array or (\Traversable and \ArrayAccess)'); + } + + if ($this->deleteEmpty) { + $previousData = $form->getData(); + /** @var FormInterface $child */ + foreach ($form as $name => $child) { + if (!$child->isValid() || !$child->isSynchronized()) { + continue; + } + + $isNew = !isset($previousData[$name]); + $isEmpty = \is_callable($this->deleteEmpty) ? ($this->deleteEmpty)($child->getData()) : $child->isEmpty(); + + // $isNew can only be true if allowAdd is true, so we don't + // need to check allowAdd again + if ($isEmpty && ($isNew || $this->allowDelete)) { + unset($data[$name]); + $form->remove($name); + } + } + } + + // The data mapper only adds, but does not remove items, so do this + // here + if ($this->allowDelete) { + $toDelete = []; + + foreach ($data as $name => $child) { + if (!$form->has($name)) { + $toDelete[] = $name; + } + } + + foreach ($toDelete as $name) { + unset($data[$name]); + } + } + + $event->setData($data); + } +} diff --git a/lib/symfony/form/Extension/Core/EventListener/TransformationFailureListener.php b/lib/symfony/form/Extension/Core/EventListener/TransformationFailureListener.php new file mode 100644 index 000000000..cb9a675be --- /dev/null +++ b/lib/symfony/form/Extension/Core/EventListener/TransformationFailureListener.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\Core\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @author Christian Flothmann + */ +class TransformationFailureListener implements EventSubscriberInterface +{ + private ?TranslatorInterface $translator; + + public function __construct(?TranslatorInterface $translator = null) + { + $this->translator = $translator; + } + + public static function getSubscribedEvents(): array + { + return [ + FormEvents::POST_SUBMIT => ['convertTransformationFailureToFormError', -1024], + ]; + } + + /** + * @return void + */ + public function convertTransformationFailureToFormError(FormEvent $event) + { + $form = $event->getForm(); + + if (null === $form->getTransformationFailure() || !$form->isValid()) { + return; + } + + foreach ($form as $child) { + if (!$child->isSynchronized()) { + return; + } + } + + $clientDataAsString = \is_scalar($form->getViewData()) ? (string) $form->getViewData() : get_debug_type($form->getViewData()); + $messageTemplate = $form->getConfig()->getOption('invalid_message', 'The value {{ value }} is not valid.'); + $messageParameters = array_replace(['{{ value }}' => $clientDataAsString], $form->getConfig()->getOption('invalid_message_parameters', [])); + + if (null !== $this->translator) { + $message = $this->translator->trans($messageTemplate, $messageParameters); + } else { + $message = strtr($messageTemplate, $messageParameters); + } + + $form->addError(new FormError($message, $messageTemplate, $messageParameters, null, $form->getTransformationFailure())); + } +} diff --git a/lib/symfony/form/Extension/Core/EventListener/TrimListener.php b/lib/symfony/form/Extension/Core/EventListener/TrimListener.php new file mode 100644 index 000000000..81a55f3cb --- /dev/null +++ b/lib/symfony/form/Extension/Core/EventListener/TrimListener.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\Util\StringUtil; + +/** + * Trims string data. + * + * @author Bernhard Schussek + */ +class TrimListener implements EventSubscriberInterface +{ + /** + * @return void + */ + public function preSubmit(FormEvent $event) + { + $data = $event->getData(); + + if (!\is_string($data)) { + return; + } + + $event->setData(StringUtil::trim($data)); + } + + public static function getSubscribedEvents(): array + { + return [FormEvents::PRE_SUBMIT => 'preSubmit']; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/BaseType.php b/lib/symfony/form/Extension/Core/Type/BaseType.php new file mode 100644 index 000000000..5e2ae2248 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/BaseType.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractRendererEngine; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Encapsulates common logic of {@link FormType} and {@link ButtonType}. + * + * This type does not appear in the form's type inheritance chain and as such + * cannot be extended (via {@link \Symfony\Component\Form\FormExtensionInterface}) nor themed. + * + * @author Bernhard Schussek + */ +abstract class BaseType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->setDisabled($options['disabled']); + $builder->setAutoInitialize($options['auto_initialize']); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $name = $form->getName(); + $blockName = $options['block_name'] ?: $form->getName(); + $translationDomain = $options['translation_domain']; + $labelTranslationParameters = $options['label_translation_parameters']; + $attrTranslationParameters = $options['attr_translation_parameters']; + $labelFormat = $options['label_format']; + + if ($view->parent) { + if ('' !== ($parentFullName = $view->parent->vars['full_name'])) { + $id = sprintf('%s_%s', $view->parent->vars['id'], $name); + $fullName = sprintf('%s[%s]', $parentFullName, $name); + $uniqueBlockPrefix = sprintf('%s_%s', $view->parent->vars['unique_block_prefix'], $blockName); + } else { + $id = $name; + $fullName = $name; + $uniqueBlockPrefix = '_'.$blockName; + } + + $translationDomain ??= $view->parent->vars['translation_domain']; + + $labelTranslationParameters = array_merge($view->parent->vars['label_translation_parameters'], $labelTranslationParameters); + $attrTranslationParameters = array_merge($view->parent->vars['attr_translation_parameters'], $attrTranslationParameters); + + if (!$labelFormat) { + $labelFormat = $view->parent->vars['label_format']; + } + + $rootFormAttrOption = $form->getRoot()->getConfig()->getOption('form_attr'); + if ($options['form_attr'] || $rootFormAttrOption) { + $options['attr']['form'] = \is_string($rootFormAttrOption) ? $rootFormAttrOption : $form->getRoot()->getName(); + if (empty($options['attr']['form'])) { + throw new LogicException('"form_attr" option must be a string identifier on root form when it has no id.'); + } + } + } else { + $id = \is_string($options['form_attr']) ? $options['form_attr'] : $name; + $fullName = $name; + $uniqueBlockPrefix = '_'.$blockName; + + // Strip leading underscores and digits. These are allowed in + // form names, but not in HTML4 ID attributes. + // https://www.w3.org/TR/html401/struct/global#adef-id + $id = ltrim($id, '_0123456789'); + } + + $blockPrefixes = []; + for ($type = $form->getConfig()->getType(); null !== $type; $type = $type->getParent()) { + array_unshift($blockPrefixes, $type->getBlockPrefix()); + } + if (null !== $options['block_prefix']) { + $blockPrefixes[] = $options['block_prefix']; + } + $blockPrefixes[] = $uniqueBlockPrefix; + + $view->vars = array_replace($view->vars, [ + 'form' => $view, + 'id' => $id, + 'name' => $name, + 'full_name' => $fullName, + 'disabled' => $form->isDisabled(), + 'label' => $options['label'], + 'label_format' => $labelFormat, + 'label_html' => $options['label_html'], + 'multipart' => false, + 'attr' => $options['attr'], + 'block_prefixes' => $blockPrefixes, + 'unique_block_prefix' => $uniqueBlockPrefix, + 'row_attr' => $options['row_attr'], + 'translation_domain' => $translationDomain, + 'label_translation_parameters' => $labelTranslationParameters, + 'attr_translation_parameters' => $attrTranslationParameters, + 'priority' => $options['priority'], + // Using the block name here speeds up performance in collection + // forms, where each entry has the same full block name. + // Including the type is important too, because if rows of a + // collection form have different types (dynamically), they should + // be rendered differently. + // https://github.com/symfony/symfony/issues/5038 + AbstractRendererEngine::CACHE_KEY_VAR => $uniqueBlockPrefix.'_'.$form->getConfig()->getType()->getBlockPrefix(), + ]); + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'block_name' => null, + 'block_prefix' => null, + 'disabled' => false, + 'label' => null, + 'label_format' => null, + 'row_attr' => [], + 'label_html' => false, + 'label_translation_parameters' => [], + 'attr_translation_parameters' => [], + 'attr' => [], + 'translation_domain' => null, + 'auto_initialize' => true, + 'priority' => 0, + 'form_attr' => false, + ]); + + $resolver->setAllowedTypes('block_prefix', ['null', 'string']); + $resolver->setAllowedTypes('attr', 'array'); + $resolver->setAllowedTypes('row_attr', 'array'); + $resolver->setAllowedTypes('label_html', 'bool'); + $resolver->setAllowedTypes('priority', 'int'); + $resolver->setAllowedTypes('form_attr', ['bool', 'string']); + + $resolver->setInfo('priority', 'The form rendering priority (higher priorities will be rendered first)'); + } +} diff --git a/lib/symfony/form/Extension/Core/Type/BirthdayType.php b/lib/symfony/form/Extension/Core/Type/BirthdayType.php new file mode 100644 index 000000000..fa60d016e --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/BirthdayType.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class BirthdayType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'years' => range((int) date('Y') - 120, date('Y')), + 'invalid_message' => 'Please enter a valid birthdate.', + ]); + + $resolver->setAllowedTypes('years', 'array'); + } + + public function getParent(): ?string + { + return DateType::class; + } + + public function getBlockPrefix(): string + { + return 'birthday'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/ButtonType.php b/lib/symfony/form/Extension/Core/Type/ButtonType.php new file mode 100644 index 000000000..d71054640 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/ButtonType.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\ButtonTypeInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * A form button. + * + * @author Bernhard Schussek + */ +class ButtonType extends BaseType implements ButtonTypeInterface +{ + public function getParent(): ?string + { + return null; + } + + public function getBlockPrefix(): string + { + return 'button'; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefault('auto_initialize', false); + } +} diff --git a/lib/symfony/form/Extension/Core/Type/CheckboxType.php b/lib/symfony/form/Extension/Core/Type/CheckboxType.php new file mode 100644 index 000000000..291ede93e --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/CheckboxType.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\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\DataTransformer\BooleanToStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class CheckboxType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + // Unlike in other types, where the data is NULL by default, it + // needs to be a Boolean here. setData(null) is not acceptable + // for checkboxes and radio buttons (unless a custom model + // transformer handles this case). + // We cannot solve this case via overriding the "data" option, because + // doing so also calls setDataLocked(true). + $builder->setData($options['data'] ?? false); + $builder->addViewTransformer(new BooleanToStringTransformer($options['value'], $options['false_values'])); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars = array_replace($view->vars, [ + 'value' => $options['value'], + 'checked' => null !== $form->getViewData(), + ]); + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $emptyData = static fn (FormInterface $form, $viewData) => $viewData; + + $resolver->setDefaults([ + 'value' => '1', + 'empty_data' => $emptyData, + 'compound' => false, + 'false_values' => [null], + 'invalid_message' => 'The checkbox has an invalid value.', + 'is_empty_callback' => static fn ($modelData): bool => false === $modelData, + ]); + + $resolver->setAllowedTypes('false_values', 'array'); + } + + public function getBlockPrefix(): string + { + return 'checkbox'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/ChoiceType.php b/lib/symfony/form/Extension/Core/Type/ChoiceType.php new file mode 100644 index 000000000..32bc67766 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/ChoiceType.php @@ -0,0 +1,478 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceListInterface; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceAttr; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFieldName; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFilter; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLabel; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLoader; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceTranslationParameters; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceValue; +use Symfony\Component\Form\ChoiceList\Factory\Cache\GroupBy; +use Symfony\Component\Form\ChoiceList\Factory\Cache\PreferredChoice; +use Symfony\Component\Form\ChoiceList\Factory\CachingFactoryDecorator; +use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface; +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\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\TransformationFailedException; +use Symfony\Component\Form\Extension\Core\DataMapper\CheckboxListMapper; +use Symfony\Component\Form\Extension\Core\DataMapper\RadioListMapper; +use Symfony\Component\Form\Extension\Core\DataTransformer\ChoicesToValuesTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\ChoiceToValueTransformer; +use Symfony\Component\Form\Extension\Core\EventListener\MergeCollectionListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Contracts\Translation\TranslatorInterface; + +class ChoiceType extends AbstractType +{ + private ChoiceListFactoryInterface $choiceListFactory; + private ?TranslatorInterface $translator; + + public function __construct(?ChoiceListFactoryInterface $choiceListFactory = null, ?TranslatorInterface $translator = null) + { + $this->choiceListFactory = $choiceListFactory ?? new CachingFactoryDecorator( + new PropertyAccessDecorator( + new DefaultChoiceListFactory() + ) + ); + $this->translator = $translator; + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $unknownValues = []; + $choiceList = $this->createChoiceList($options); + $builder->setAttribute('choice_list', $choiceList); + + if ($options['expanded']) { + $builder->setDataMapper($options['multiple'] ? new CheckboxListMapper() : new RadioListMapper()); + + // Initialize all choices before doing the index check below. + // This helps in cases where index checks are optimized for non + // initialized choice lists. For example, when using an SQL driver, + // the index check would read in one SQL query and the initialization + // requires another SQL query. When the initialization is done first, + // one SQL query is sufficient. + + $choiceListView = $this->createChoiceListView($choiceList, $options); + $builder->setAttribute('choice_list_view', $choiceListView); + + // Check if the choices already contain the empty value + // Only add the placeholder option if this is not the case + if (null !== $options['placeholder'] && 0 === \count($choiceList->getChoicesForValues(['']))) { + $placeholderView = new ChoiceView(null, '', $options['placeholder'], $options['placeholder_attr']); + + // "placeholder" is a reserved name + $this->addSubForm($builder, 'placeholder', $placeholderView, $options); + } + + $this->addSubForms($builder, $choiceListView->preferredChoices, $options); + $this->addSubForms($builder, $choiceListView->choices, $options); + } + + if ($options['expanded'] || $options['multiple']) { + // Make sure that scalar, submitted values are converted to arrays + // which can be submitted to the checkboxes/radio buttons + $builder->addEventListener(FormEvents::PRE_SUBMIT, static function (FormEvent $event) use ($choiceList, $options, &$unknownValues) { + /** @var PreSubmitEvent $event */ + $form = $event->getForm(); + $data = $event->getData(); + + // Since the type always use mapper an empty array will not be + // considered as empty in Form::submit(), we need to evaluate + // empty data here so its value is submitted to sub forms + if (null === $data) { + $emptyData = $form->getConfig()->getEmptyData(); + $data = $emptyData instanceof \Closure ? $emptyData($form, $data) : $emptyData; + } + + // Convert the submitted data to a string, if scalar, before + // casting it to an array + if (!\is_array($data)) { + if ($options['multiple']) { + throw new TransformationFailedException('Expected an array.'); + } + + $data = (array) (string) $data; + } + + // A map from submitted values to integers + $valueMap = array_flip($data); + + // Make a copy of the value map to determine whether any unknown + // values were submitted + $unknownValues = $valueMap; + + // Reconstruct the data as mapping from child names to values + $knownValues = []; + + if ($options['expanded']) { + /** @var FormInterface $child */ + foreach ($form as $child) { + $value = $child->getConfig()->getOption('value'); + + // Add the value to $data with the child's name as key + if (isset($valueMap[$value])) { + $knownValues[$child->getName()] = $value; + unset($unknownValues[$value]); + continue; + } else { + $knownValues[$child->getName()] = null; + } + } + } else { + foreach ($choiceList->getChoicesForValues($data) as $key => $choice) { + $knownValues[] = $data[$key]; + unset($unknownValues[$data[$key]]); + } + } + + // The empty value is always known, independent of whether a + // field exists for it or not + unset($unknownValues['']); + + // Throw exception if unknown values were submitted (multiple choices will be handled in a different event listener below) + if (\count($unknownValues) > 0 && !$options['multiple']) { + throw new TransformationFailedException(sprintf('The choices "%s" do not exist in the choice list.', implode('", "', array_keys($unknownValues)))); + } + + $event->setData($knownValues); + }); + } + + if ($options['multiple']) { + $messageTemplate = $options['invalid_message'] ?? 'The value {{ value }} is not valid.'; + $translator = $this->translator; + + $builder->addEventListener(FormEvents::POST_SUBMIT, static function (FormEvent $event) use (&$unknownValues, $messageTemplate, $translator) { + // Throw exception if unknown values were submitted + if (\count($unknownValues) > 0) { + $form = $event->getForm(); + + $clientDataAsString = \is_scalar($form->getViewData()) ? (string) $form->getViewData() : (\is_array($form->getViewData()) ? implode('", "', array_keys($unknownValues)) : \gettype($form->getViewData())); + + if ($translator) { + $message = $translator->trans($messageTemplate, ['{{ value }}' => $clientDataAsString], 'validators'); + } else { + $message = strtr($messageTemplate, ['{{ value }}' => $clientDataAsString]); + } + + $form->addError(new FormError($message, $messageTemplate, ['{{ value }}' => $clientDataAsString], null, new TransformationFailedException(sprintf('The choices "%s" do not exist in the choice list.', $clientDataAsString)))); + } + }); + + // tag without "multiple" option or list of radio inputs + $builder->addViewTransformer(new ChoiceToValueTransformer($choiceList)); + } + + if ($options['multiple'] && $options['by_reference']) { + // Make sure the collection created during the client->norm + // transformation is merged back into the original collection + $builder->addEventSubscriber(new MergeCollectionListener(true, true)); + } + + // To avoid issues when the submitted choices are arrays (i.e. array to string conversions), + // we have to ensure that all elements of the submitted choice data are NULL, strings or ints. + $builder->addEventListener(FormEvents::PRE_SUBMIT, static function (FormEvent $event) { + $data = $event->getData(); + + if (!\is_array($data)) { + return; + } + + foreach ($data as $v) { + if (null !== $v && !\is_string($v) && !\is_int($v)) { + throw new TransformationFailedException('All choices submitted must be NULL, strings or ints.'); + } + } + }, 256); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $choiceTranslationDomain = $options['choice_translation_domain']; + if ($view->parent && null === $choiceTranslationDomain) { + $choiceTranslationDomain = $view->vars['translation_domain']; + } + + /** @var ChoiceListInterface $choiceList */ + $choiceList = $form->getConfig()->getAttribute('choice_list'); + + /** @var ChoiceListView $choiceListView */ + $choiceListView = $form->getConfig()->hasAttribute('choice_list_view') + ? $form->getConfig()->getAttribute('choice_list_view') + : $this->createChoiceListView($choiceList, $options); + + $view->vars = array_replace($view->vars, [ + 'multiple' => $options['multiple'], + 'expanded' => $options['expanded'], + 'preferred_choices' => $choiceListView->preferredChoices, + 'choices' => $choiceListView->choices, + 'separator' => '-------------------', + 'placeholder' => null, + 'placeholder_attr' => [], + 'choice_translation_domain' => $choiceTranslationDomain, + 'choice_translation_parameters' => $options['choice_translation_parameters'], + ]); + + // The decision, whether a choice is selected, is potentially done + // thousand of times during the rendering of a template. Provide a + // closure here that is optimized for the value of the form, to + // avoid making the type check inside the closure. + if ($options['multiple']) { + $view->vars['is_selected'] = static fn ($choice, array $values) => \in_array($choice, $values, true); + } else { + $view->vars['is_selected'] = static fn ($choice, $value) => $choice === $value; + } + + // Check if the choices already contain the empty value + $view->vars['placeholder_in_choices'] = $choiceListView->hasPlaceholder(); + + // Only add the empty value option if this is not the case + if (null !== $options['placeholder'] && !$view->vars['placeholder_in_choices']) { + $view->vars['placeholder'] = $options['placeholder']; + $view->vars['placeholder_attr'] = $options['placeholder_attr']; + } + + if ($options['multiple'] && !$options['expanded']) { + // Add "[]" to the name in case a select tag with multiple options is + // displayed. Otherwise only one of the selected options is sent in the + // POST request. + $view->vars['full_name'] .= '[]'; + } + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + $view->vars['duplicate_preferred_choices'] = $options['duplicate_preferred_choices']; + + if ($options['expanded']) { + // Radio buttons should have the same name as the parent + $childName = $view->vars['full_name']; + + // Checkboxes should append "[]" to allow multiple selection + if ($options['multiple']) { + $childName .= '[]'; + } + + foreach ($view as $childView) { + $childView->vars['full_name'] = $childName; + } + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $emptyData = static function (Options $options) { + if ($options['expanded'] && !$options['multiple']) { + return null; + } + + if ($options['multiple']) { + return []; + } + + return ''; + }; + + $placeholderDefault = static fn (Options $options) => $options['required'] ? null : ''; + + $placeholderNormalizer = static function (Options $options, $placeholder) { + if ($options['multiple']) { + // never use an empty value for this case + return null; + } elseif ($options['required'] && ($options['expanded'] || isset($options['attr']['size']) && $options['attr']['size'] > 1)) { + // placeholder for required radio buttons or a select with size > 1 does not make sense + return null; + } elseif (false === $placeholder) { + // an empty value should be added but the user decided otherwise + return null; + } elseif ($options['expanded'] && '' === $placeholder) { + // never use an empty label for radio buttons + return 'None'; + } + + // empty value has been set explicitly + return $placeholder; + }; + + $compound = static fn (Options $options) => $options['expanded']; + + $choiceTranslationDomainNormalizer = static function (Options $options, $choiceTranslationDomain) { + if (true === $choiceTranslationDomain) { + return $options['translation_domain']; + } + + return $choiceTranslationDomain; + }; + + $resolver->setDefaults([ + 'multiple' => false, + 'expanded' => false, + 'choices' => [], + 'choice_filter' => null, + 'choice_loader' => null, + 'choice_label' => null, + 'choice_name' => null, + 'choice_value' => null, + 'choice_attr' => null, + 'choice_translation_parameters' => [], + 'preferred_choices' => [], + 'duplicate_preferred_choices' => true, + 'group_by' => null, + 'empty_data' => $emptyData, + 'placeholder' => $placeholderDefault, + 'placeholder_attr' => [], + 'error_bubbling' => false, + 'compound' => $compound, + // The view data is always a string or an array of strings, + // even if the "data" option is manually set to an object. + // See https://github.com/symfony/symfony/pull/5582 + 'data_class' => null, + 'choice_translation_domain' => true, + 'trim' => false, + 'invalid_message' => 'The selected choice is invalid.', + ]); + + $resolver->setNormalizer('placeholder', $placeholderNormalizer); + $resolver->setNormalizer('choice_translation_domain', $choiceTranslationDomainNormalizer); + + $resolver->setAllowedTypes('choices', ['null', 'array', \Traversable::class]); + $resolver->setAllowedTypes('choice_translation_domain', ['null', 'bool', 'string']); + $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]); + $resolver->setAllowedTypes('choice_name', ['null', 'callable', 'string', PropertyPath::class, ChoiceFieldName::class]); + $resolver->setAllowedTypes('choice_value', ['null', 'callable', 'string', PropertyPath::class, ChoiceValue::class]); + $resolver->setAllowedTypes('choice_attr', ['null', 'array', 'callable', 'string', PropertyPath::class, ChoiceAttr::class]); + $resolver->setAllowedTypes('choice_translation_parameters', ['null', 'array', 'callable', ChoiceTranslationParameters::class]); + $resolver->setAllowedTypes('placeholder_attr', ['array']); + $resolver->setAllowedTypes('preferred_choices', ['array', \Traversable::class, 'callable', 'string', PropertyPath::class, PreferredChoice::class]); + $resolver->setAllowedTypes('duplicate_preferred_choices', 'bool'); + $resolver->setAllowedTypes('group_by', ['null', 'callable', 'string', PropertyPath::class, GroupBy::class]); + } + + public function getBlockPrefix(): string + { + return 'choice'; + } + + /** + * Adds the sub fields for an expanded choice field. + */ + private function addSubForms(FormBuilderInterface $builder, array $choiceViews, array $options): void + { + foreach ($choiceViews as $name => $choiceView) { + // Flatten groups + if (\is_array($choiceView)) { + $this->addSubForms($builder, $choiceView, $options); + continue; + } + + if ($choiceView instanceof ChoiceGroupView) { + $this->addSubForms($builder, $choiceView->choices, $options); + continue; + } + + $this->addSubForm($builder, $name, $choiceView, $options); + } + } + + private function addSubForm(FormBuilderInterface $builder, string $name, ChoiceView $choiceView, array $options): void + { + $choiceOpts = [ + 'value' => $choiceView->value, + 'label' => $choiceView->label, + 'label_html' => $options['label_html'], + 'attr' => $choiceView->attr, + 'label_translation_parameters' => $choiceView->labelTranslationParameters, + 'translation_domain' => $options['choice_translation_domain'], + 'block_name' => 'entry', + ]; + + if ($options['multiple']) { + $choiceType = CheckboxType::class; + // The user can check 0 or more checkboxes. If required + // is true, they are required to check all of them. + $choiceOpts['required'] = false; + } else { + $choiceType = RadioType::class; + } + + $builder->add($name, $choiceType, $choiceOpts); + } + + private function createChoiceList(array $options): ChoiceListInterface + { + if (null !== $options['choice_loader']) { + return $this->choiceListFactory->createListFromLoader( + $options['choice_loader'], + $options['choice_value'], + $options['choice_filter'] + ); + } + + // Harden against NULL values (like in EntityType and ModelType) + $choices = null !== $options['choices'] ? $options['choices'] : []; + + return $this->choiceListFactory->createListFromChoices( + $choices, + $options['choice_value'], + $options['choice_filter'] + ); + } + + private function createChoiceListView(ChoiceListInterface $choiceList, array $options): ChoiceListView + { + return $this->choiceListFactory->createView( + $choiceList, + $options['preferred_choices'], + $options['choice_label'], + $options['choice_name'], + $options['group_by'], + $options['choice_attr'], + $options['choice_translation_parameters'], + $options['duplicate_preferred_choices'], + ); + } +} diff --git a/lib/symfony/form/Extension/Core/Type/CollectionType.php b/lib/symfony/form/Extension/Core/Type/CollectionType.php new file mode 100644 index 000000000..0216e61dd --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/CollectionType.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\EventListener\ResizeFormListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class CollectionType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $resizePrototypeOptions = null; + if ($options['allow_add'] && $options['prototype']) { + $resizePrototypeOptions = array_replace($options['entry_options'], $options['prototype_options']); + $prototypeOptions = array_replace([ + 'required' => $options['required'], + 'label' => $options['prototype_name'].'label__', + ], $resizePrototypeOptions); + + if (null !== $options['prototype_data']) { + $prototypeOptions['data'] = $options['prototype_data']; + } + + $prototype = $builder->create($options['prototype_name'], $options['entry_type'], $prototypeOptions); + $builder->setAttribute('prototype', $prototype->getForm()); + } + + $resizeListener = new ResizeFormListener( + $options['entry_type'], + $options['entry_options'], + $options['allow_add'], + $options['allow_delete'], + $options['delete_empty'], + $resizePrototypeOptions + ); + + $builder->addEventSubscriber($resizeListener); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars = array_replace($view->vars, [ + 'allow_add' => $options['allow_add'], + 'allow_delete' => $options['allow_delete'], + ]); + + if ($form->getConfig()->hasAttribute('prototype')) { + $prototype = $form->getConfig()->getAttribute('prototype'); + $view->vars['prototype'] = $prototype->setParent($form)->createView($view); + } + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + $prefixOffset = -2; + // check if the entry type also defines a block prefix + /** @var FormInterface $entry */ + foreach ($form as $entry) { + if ($entry->getConfig()->getOption('block_prefix')) { + --$prefixOffset; + } + + break; + } + + foreach ($view as $entryView) { + array_splice($entryView->vars['block_prefixes'], $prefixOffset, 0, 'collection_entry'); + } + + /** @var FormInterface $prototype */ + if ($prototype = $form->getConfig()->getAttribute('prototype')) { + if ($view->vars['prototype']->vars['multipart']) { + $view->vars['multipart'] = true; + } + + if ($prefixOffset > -3 && $prototype->getConfig()->getOption('block_prefix')) { + --$prefixOffset; + } + + array_splice($view->vars['prototype']->vars['block_prefixes'], $prefixOffset, 0, 'collection_entry'); + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $entryOptionsNormalizer = static function (Options $options, $value) { + $value['block_name'] = 'entry'; + + return $value; + }; + + $resolver->setDefaults([ + 'allow_add' => false, + 'allow_delete' => false, + 'prototype' => true, + 'prototype_data' => null, + 'prototype_name' => '__name__', + 'entry_type' => TextType::class, + 'entry_options' => [], + 'prototype_options' => [], + 'delete_empty' => false, + 'invalid_message' => 'The collection is invalid.', + ]); + + $resolver->setNormalizer('entry_options', $entryOptionsNormalizer); + + $resolver->setAllowedTypes('delete_empty', ['bool', 'callable']); + $resolver->setAllowedTypes('prototype_options', 'array'); + } + + public function getBlockPrefix(): string + { + return 'collection'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/ColorType.php b/lib/symfony/form/Extension/Core/Type/ColorType.php new file mode 100644 index 000000000..71df9edd8 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/ColorType.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +class ColorType extends AbstractType +{ + /** + * @see https://www.w3.org/TR/html52/sec-forms.html#color-state-typecolor + */ + private const HTML5_PATTERN = '/^#[0-9a-f]{6}$/i'; + + private ?TranslatorInterface $translator; + + public function __construct(?TranslatorInterface $translator = null) + { + $this->translator = $translator; + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if (!$options['html5']) { + return; + } + + $translator = $this->translator; + $builder->addEventListener(FormEvents::PRE_SUBMIT, static function (FormEvent $event) use ($translator): void { + $value = $event->getData(); + if (null === $value || '' === $value) { + return; + } + + if (\is_string($value) && preg_match(self::HTML5_PATTERN, $value)) { + return; + } + + $messageTemplate = 'This value is not a valid HTML5 color.'; + $messageParameters = [ + '{{ value }}' => \is_scalar($value) ? (string) $value : \gettype($value), + ]; + $message = $translator?->trans($messageTemplate, $messageParameters, 'validators') ?? $messageTemplate; + + $event->getForm()->addError(new FormError($message, $messageTemplate, $messageParameters)); + }); + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'html5' => false, + 'invalid_message' => 'Please select a valid color.', + ]); + + $resolver->setAllowedTypes('html5', 'bool'); + } + + public function getParent(): ?string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'color'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/CountryType.php b/lib/symfony/form/Extension/Core/Type/CountryType.php new file mode 100644 index 000000000..6f872660a --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/CountryType.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Intl\Countries; +use Symfony\Component\Intl\Intl; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class CountryType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'choice_loader' => function (Options $options) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class)); + } + + $choiceTranslationLocale = $options['choice_translation_locale']; + $alpha3 = $options['alpha3']; + + return ChoiceList::loader($this, new IntlCallbackChoiceLoader(static fn () => array_flip($alpha3 ? Countries::getAlpha3Names($choiceTranslationLocale) : Countries::getNames($choiceTranslationLocale))), [$choiceTranslationLocale, $alpha3]); + }, + 'choice_translation_domain' => false, + 'choice_translation_locale' => null, + 'alpha3' => false, + 'invalid_message' => 'Please select a valid country.', + ]); + + $resolver->setAllowedTypes('choice_translation_locale', ['null', 'string']); + $resolver->setAllowedTypes('alpha3', 'bool'); + } + + public function getParent(): ?string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'country'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/CurrencyType.php b/lib/symfony/form/Extension/Core/Type/CurrencyType.php new file mode 100644 index 000000000..89edc6f63 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/CurrencyType.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Intl\Currencies; +use Symfony\Component\Intl\Intl; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class CurrencyType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'choice_loader' => function (Options $options) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class)); + } + + $choiceTranslationLocale = $options['choice_translation_locale']; + + return ChoiceList::loader($this, new IntlCallbackChoiceLoader(static fn () => array_flip(Currencies::getNames($choiceTranslationLocale))), $choiceTranslationLocale); + }, + 'choice_translation_domain' => false, + 'choice_translation_locale' => null, + 'invalid_message' => 'Please select a valid currency.', + ]); + + $resolver->setAllowedTypes('choice_translation_locale', ['null', 'string']); + } + + public function getParent(): ?string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'currency'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/DateIntervalType.php b/lib/symfony/form/Extension/Core/Type/DateIntervalType.php new file mode 100644 index 000000000..655ef6682 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/DateIntervalType.php @@ -0,0 +1,274 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Exception\InvalidConfigurationException; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateIntervalToArrayTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateIntervalToStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\IntegerToLocalizedStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\ReversedTransformer; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Steffen Roßkamp + */ +class DateIntervalType extends AbstractType +{ + private const TIME_PARTS = [ + 'years', + 'months', + 'weeks', + 'days', + 'hours', + 'minutes', + 'seconds', + ]; + private const WIDGETS = [ + 'text' => TextType::class, + 'integer' => IntegerType::class, + 'choice' => ChoiceType::class, + ]; + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if (!$options['with_years'] && !$options['with_months'] && !$options['with_weeks'] && !$options['with_days'] && !$options['with_hours'] && !$options['with_minutes'] && !$options['with_seconds']) { + throw new InvalidConfigurationException('You must enable at least one interval field.'); + } + if ($options['with_invert'] && 'single_text' === $options['widget']) { + throw new InvalidConfigurationException('The single_text widget does not support invertible intervals.'); + } + if ($options['with_weeks'] && $options['with_days']) { + throw new InvalidConfigurationException('You cannot enable weeks and days fields together.'); + } + $format = 'P'; + $parts = []; + if ($options['with_years']) { + $format .= '%yY'; + $parts[] = 'years'; + } + if ($options['with_months']) { + $format .= '%mM'; + $parts[] = 'months'; + } + if ($options['with_weeks']) { + $format .= '%wW'; + $parts[] = 'weeks'; + } + if ($options['with_days']) { + $format .= '%dD'; + $parts[] = 'days'; + } + if ($options['with_hours'] || $options['with_minutes'] || $options['with_seconds']) { + $format .= 'T'; + } + if ($options['with_hours']) { + $format .= '%hH'; + $parts[] = 'hours'; + } + if ($options['with_minutes']) { + $format .= '%iM'; + $parts[] = 'minutes'; + } + if ($options['with_seconds']) { + $format .= '%sS'; + $parts[] = 'seconds'; + } + if ($options['with_invert']) { + $parts[] = 'invert'; + } + if ('single_text' === $options['widget']) { + $builder->addViewTransformer(new DateIntervalToStringTransformer($format)); + } else { + foreach (self::TIME_PARTS as $part) { + if ($options['with_'.$part]) { + $childOptions = [ + 'error_bubbling' => true, + 'label' => $options['labels'][$part], + // Append generic carry-along options + 'required' => $options['required'], + 'translation_domain' => $options['translation_domain'], + // when compound the array entries are ignored, we need to cascade the configuration here + 'empty_data' => $options['empty_data'][$part] ?? null, + ]; + if ('choice' === $options['widget']) { + $childOptions['choice_translation_domain'] = false; + $childOptions['choices'] = $options[$part]; + $childOptions['placeholder'] = $options['placeholder'][$part]; + } + $childForm = $builder->create($part, self::WIDGETS[$options['widget']], $childOptions); + if ('integer' === $options['widget']) { + $childForm->addModelTransformer( + new ReversedTransformer( + new IntegerToLocalizedStringTransformer() + ) + ); + } + $builder->add($childForm); + } + } + if ($options['with_invert']) { + $builder->add('invert', CheckboxType::class, [ + 'label' => $options['labels']['invert'], + 'error_bubbling' => true, + 'required' => false, + 'translation_domain' => $options['translation_domain'], + ]); + } + $builder->addViewTransformer(new DateIntervalToArrayTransformer($parts, 'text' === $options['widget'])); + } + if ('string' === $options['input']) { + $builder->addModelTransformer( + new ReversedTransformer( + new DateIntervalToStringTransformer($format) + ) + ); + } elseif ('array' === $options['input']) { + $builder->addModelTransformer( + new ReversedTransformer( + new DateIntervalToArrayTransformer($parts) + ) + ); + } + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $vars = [ + 'widget' => $options['widget'], + 'with_invert' => $options['with_invert'], + ]; + foreach (self::TIME_PARTS as $part) { + $vars['with_'.$part] = $options['with_'.$part]; + } + $view->vars = array_replace($view->vars, $vars); + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $compound = static fn (Options $options) => 'single_text' !== $options['widget']; + $emptyData = static fn (Options $options) => 'single_text' === $options['widget'] ? '' : []; + + $placeholderDefault = static fn (Options $options) => $options['required'] ? null : ''; + + $placeholderNormalizer = static function (Options $options, $placeholder) use ($placeholderDefault) { + if (\is_array($placeholder)) { + $default = $placeholderDefault($options); + + return array_merge(array_fill_keys(self::TIME_PARTS, $default), $placeholder); + } + + return array_fill_keys(self::TIME_PARTS, $placeholder); + }; + + $labelsNormalizer = static fn (Options $options, array $labels) => array_replace([ + 'years' => null, + 'months' => null, + 'days' => null, + 'weeks' => null, + 'hours' => null, + 'minutes' => null, + 'seconds' => null, + 'invert' => 'Negative interval', + ], array_filter($labels, static fn ($label) => null !== $label)); + + $resolver->setDefaults([ + 'with_years' => true, + 'with_months' => true, + 'with_days' => true, + 'with_weeks' => false, + 'with_hours' => false, + 'with_minutes' => false, + 'with_seconds' => false, + 'with_invert' => false, + 'years' => range(0, 100), + 'months' => range(0, 12), + 'weeks' => range(0, 52), + 'days' => range(0, 31), + 'hours' => range(0, 24), + 'minutes' => range(0, 60), + 'seconds' => range(0, 60), + 'widget' => 'choice', + 'input' => 'dateinterval', + 'placeholder' => $placeholderDefault, + 'by_reference' => true, + 'error_bubbling' => false, + // If initialized with a \DateInterval object, FormType initializes + // this option to "\DateInterval". Since the internal, normalized + // representation is not \DateInterval, but an array, we need to unset + // this option. + 'data_class' => null, + 'compound' => $compound, + 'empty_data' => $emptyData, + 'labels' => [], + 'invalid_message' => 'Please choose a valid date interval.', + ]); + $resolver->setNormalizer('placeholder', $placeholderNormalizer); + $resolver->setNormalizer('labels', $labelsNormalizer); + + $resolver->setAllowedValues( + 'input', + [ + 'dateinterval', + 'string', + 'array', + ] + ); + $resolver->setAllowedValues( + 'widget', + [ + 'single_text', + 'text', + 'integer', + 'choice', + ] + ); + // Don't clone \DateInterval classes, as i.e. format() + // does not work after that + $resolver->setAllowedValues('by_reference', true); + + $resolver->setAllowedTypes('years', 'array'); + $resolver->setAllowedTypes('months', 'array'); + $resolver->setAllowedTypes('weeks', 'array'); + $resolver->setAllowedTypes('days', 'array'); + $resolver->setAllowedTypes('hours', 'array'); + $resolver->setAllowedTypes('minutes', 'array'); + $resolver->setAllowedTypes('seconds', 'array'); + $resolver->setAllowedTypes('with_years', 'bool'); + $resolver->setAllowedTypes('with_months', 'bool'); + $resolver->setAllowedTypes('with_weeks', 'bool'); + $resolver->setAllowedTypes('with_days', 'bool'); + $resolver->setAllowedTypes('with_hours', 'bool'); + $resolver->setAllowedTypes('with_minutes', 'bool'); + $resolver->setAllowedTypes('with_seconds', 'bool'); + $resolver->setAllowedTypes('with_invert', 'bool'); + $resolver->setAllowedTypes('labels', 'array'); + } + + public function getBlockPrefix(): string + { + return 'dateinterval'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/DateTimeType.php b/lib/symfony/form/Extension/Core/Type/DateTimeType.php new file mode 100644 index 000000000..9ec4c9cca --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/DateTimeType.php @@ -0,0 +1,357 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\DataTransformer\ArrayToPartsTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DataTransformerChain; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeImmutableToDateTimeTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToArrayTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToHtml5LocalDateTimeTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToLocalizedStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\ReversedTransformer; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class DateTimeType extends AbstractType +{ + public const DEFAULT_DATE_FORMAT = \IntlDateFormatter::MEDIUM; + public const DEFAULT_TIME_FORMAT = \IntlDateFormatter::MEDIUM; + + /** + * The HTML5 datetime-local format as defined in + * http://w3c.github.io/html-reference/datatypes.html#form.data.datetime-local. + */ + public const HTML5_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; + + private const ACCEPTED_FORMATS = [ + \IntlDateFormatter::FULL, + \IntlDateFormatter::LONG, + \IntlDateFormatter::MEDIUM, + \IntlDateFormatter::SHORT, + ]; + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $parts = ['year', 'month', 'day', 'hour']; + $dateParts = ['year', 'month', 'day']; + $timeParts = ['hour']; + + if ($options['with_minutes']) { + $parts[] = 'minute'; + $timeParts[] = 'minute'; + } + + if ($options['with_seconds']) { + $parts[] = 'second'; + $timeParts[] = 'second'; + } + + $dateFormat = \is_int($options['date_format']) ? $options['date_format'] : self::DEFAULT_DATE_FORMAT; + $timeFormat = self::DEFAULT_TIME_FORMAT; + $calendar = \IntlDateFormatter::GREGORIAN; + $pattern = \is_string($options['format']) ? $options['format'] : null; + + if (!\in_array($dateFormat, self::ACCEPTED_FORMATS, true)) { + throw new InvalidOptionsException('The "date_format" option must be one of the IntlDateFormatter constants (FULL, LONG, MEDIUM, SHORT) or a string representing a custom format.'); + } + + if ('single_text' === $options['widget']) { + if (self::HTML5_FORMAT === $pattern) { + $builder->addViewTransformer(new DateTimeToHtml5LocalDateTimeTransformer( + $options['model_timezone'], + $options['view_timezone'], + $options['with_seconds'] + )); + } else { + $builder->addViewTransformer(new DateTimeToLocalizedStringTransformer( + $options['model_timezone'], + $options['view_timezone'], + $dateFormat, + $timeFormat, + $calendar, + $pattern + )); + } + } else { + // when the form is compound the entries of the array are ignored in favor of children data + // so we need to handle the cascade setting here + $emptyData = $builder->getEmptyData() ?: []; + // Only pass a subset of the options to children + $dateOptions = array_intersect_key($options, array_flip([ + 'years', + 'months', + 'days', + 'placeholder', + 'choice_translation_domain', + 'required', + 'translation_domain', + 'html5', + 'invalid_message', + 'invalid_message_parameters', + ])); + + if ($emptyData instanceof \Closure) { + $lazyEmptyData = static fn ($option) => static function (FormInterface $form) use ($emptyData, $option) { + $emptyData = $emptyData($form->getParent()); + + return $emptyData[$option] ?? ''; + }; + + $dateOptions['empty_data'] = $lazyEmptyData('date'); + } elseif (isset($emptyData['date'])) { + $dateOptions['empty_data'] = $emptyData['date']; + } + + $timeOptions = array_intersect_key($options, array_flip([ + 'hours', + 'minutes', + 'seconds', + 'with_minutes', + 'with_seconds', + 'placeholder', + 'choice_translation_domain', + 'required', + 'translation_domain', + 'html5', + 'invalid_message', + 'invalid_message_parameters', + ])); + + if ($emptyData instanceof \Closure) { + $timeOptions['empty_data'] = $lazyEmptyData('time'); + } elseif (isset($emptyData['time'])) { + $timeOptions['empty_data'] = $emptyData['time']; + } + + if (false === $options['label']) { + $dateOptions['label'] = false; + $timeOptions['label'] = false; + } + + $dateOptions['widget'] = $options['date_widget'] ?? $options['widget'] ?? 'choice'; + $timeOptions['widget'] = $options['time_widget'] ?? $options['widget'] ?? 'choice'; + + if (null !== $options['date_label']) { + $dateOptions['label'] = $options['date_label']; + } + + if (null !== $options['time_label']) { + $timeOptions['label'] = $options['time_label']; + } + + if (null !== $options['date_format']) { + $dateOptions['format'] = $options['date_format']; + } + + $dateOptions['input'] = $timeOptions['input'] = 'array'; + $dateOptions['error_bubbling'] = $timeOptions['error_bubbling'] = true; + + $builder + ->addViewTransformer(new DataTransformerChain([ + new DateTimeToArrayTransformer($options['model_timezone'], $options['view_timezone'], $parts), + new ArrayToPartsTransformer([ + 'date' => $dateParts, + 'time' => $timeParts, + ]), + ])) + ->add('date', DateType::class, $dateOptions) + ->add('time', TimeType::class, $timeOptions) + ; + } + + if ('datetime_immutable' === $options['input']) { + $builder->addModelTransformer(new DateTimeImmutableToDateTimeTransformer()); + } elseif ('string' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToStringTransformer($options['model_timezone'], $options['model_timezone'], $options['input_format']) + )); + } elseif ('timestamp' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToTimestampTransformer($options['model_timezone'], $options['model_timezone']) + )); + } elseif ('array' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToArrayTransformer($options['model_timezone'], $options['model_timezone'], $parts) + )); + } + + if (\in_array($options['input'], ['datetime', 'datetime_immutable'], true) && null !== $options['model_timezone']) { + $builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use ($options): void { + $date = $event->getData(); + + if (!$date instanceof \DateTimeInterface) { + return; + } + + if ($date->getTimezone()->getName() !== $options['model_timezone']) { + trigger_deprecation('symfony/form', '6.4', sprintf('Using a "%s" instance with a timezone ("%s") not matching the configured model timezone "%s" is deprecated.', $date::class, $date->getTimezone()->getName(), $options['model_timezone'])); + // throw new LogicException(sprintf('Using a "%s" instance with a timezone ("%s") not matching the configured model timezone "%s" is not supported.', $date::class, $date->getTimezone()->getName(), $options['model_timezone'])); + } + }); + } + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['widget'] = $options['widget']; + + // Change the input to an HTML5 datetime input if + // * the widget is set to "single_text" + // * the format matches the one expected by HTML5 + // * the html5 is set to true + if ($options['html5'] && 'single_text' === $options['widget'] && self::HTML5_FORMAT === $options['format']) { + $view->vars['type'] = 'datetime-local'; + + // we need to force the browser to display the seconds by + // adding the HTML attribute step if not already defined. + // Otherwise the browser will not display and so not send the seconds + // therefore the value will always be considered as invalid. + if (!isset($view->vars['attr']['step'])) { + if ($options['with_seconds']) { + $view->vars['attr']['step'] = 1; + } elseif (!$options['with_minutes']) { + $view->vars['attr']['step'] = 3600; + } + } + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $compound = static fn (Options $options) => 'single_text' !== $options['widget']; + + $resolver->setDefaults([ + 'input' => 'datetime', + 'model_timezone' => null, + 'view_timezone' => null, + 'format' => self::HTML5_FORMAT, + 'date_format' => null, + 'widget' => null, + 'date_widget' => null, + 'time_widget' => null, + 'with_minutes' => true, + 'with_seconds' => false, + 'html5' => true, + // Don't modify \DateTime classes by reference, we treat + // them like immutable value objects + 'by_reference' => false, + 'error_bubbling' => false, + // If initialized with a \DateTime object, FormType initializes + // this option to "\DateTime". Since the internal, normalized + // representation is not \DateTime, but an array, we need to unset + // this option. + 'data_class' => null, + 'compound' => $compound, + 'date_label' => null, + 'time_label' => null, + 'empty_data' => static fn (Options $options) => $options['compound'] ? [] : '', + 'input_format' => 'Y-m-d H:i:s', + 'invalid_message' => 'Please enter a valid date and time.', + ]); + + // Don't add some defaults in order to preserve the defaults + // set in DateType and TimeType + $resolver->setDefined([ + 'placeholder', + 'choice_translation_domain', + 'years', + 'months', + 'days', + 'hours', + 'minutes', + 'seconds', + ]); + + $resolver->setAllowedValues('input', [ + 'datetime', + 'datetime_immutable', + 'string', + 'timestamp', + 'array', + ]); + $resolver->setAllowedValues('date_widget', [ + null, // inherit default from DateType + 'single_text', + 'text', + 'choice', + ]); + $resolver->setAllowedValues('time_widget', [ + null, // inherit default from TimeType + 'single_text', + 'text', + 'choice', + ]); + // This option will overwrite "date_widget" and "time_widget" options + $resolver->setAllowedValues('widget', [ + null, // default, don't overwrite options + 'single_text', + 'text', + 'choice', + ]); + + $resolver->setAllowedTypes('input_format', 'string'); + + $resolver->setNormalizer('date_format', static function (Options $options, $dateFormat) { + if (null !== $dateFormat && 'single_text' === $options['widget'] && self::HTML5_FORMAT === $options['format']) { + throw new LogicException(sprintf('Cannot use the "date_format" option of the "%s" with an HTML5 date.', self::class)); + } + + return $dateFormat; + }); + $resolver->setNormalizer('widget', static function (Options $options, $widget) { + if ('single_text' === $widget) { + if (null !== $options['date_widget']) { + throw new LogicException(sprintf('Cannot use the "date_widget" option of the "%s" when the "widget" option is set to "single_text".', self::class)); + } + if (null !== $options['time_widget']) { + throw new LogicException(sprintf('Cannot use the "time_widget" option of the "%s" when the "widget" option is set to "single_text".', self::class)); + } + } elseif (null === $widget && null === $options['date_widget'] && null === $options['time_widget']) { + trigger_deprecation('symfony/form', '6.3', 'Not configuring the "widget" option of form type "datetime" is deprecated. It will default to "single_text" in Symfony 7.0.'); + // return 'single_text'; + } + + return $widget; + }); + $resolver->setNormalizer('html5', static function (Options $options, $html5) { + if ($html5 && self::HTML5_FORMAT !== $options['format']) { + throw new LogicException(sprintf('Cannot use the "format" option of "%s" when the "html5" option is enabled.', self::class)); + } + + return $html5; + }); + } + + public function getBlockPrefix(): string + { + return 'datetime'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/DateType.php b/lib/symfony/form/Extension/Core/Type/DateType.php new file mode 100644 index 000000000..480afc315 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/DateType.php @@ -0,0 +1,409 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeImmutableToDateTimeTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToArrayTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToLocalizedStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\ReversedTransformer; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class DateType extends AbstractType +{ + public const DEFAULT_FORMAT = \IntlDateFormatter::MEDIUM; + public const HTML5_FORMAT = 'yyyy-MM-dd'; + + private const ACCEPTED_FORMATS = [ + \IntlDateFormatter::FULL, + \IntlDateFormatter::LONG, + \IntlDateFormatter::MEDIUM, + \IntlDateFormatter::SHORT, + ]; + + private const WIDGETS = [ + 'text' => TextType::class, + 'choice' => ChoiceType::class, + ]; + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $dateFormat = \is_int($options['format']) ? $options['format'] : self::DEFAULT_FORMAT; + $timeFormat = \IntlDateFormatter::NONE; + $calendar = \IntlDateFormatter::GREGORIAN; + $pattern = \is_string($options['format']) ? $options['format'] : ''; + + if (!\in_array($dateFormat, self::ACCEPTED_FORMATS, true)) { + throw new InvalidOptionsException('The "format" option must be one of the IntlDateFormatter constants (FULL, LONG, MEDIUM, SHORT) or a string representing a custom format.'); + } + + if ('single_text' === $options['widget']) { + if ('' !== $pattern && !str_contains($pattern, 'y') && !str_contains($pattern, 'M') && !str_contains($pattern, 'd')) { + throw new InvalidOptionsException(sprintf('The "format" option should contain the letters "y", "M" or "d". Its current value is "%s".', $pattern)); + } + + $builder->addViewTransformer(new DateTimeToLocalizedStringTransformer( + $options['model_timezone'], + $options['view_timezone'], + $dateFormat, + $timeFormat, + $calendar, + $pattern + )); + } else { + if ('' !== $pattern && (!str_contains($pattern, 'y') || !str_contains($pattern, 'M') || !str_contains($pattern, 'd'))) { + throw new InvalidOptionsException(sprintf('The "format" option should contain the letters "y", "M" and "d". Its current value is "%s".', $pattern)); + } + + $yearOptions = $monthOptions = $dayOptions = [ + 'error_bubbling' => true, + 'empty_data' => '', + ]; + // when the form is compound the entries of the array are ignored in favor of children data + // so we need to handle the cascade setting here + $emptyData = $builder->getEmptyData() ?: []; + + if ($emptyData instanceof \Closure) { + $lazyEmptyData = static fn ($option) => static function (FormInterface $form) use ($emptyData, $option) { + $emptyData = $emptyData($form->getParent()); + + return $emptyData[$option] ?? ''; + }; + + $yearOptions['empty_data'] = $lazyEmptyData('year'); + $monthOptions['empty_data'] = $lazyEmptyData('month'); + $dayOptions['empty_data'] = $lazyEmptyData('day'); + } else { + if (isset($emptyData['year'])) { + $yearOptions['empty_data'] = $emptyData['year']; + } + if (isset($emptyData['month'])) { + $monthOptions['empty_data'] = $emptyData['month']; + } + if (isset($emptyData['day'])) { + $dayOptions['empty_data'] = $emptyData['day']; + } + } + + if (isset($options['invalid_message'])) { + $dayOptions['invalid_message'] = $options['invalid_message']; + $monthOptions['invalid_message'] = $options['invalid_message']; + $yearOptions['invalid_message'] = $options['invalid_message']; + } + + if (isset($options['invalid_message_parameters'])) { + $dayOptions['invalid_message_parameters'] = $options['invalid_message_parameters']; + $monthOptions['invalid_message_parameters'] = $options['invalid_message_parameters']; + $yearOptions['invalid_message_parameters'] = $options['invalid_message_parameters']; + } + + $formatter = new \IntlDateFormatter( + \Locale::getDefault(), + $dateFormat, + $timeFormat, + // see https://bugs.php.net/66323 + class_exists(\IntlTimeZone::class, false) ? \IntlTimeZone::createDefault() : null, + $calendar, + $pattern + ); + + // new \IntlDateFormatter may return null instead of false in case of failure, see https://bugs.php.net/66323 + if (!$formatter) { + throw new InvalidOptionsException(intl_get_error_message(), intl_get_error_code()); + } + + $formatter->setLenient(false); + + if ('choice' === $options['widget']) { + // Only pass a subset of the options to children + $yearOptions['choices'] = $this->formatTimestamps($formatter, '/y+/', $this->listYears($options['years'])); + $yearOptions['placeholder'] = $options['placeholder']['year']; + $yearOptions['choice_translation_domain'] = $options['choice_translation_domain']['year']; + $monthOptions['choices'] = $this->formatTimestamps($formatter, '/[M|L]+/', $this->listMonths($options['months'])); + $monthOptions['placeholder'] = $options['placeholder']['month']; + $monthOptions['choice_translation_domain'] = $options['choice_translation_domain']['month']; + $dayOptions['choices'] = $this->formatTimestamps($formatter, '/d+/', $this->listDays($options['days'])); + $dayOptions['placeholder'] = $options['placeholder']['day']; + $dayOptions['choice_translation_domain'] = $options['choice_translation_domain']['day']; + } + + // Append generic carry-along options + foreach (['required', 'translation_domain'] as $passOpt) { + $yearOptions[$passOpt] = $monthOptions[$passOpt] = $dayOptions[$passOpt] = $options[$passOpt]; + } + + $builder + ->add('year', self::WIDGETS[$options['widget']], $yearOptions) + ->add('month', self::WIDGETS[$options['widget']], $monthOptions) + ->add('day', self::WIDGETS[$options['widget']], $dayOptions) + ->addViewTransformer(new DateTimeToArrayTransformer( + $options['model_timezone'], $options['view_timezone'], ['year', 'month', 'day'] + )) + ->setAttribute('formatter', $formatter) + ; + } + + if ('datetime_immutable' === $options['input']) { + $builder->addModelTransformer(new DateTimeImmutableToDateTimeTransformer()); + } elseif ('string' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToStringTransformer($options['model_timezone'], $options['model_timezone'], $options['input_format']) + )); + } elseif ('timestamp' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToTimestampTransformer($options['model_timezone'], $options['model_timezone']) + )); + } elseif ('array' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToArrayTransformer($options['model_timezone'], $options['model_timezone'], ['year', 'month', 'day']) + )); + } + + if (\in_array($options['input'], ['datetime', 'datetime_immutable'], true) && null !== $options['model_timezone']) { + $builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use ($options): void { + $date = $event->getData(); + + if (!$date instanceof \DateTimeInterface) { + return; + } + + if ($date->getTimezone()->getName() !== $options['model_timezone']) { + trigger_deprecation('symfony/form', '6.4', sprintf('Using a "%s" instance with a timezone ("%s") not matching the configured model timezone "%s" is deprecated.', $date::class, $date->getTimezone()->getName(), $options['model_timezone'])); + // throw new LogicException(sprintf('Using a "%s" instance with a timezone ("%s") not matching the configured model timezone "%s" is not supported.', $date::class, $date->getTimezone()->getName(), $options['model_timezone'])); + } + }); + } + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + $view->vars['widget'] = $options['widget']; + + // Change the input to an HTML5 date input if + // * the widget is set to "single_text" + // * the format matches the one expected by HTML5 + // * the html5 is set to true + if ($options['html5'] && 'single_text' === $options['widget'] && self::HTML5_FORMAT === $options['format']) { + $view->vars['type'] = 'date'; + } + + if ($form->getConfig()->hasAttribute('formatter')) { + $pattern = $form->getConfig()->getAttribute('formatter')->getPattern(); + + // remove special characters unless the format was explicitly specified + if (!\is_string($options['format'])) { + // remove quoted strings first + $pattern = preg_replace('/\'[^\']+\'/', '', $pattern); + + // remove remaining special chars + $pattern = preg_replace('/[^yMd]+/', '', $pattern); + } + + // set right order with respect to locale (e.g.: de_DE=dd.MM.yy; en_US=M/d/yy) + // lookup various formats at http://userguide.icu-project.org/formatparse/datetime + if (preg_match('/^([yMd]+)[^yMd]*([yMd]+)[^yMd]*([yMd]+)$/', $pattern)) { + $pattern = preg_replace(['/y+/', '/M+/', '/d+/'], ['{{ year }}', '{{ month }}', '{{ day }}'], $pattern); + } else { + // default fallback + $pattern = '{{ year }}{{ month }}{{ day }}'; + } + + $view->vars['date_pattern'] = $pattern; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $compound = static fn (Options $options) => 'single_text' !== $options['widget']; + + $placeholderDefault = static fn (Options $options) => $options['required'] ? null : ''; + + $placeholderNormalizer = static function (Options $options, $placeholder) use ($placeholderDefault) { + if (\is_array($placeholder)) { + $default = $placeholderDefault($options); + + return array_merge( + ['year' => $default, 'month' => $default, 'day' => $default], + $placeholder + ); + } + + return [ + 'year' => $placeholder, + 'month' => $placeholder, + 'day' => $placeholder, + ]; + }; + + $choiceTranslationDomainNormalizer = static function (Options $options, $choiceTranslationDomain) { + if (\is_array($choiceTranslationDomain)) { + $default = false; + + return array_replace( + ['year' => $default, 'month' => $default, 'day' => $default], + $choiceTranslationDomain + ); + } + + return [ + 'year' => $choiceTranslationDomain, + 'month' => $choiceTranslationDomain, + 'day' => $choiceTranslationDomain, + ]; + }; + + $format = static fn (Options $options) => 'single_text' === $options['widget'] ? self::HTML5_FORMAT : self::DEFAULT_FORMAT; + + $resolver->setDefaults([ + 'years' => range((int) date('Y') - 5, (int) date('Y') + 5), + 'months' => range(1, 12), + 'days' => range(1, 31), + 'widget' => static function (Options $options) { + trigger_deprecation('symfony/form', '6.3', 'Not configuring the "widget" option of form type "date" is deprecated. It will default to "single_text" in Symfony 7.0.'); + + return 'choice'; + }, + 'input' => 'datetime', + 'format' => $format, + 'model_timezone' => null, + 'view_timezone' => null, + 'placeholder' => $placeholderDefault, + 'html5' => true, + // Don't modify \DateTime classes by reference, we treat + // them like immutable value objects + 'by_reference' => false, + 'error_bubbling' => false, + // If initialized with a \DateTime object, FormType initializes + // this option to "\DateTime". Since the internal, normalized + // representation is not \DateTime, but an array, we need to unset + // this option. + 'data_class' => null, + 'compound' => $compound, + 'empty_data' => static fn (Options $options) => $options['compound'] ? [] : '', + 'choice_translation_domain' => false, + 'input_format' => 'Y-m-d', + 'invalid_message' => 'Please enter a valid date.', + ]); + + $resolver->setNormalizer('placeholder', $placeholderNormalizer); + $resolver->setNormalizer('choice_translation_domain', $choiceTranslationDomainNormalizer); + + $resolver->setAllowedValues('input', [ + 'datetime', + 'datetime_immutable', + 'string', + 'timestamp', + 'array', + ]); + $resolver->setAllowedValues('widget', [ + 'single_text', + 'text', + 'choice', + ]); + + $resolver->setAllowedTypes('format', ['int', 'string']); + $resolver->setAllowedTypes('years', 'array'); + $resolver->setAllowedTypes('months', 'array'); + $resolver->setAllowedTypes('days', 'array'); + $resolver->setAllowedTypes('input_format', 'string'); + + $resolver->setNormalizer('html5', static function (Options $options, $html5) { + if ($html5 && 'single_text' === $options['widget'] && self::HTML5_FORMAT !== $options['format']) { + throw new LogicException(sprintf('Cannot use the "format" option of "%s" when the "html5" option is enabled.', self::class)); + } + + return $html5; + }); + } + + public function getBlockPrefix(): string + { + return 'date'; + } + + private function formatTimestamps(\IntlDateFormatter $formatter, string $regex, array $timestamps): array + { + $pattern = $formatter->getPattern(); + $timezone = $formatter->getTimeZoneId(); + $formattedTimestamps = []; + + $formatter->setTimeZone('UTC'); + + if (preg_match($regex, $pattern, $matches)) { + $formatter->setPattern($matches[0]); + + foreach ($timestamps as $timestamp => $choice) { + $formattedTimestamps[$formatter->format($timestamp)] = $choice; + } + + // I'd like to clone the formatter above, but then we get a + // segmentation fault, so let's restore the old state instead + $formatter->setPattern($pattern); + } + + $formatter->setTimeZone($timezone); + + return $formattedTimestamps; + } + + private function listYears(array $years): array + { + $result = []; + + foreach ($years as $year) { + $result[\PHP_INT_SIZE === 4 ? \DateTimeImmutable::createFromFormat('Y e', $year.' UTC')->format('U') : gmmktime(0, 0, 0, 6, 15, $year)] = $year; + } + + return $result; + } + + private function listMonths(array $months): array + { + $result = []; + + foreach ($months as $month) { + $result[gmmktime(0, 0, 0, $month, 15)] = $month; + } + + return $result; + } + + private function listDays(array $days): array + { + $result = []; + + foreach ($days as $day) { + $result[gmmktime(0, 0, 0, 5, $day)] = $day; + } + + return $result; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/EmailType.php b/lib/symfony/form/Extension/Core/Type/EmailType.php new file mode 100644 index 000000000..64d01ee67 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/EmailType.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class EmailType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'invalid_message' => 'Please enter a valid email address.', + ]); + } + + public function getParent(): ?string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'email'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/EnumType.php b/lib/symfony/form/Extension/Core/Type/EnumType.php new file mode 100644 index 000000000..bfede9c04 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/EnumType.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\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatableInterface; + +/** + * A choice type for native PHP enums. + * + * @author Alexander M. Turek + */ +final class EnumType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setRequired(['class']) + ->setAllowedTypes('class', 'string') + ->setAllowedValues('class', enum_exists(...)) + ->setDefault('choices', static fn (Options $options): array => $options['class']::cases()) + ->setDefault('choice_label', static fn (\UnitEnum $choice) => $choice instanceof TranslatableInterface ? $choice : $choice->name) + ->setDefault('choice_value', static function (Options $options): ?\Closure { + if (!is_a($options['class'], \BackedEnum::class, true)) { + return null; + } + + return static function (?\BackedEnum $choice): ?string { + if (null === $choice) { + return null; + } + + return (string) $choice->value; + }; + }) + ; + } + + public function getParent(): string + { + return ChoiceType::class; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/FileType.php b/lib/symfony/form/Extension/Core/Type/FileType.php new file mode 100644 index 000000000..bbf01a80a --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/FileType.php @@ -0,0 +1,243 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Event\PreSubmitEvent; +use Symfony\Component\Form\FileUploadError; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +class FileType extends AbstractType +{ + public const KIB_BYTES = 1024; + public const MIB_BYTES = 1048576; + + private const SUFFIXES = [ + 1 => 'bytes', + self::KIB_BYTES => 'KiB', + self::MIB_BYTES => 'MiB', + ]; + + private ?TranslatorInterface $translator; + + public function __construct(?TranslatorInterface $translator = null) + { + $this->translator = $translator; + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + // Ensure that submitted data is always an uploaded file or an array of some + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($options) { + /** @var PreSubmitEvent $event */ + $form = $event->getForm(); + $requestHandler = $form->getConfig()->getRequestHandler(); + + if ($options['multiple']) { + $data = []; + $files = $event->getData(); + + if (!\is_array($files)) { + $files = []; + } + + foreach ($files as $file) { + if ($requestHandler->isFileUpload($file)) { + $data[] = $file; + + if (method_exists($requestHandler, 'getUploadFileError') && null !== $errorCode = $requestHandler->getUploadFileError($file)) { + $form->addError($this->getFileUploadError($errorCode)); + } + } + } + + // Since the array is never considered empty in the view data format + // on submission, we need to evaluate the configured empty data here + if ([] === $data) { + $emptyData = $form->getConfig()->getEmptyData(); + $data = $emptyData instanceof \Closure ? $emptyData($form, $data) : $emptyData; + } + + $event->setData($data); + } elseif ($requestHandler->isFileUpload($event->getData()) && method_exists($requestHandler, 'getUploadFileError') && null !== $errorCode = $requestHandler->getUploadFileError($event->getData())) { + $form->addError($this->getFileUploadError($errorCode)); + } elseif (!$requestHandler->isFileUpload($event->getData())) { + $event->setData(null); + } + }); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + if ($options['multiple']) { + $view->vars['full_name'] .= '[]'; + $view->vars['attr']['multiple'] = 'multiple'; + } + + $view->vars = array_replace($view->vars, [ + 'type' => 'file', + 'value' => '', + ]); + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + $view->vars['multipart'] = true; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $dataClass = null; + if (class_exists(File::class)) { + $dataClass = static fn (Options $options) => $options['multiple'] ? null : File::class; + } + + $emptyData = static fn (Options $options) => $options['multiple'] ? [] : null; + + $resolver->setDefaults([ + 'compound' => false, + 'data_class' => $dataClass, + 'empty_data' => $emptyData, + 'multiple' => false, + 'allow_file_upload' => true, + 'invalid_message' => 'Please select a valid file.', + ]); + } + + public function getBlockPrefix(): string + { + return 'file'; + } + + private function getFileUploadError(int $errorCode): FileUploadError + { + $messageParameters = []; + + if (\UPLOAD_ERR_INI_SIZE === $errorCode) { + [$limitAsString, $suffix] = $this->factorizeSizes(0, self::getMaxFilesize()); + $messageTemplate = 'The file is too large. Allowed maximum size is {{ limit }} {{ suffix }}.'; + $messageParameters = [ + '{{ limit }}' => $limitAsString, + '{{ suffix }}' => $suffix, + ]; + } elseif (\UPLOAD_ERR_FORM_SIZE === $errorCode) { + $messageTemplate = 'The file is too large.'; + } else { + $messageTemplate = 'The file could not be uploaded.'; + } + + if (null !== $this->translator) { + $message = $this->translator->trans($messageTemplate, $messageParameters, 'validators'); + } else { + $message = strtr($messageTemplate, $messageParameters); + } + + return new FileUploadError($message, $messageTemplate, $messageParameters); + } + + /** + * Returns the maximum size of an uploaded file as configured in php.ini. + * + * This method should be kept in sync with Symfony\Component\HttpFoundation\File\UploadedFile::getMaxFilesize(). + */ + private static function getMaxFilesize(): int|float + { + $iniMax = strtolower(\ini_get('upload_max_filesize')); + + if ('' === $iniMax) { + return \PHP_INT_MAX; + } + + $max = ltrim($iniMax, '+'); + if (str_starts_with($max, '0x')) { + $max = \intval($max, 16); + } elseif (str_starts_with($max, '0')) { + $max = \intval($max, 8); + } else { + $max = (int) $max; + } + + switch (substr($iniMax, -1)) { + case 't': $max *= 1024; + // no break + case 'g': $max *= 1024; + // no break + case 'm': $max *= 1024; + // no break + case 'k': $max *= 1024; + } + + return $max; + } + + /** + * Converts the limit to the smallest possible number + * (i.e. try "MB", then "kB", then "bytes"). + * + * This method should be kept in sync with Symfony\Component\Validator\Constraints\FileValidator::factorizeSizes(). + */ + private function factorizeSizes(int $size, int|float $limit): array + { + $coef = self::MIB_BYTES; + $coefFactor = self::KIB_BYTES; + + $limitAsString = (string) ($limit / $coef); + + // Restrict the limit to 2 decimals (without rounding! we + // need the precise value) + while (self::moreDecimalsThan($limitAsString, 2)) { + $coef /= $coefFactor; + $limitAsString = (string) ($limit / $coef); + } + + // Convert size to the same measure, but round to 2 decimals + $sizeAsString = (string) round($size / $coef, 2); + + // If the size and limit produce the same string output + // (due to rounding), reduce the coefficient + while ($sizeAsString === $limitAsString) { + $coef /= $coefFactor; + $limitAsString = (string) ($limit / $coef); + $sizeAsString = (string) round($size / $coef, 2); + } + + return [$limitAsString, self::SUFFIXES[$coef]]; + } + + /** + * This method should be kept in sync with Symfony\Component\Validator\Constraints\FileValidator::moreDecimalsThan(). + */ + private static function moreDecimalsThan(string $double, int $numberOfDecimals): bool + { + return \strlen($double) > \strlen(round($double, $numberOfDecimals)); + } +} diff --git a/lib/symfony/form/Extension/Core/Type/FormType.php b/lib/symfony/form/Extension/Core/Type/FormType.php new file mode 100644 index 000000000..432ba78cd --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/FormType.php @@ -0,0 +1,218 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\DataAccessor\CallbackAccessor; +use Symfony\Component\Form\Extension\Core\DataAccessor\ChainAccessor; +use Symfony\Component\Form\Extension\Core\DataAccessor\PropertyPathAccessor; +use Symfony\Component\Form\Extension\Core\DataMapper\DataMapper; +use Symfony\Component\Form\Extension\Core\EventListener\TrimListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Contracts\Translation\TranslatableInterface; + +class FormType extends BaseType +{ + private DataMapper $dataMapper; + + public function __construct(?PropertyAccessorInterface $propertyAccessor = null) + { + $this->dataMapper = new DataMapper(new ChainAccessor([ + new CallbackAccessor(), + new PropertyPathAccessor($propertyAccessor ?? PropertyAccess::createPropertyAccessor()), + ])); + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + parent::buildForm($builder, $options); + + $isDataOptionSet = \array_key_exists('data', $options); + + $builder + ->setRequired($options['required']) + ->setErrorBubbling($options['error_bubbling']) + ->setEmptyData($options['empty_data']) + ->setPropertyPath($options['property_path']) + ->setMapped($options['mapped']) + ->setByReference($options['by_reference']) + ->setInheritData($options['inherit_data']) + ->setCompound($options['compound']) + ->setData($isDataOptionSet ? $options['data'] : null) + ->setDataLocked($isDataOptionSet) + ->setDataMapper($options['compound'] ? $this->dataMapper : null) + ->setMethod($options['method']) + ->setAction($options['action']); + + if ($options['trim']) { + $builder->addEventSubscriber(new TrimListener()); + } + + $builder->setIsEmptyCallback($options['is_empty_callback']); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + parent::buildView($view, $form, $options); + + $name = $form->getName(); + $helpTranslationParameters = $options['help_translation_parameters']; + + if ($view->parent) { + if ('' === $name) { + throw new LogicException('Form node with empty name can be used only as root form node.'); + } + + // Complex fields are read-only if they themselves or their parents are. + if (!isset($view->vars['attr']['readonly']) && isset($view->parent->vars['attr']['readonly']) && false !== $view->parent->vars['attr']['readonly']) { + $view->vars['attr']['readonly'] = true; + } + + $helpTranslationParameters = array_merge($view->parent->vars['help_translation_parameters'], $helpTranslationParameters); + } + + $formConfig = $form->getConfig(); + $view->vars = array_replace($view->vars, [ + 'errors' => $form->getErrors(), + 'valid' => $form->isSubmitted() ? $form->isValid() : true, + 'value' => $form->getViewData(), + 'data' => $form->getNormData(), + 'required' => $form->isRequired(), + 'label_attr' => $options['label_attr'], + 'help' => $options['help'], + 'help_attr' => $options['help_attr'], + 'help_html' => $options['help_html'], + 'help_translation_parameters' => $helpTranslationParameters, + 'compound' => $formConfig->getCompound(), + 'method' => $formConfig->getMethod(), + 'action' => $formConfig->getAction(), + 'submitted' => $form->isSubmitted(), + ]); + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + $multipart = false; + + foreach ($view->children as $child) { + if ($child->vars['multipart']) { + $multipart = true; + break; + } + } + + $view->vars['multipart'] = $multipart; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + // Derive "data_class" option from passed "data" object + $dataClass = static fn (Options $options) => isset($options['data']) && \is_object($options['data']) ? $options['data']::class : null; + + // Derive "empty_data" closure from "data_class" option + $emptyData = static function (Options $options) { + $class = $options['data_class']; + + if (null !== $class) { + return static fn (FormInterface $form) => $form->isEmpty() && !$form->isRequired() ? null : new $class(); + } + + return static fn (FormInterface $form) => $form->getConfig()->getCompound() ? [] : ''; + }; + + // Wrap "post_max_size_message" in a closure to translate it lazily + $uploadMaxSizeMessage = static fn (Options $options) => static fn () => $options['post_max_size_message']; + + // For any form that is not represented by a single HTML control, + // errors should bubble up by default + $errorBubbling = static fn (Options $options) => $options['compound'] && !$options['inherit_data']; + + // If data is given, the form is locked to that data + // (independent of its value) + $resolver->setDefined([ + 'data', + ]); + + $resolver->setDefaults([ + 'data_class' => $dataClass, + 'empty_data' => $emptyData, + 'trim' => true, + 'required' => true, + 'property_path' => null, + 'mapped' => true, + 'by_reference' => true, + 'error_bubbling' => $errorBubbling, + 'label_attr' => [], + 'inherit_data' => false, + 'compound' => true, + 'method' => 'POST', + // According to RFC 2396 (http://www.ietf.org/rfc/rfc2396.txt) + // section 4.2., empty URIs are considered same-document references + 'action' => '', + 'post_max_size_message' => 'The uploaded file was too large. Please try to upload a smaller file.', + 'upload_max_size_message' => $uploadMaxSizeMessage, // internal + 'allow_file_upload' => false, + 'help' => null, + 'help_attr' => [], + 'help_html' => false, + 'help_translation_parameters' => [], + 'invalid_message' => 'This value is not valid.', + 'invalid_message_parameters' => [], + 'is_empty_callback' => null, + 'getter' => null, + 'setter' => null, + ]); + + $resolver->setAllowedTypes('label_attr', 'array'); + $resolver->setAllowedTypes('action', 'string'); + $resolver->setAllowedTypes('upload_max_size_message', ['callable']); + $resolver->setAllowedTypes('help', ['string', 'null', TranslatableInterface::class]); + $resolver->setAllowedTypes('help_attr', 'array'); + $resolver->setAllowedTypes('help_html', 'bool'); + $resolver->setAllowedTypes('is_empty_callback', ['null', 'callable']); + $resolver->setAllowedTypes('getter', ['null', 'callable']); + $resolver->setAllowedTypes('setter', ['null', 'callable']); + + $resolver->setInfo('getter', 'A callable that accepts two arguments (the view data and the current form field) and must return a value.'); + $resolver->setInfo('setter', 'A callable that accepts three arguments (a reference to the view data, the submitted value and the current form field).'); + } + + public function getParent(): ?string + { + return null; + } + + public function getBlockPrefix(): string + { + return 'form'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/HiddenType.php b/lib/symfony/form/Extension/Core/Type/HiddenType.php new file mode 100644 index 000000000..c4e5eb2cc --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/HiddenType.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class HiddenType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + // hidden fields cannot have a required attribute + 'required' => false, + // Pass errors to the parent + 'error_bubbling' => true, + 'compound' => false, + 'invalid_message' => 'The hidden field is invalid.', + ]); + } + + public function getBlockPrefix(): string + { + return 'hidden'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/IntegerType.php b/lib/symfony/form/Extension/Core/Type/IntegerType.php new file mode 100644 index 000000000..a287b66b7 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/IntegerType.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\DataTransformer\IntegerToLocalizedStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class IntegerType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addViewTransformer(new IntegerToLocalizedStringTransformer($options['grouping'], $options['rounding_mode'], !$options['grouping'] ? 'en' : null)); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + if ($options['grouping']) { + $view->vars['type'] = 'text'; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'grouping' => false, + // Integer cast rounds towards 0, so do the same when displaying fractions + 'rounding_mode' => \NumberFormatter::ROUND_DOWN, + 'compound' => false, + 'invalid_message' => 'Please enter an integer.', + ]); + + $resolver->setAllowedValues('rounding_mode', [ + \NumberFormatter::ROUND_FLOOR, + \NumberFormatter::ROUND_DOWN, + \NumberFormatter::ROUND_HALFDOWN, + \NumberFormatter::ROUND_HALFEVEN, + \NumberFormatter::ROUND_HALFUP, + \NumberFormatter::ROUND_UP, + \NumberFormatter::ROUND_CEILING, + ]); + } + + public function getBlockPrefix(): string + { + return 'integer'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/LanguageType.php b/lib/symfony/form/Extension/Core/Type/LanguageType.php new file mode 100644 index 000000000..eeb9e591a --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/LanguageType.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Intl\Exception\MissingResourceException; +use Symfony\Component\Intl\Intl; +use Symfony\Component\Intl\Languages; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class LanguageType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'choice_loader' => function (Options $options) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class)); + } + $choiceTranslationLocale = $options['choice_translation_locale']; + $useAlpha3Codes = $options['alpha3']; + $choiceSelfTranslation = $options['choice_self_translation']; + + return ChoiceList::loader($this, new IntlCallbackChoiceLoader(static function () use ($choiceTranslationLocale, $useAlpha3Codes, $choiceSelfTranslation) { + if (true === $choiceSelfTranslation) { + foreach (Languages::getLanguageCodes() as $alpha2Code) { + try { + $languageCode = $useAlpha3Codes ? Languages::getAlpha3Code($alpha2Code) : $alpha2Code; + $languagesList[$languageCode] = Languages::getName($alpha2Code, $alpha2Code); + } catch (MissingResourceException) { + // ignore errors like "Couldn't read the indices for the locale 'meta'" + } + } + } else { + $languagesList = $useAlpha3Codes ? Languages::getAlpha3Names($choiceTranslationLocale) : Languages::getNames($choiceTranslationLocale); + } + + return array_flip($languagesList); + }), [$choiceTranslationLocale, $useAlpha3Codes, $choiceSelfTranslation]); + }, + 'choice_translation_domain' => false, + 'choice_translation_locale' => null, + 'alpha3' => false, + 'choice_self_translation' => false, + 'invalid_message' => 'Please select a valid language.', + ]); + + $resolver->setAllowedTypes('choice_self_translation', ['bool']); + $resolver->setAllowedTypes('choice_translation_locale', ['null', 'string']); + $resolver->setAllowedTypes('alpha3', 'bool'); + + $resolver->setNormalizer('choice_self_translation', static function (Options $options, $value) { + if (true === $value && $options['choice_translation_locale']) { + throw new LogicException('Cannot use the "choice_self_translation" and "choice_translation_locale" options at the same time. Remove one of them.'); + } + + return $value; + }); + } + + public function getParent(): ?string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'language'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/LocaleType.php b/lib/symfony/form/Extension/Core/Type/LocaleType.php new file mode 100644 index 000000000..e98134feb --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/LocaleType.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Intl\Intl; +use Symfony\Component\Intl\Locales; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class LocaleType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'choice_loader' => function (Options $options) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s". Try running "composer require symfony/intl".', static::class)); + } + + $choiceTranslationLocale = $options['choice_translation_locale']; + + return ChoiceList::loader($this, new IntlCallbackChoiceLoader(static fn () => array_flip(Locales::getNames($choiceTranslationLocale))), $choiceTranslationLocale); + }, + 'choice_translation_domain' => false, + 'choice_translation_locale' => null, + 'invalid_message' => 'Please select a valid locale.', + ]); + + $resolver->setAllowedTypes('choice_translation_locale', ['null', 'string']); + } + + public function getParent(): ?string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'locale'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/MoneyType.php b/lib/symfony/form/Extension/Core/Type/MoneyType.php new file mode 100644 index 000000000..9c9e5b4d7 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/MoneyType.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\DataTransformer\MoneyToLocalizedStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class MoneyType extends AbstractType +{ + protected static $patterns = []; + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + // Values used in HTML5 number inputs should be formatted as in "1234.5", ie. 'en' format without grouping, + // according to https://www.w3.org/TR/html51/sec-forms.html#date-time-and-number-formats + $builder + ->addViewTransformer(new MoneyToLocalizedStringTransformer( + $options['scale'], + $options['grouping'], + $options['rounding_mode'], + $options['divisor'], + $options['html5'] ? 'en' : null + )) + ; + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['money_pattern'] = self::getPattern($options['currency']); + + if ($options['html5']) { + $view->vars['type'] = 'number'; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'scale' => 2, + 'grouping' => false, + 'rounding_mode' => \NumberFormatter::ROUND_HALFUP, + 'divisor' => 1, + 'currency' => 'EUR', + 'compound' => false, + 'html5' => false, + 'invalid_message' => 'Please enter a valid money amount.', + ]); + + $resolver->setAllowedValues('rounding_mode', [ + \NumberFormatter::ROUND_FLOOR, + \NumberFormatter::ROUND_DOWN, + \NumberFormatter::ROUND_HALFDOWN, + \NumberFormatter::ROUND_HALFEVEN, + \NumberFormatter::ROUND_HALFUP, + \NumberFormatter::ROUND_UP, + \NumberFormatter::ROUND_CEILING, + ]); + + $resolver->setAllowedTypes('scale', 'int'); + + $resolver->setAllowedTypes('html5', 'bool'); + + $resolver->setNormalizer('grouping', static function (Options $options, $value) { + if ($value && $options['html5']) { + throw new LogicException('Cannot use the "grouping" option when the "html5" option is enabled.'); + } + + return $value; + }); + } + + public function getBlockPrefix(): string + { + return 'money'; + } + + /** + * Returns the pattern for this locale in UTF-8. + * + * The pattern contains the placeholder "{{ widget }}" where the HTML tag should + * be inserted + * + * @return string + */ + protected static function getPattern(?string $currency) + { + if (!$currency) { + return '{{ widget }}'; + } + + $locale = \Locale::getDefault(); + + if (!isset(self::$patterns[$locale])) { + self::$patterns[$locale] = []; + } + + if (!isset(self::$patterns[$locale][$currency])) { + $format = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); + $pattern = $format->formatCurrency('123', $currency); + + // the spacings between currency symbol and number are ignored, because + // a single space leads to better readability in combination with input + // fields + + // the regex also considers non-break spaces (0xC2 or 0xA0 in UTF-8) + + preg_match('/^([^\s\xc2\xa0]*)[\s\xc2\xa0]*123(?:[,.]0+)?[\s\xc2\xa0]*([^\s\xc2\xa0]*)$/u', $pattern, $matches); + + if (!empty($matches[1])) { + self::$patterns[$locale][$currency] = $matches[1].' {{ widget }}'; + } elseif (!empty($matches[2])) { + self::$patterns[$locale][$currency] = '{{ widget }} '.$matches[2]; + } else { + self::$patterns[$locale][$currency] = '{{ widget }}'; + } + } + + return self::$patterns[$locale][$currency]; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/NumberType.php b/lib/symfony/form/Extension/Core/Type/NumberType.php new file mode 100644 index 000000000..578991f9f --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/NumberType.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\DataTransformer\NumberToLocalizedStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\StringToFloatTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class NumberType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addViewTransformer(new NumberToLocalizedStringTransformer( + $options['scale'], + $options['grouping'], + $options['rounding_mode'], + $options['html5'] ? 'en' : null + )); + + if ('string' === $options['input']) { + $builder->addModelTransformer(new StringToFloatTransformer($options['scale'])); + } + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + if ($options['html5']) { + $view->vars['type'] = 'number'; + + if (!isset($view->vars['attr']['step'])) { + $view->vars['attr']['step'] = 'any'; + } + } else { + $view->vars['attr']['inputmode'] = 0 === $options['scale'] ? 'numeric' : 'decimal'; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + // default scale is locale specific (usually around 3) + 'scale' => null, + 'grouping' => false, + 'rounding_mode' => \NumberFormatter::ROUND_HALFUP, + 'compound' => false, + 'input' => 'number', + 'html5' => false, + 'invalid_message' => 'Please enter a number.', + ]); + + $resolver->setAllowedValues('rounding_mode', [ + \NumberFormatter::ROUND_FLOOR, + \NumberFormatter::ROUND_DOWN, + \NumberFormatter::ROUND_HALFDOWN, + \NumberFormatter::ROUND_HALFEVEN, + \NumberFormatter::ROUND_HALFUP, + \NumberFormatter::ROUND_UP, + \NumberFormatter::ROUND_CEILING, + ]); + $resolver->setAllowedValues('input', ['number', 'string']); + $resolver->setAllowedTypes('scale', ['null', 'int']); + $resolver->setAllowedTypes('html5', 'bool'); + + $resolver->setNormalizer('grouping', static function (Options $options, $value) { + if (true === $value && $options['html5']) { + throw new LogicException('Cannot use the "grouping" option when the "html5" option is enabled.'); + } + + return $value; + }); + } + + public function getBlockPrefix(): string + { + return 'number'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/PasswordType.php b/lib/symfony/form/Extension/Core/Type/PasswordType.php new file mode 100644 index 000000000..0c247f0f3 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/PasswordType.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\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class PasswordType extends AbstractType +{ + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + if ($options['always_empty'] || !$form->isSubmitted()) { + $view->vars['value'] = ''; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'always_empty' => true, + 'trim' => false, + 'invalid_message' => 'The password is invalid.', + ]); + } + + public function getParent(): ?string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'password'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/PercentType.php b/lib/symfony/form/Extension/Core/Type/PercentType.php new file mode 100644 index 000000000..f71e288b3 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/PercentType.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\DataTransformer\PercentToLocalizedStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class PercentType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addViewTransformer(new PercentToLocalizedStringTransformer( + $options['scale'], + $options['type'], + $options['rounding_mode'], + $options['html5'] + )); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['symbol'] = $options['symbol']; + + if ($options['html5']) { + $view->vars['type'] = 'number'; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'scale' => 0, + 'rounding_mode' => \NumberFormatter::ROUND_HALFUP, + 'symbol' => '%', + 'type' => 'fractional', + 'compound' => false, + 'html5' => false, + 'invalid_message' => 'Please enter a percentage value.', + ]); + + $resolver->setAllowedValues('type', [ + 'fractional', + 'integer', + ]); + $resolver->setAllowedValues('rounding_mode', [ + \NumberFormatter::ROUND_FLOOR, + \NumberFormatter::ROUND_DOWN, + \NumberFormatter::ROUND_HALFDOWN, + \NumberFormatter::ROUND_HALFEVEN, + \NumberFormatter::ROUND_HALFUP, + \NumberFormatter::ROUND_UP, + \NumberFormatter::ROUND_CEILING, + ]); + $resolver->setAllowedTypes('scale', 'int'); + $resolver->setAllowedTypes('symbol', ['bool', 'string']); + $resolver->setAllowedTypes('html5', 'bool'); + } + + public function getBlockPrefix(): string + { + return 'percent'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/RadioType.php b/lib/symfony/form/Extension/Core/Type/RadioType.php new file mode 100644 index 000000000..4b97b0ae2 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/RadioType.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class RadioType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'invalid_message' => 'Please select a valid option.', + ]); + } + + public function getParent(): ?string + { + return CheckboxType::class; + } + + public function getBlockPrefix(): string + { + return 'radio'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/RangeType.php b/lib/symfony/form/Extension/Core/Type/RangeType.php new file mode 100644 index 000000000..2e33a977d --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/RangeType.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class RangeType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'invalid_message' => 'Please choose a valid range.', + ]); + } + + public function getParent(): ?string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'range'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/RepeatedType.php b/lib/symfony/form/Extension/Core/Type/RepeatedType.php new file mode 100644 index 000000000..4176f93e5 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/RepeatedType.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\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\DataTransformer\ValueToDuplicatesTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class RepeatedType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + // Overwrite required option for child fields + $options['first_options']['required'] = $options['required']; + $options['second_options']['required'] = $options['required']; + + if (!isset($options['options']['error_bubbling'])) { + $options['options']['error_bubbling'] = $options['error_bubbling']; + } + + // children fields must always be mapped + $defaultOptions = ['mapped' => true]; + + $builder + ->addViewTransformer(new ValueToDuplicatesTransformer([ + $options['first_name'], + $options['second_name'], + ])) + ->add($options['first_name'], $options['type'], array_merge($options['options'], $options['first_options'], $defaultOptions)) + ->add($options['second_name'], $options['type'], array_merge($options['options'], $options['second_options'], $defaultOptions)) + ; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'type' => TextType::class, + 'options' => [], + 'first_options' => [], + 'second_options' => [], + 'first_name' => 'first', + 'second_name' => 'second', + 'error_bubbling' => false, + 'invalid_message' => 'The values do not match.', + ]); + + $resolver->setAllowedTypes('options', 'array'); + $resolver->setAllowedTypes('first_options', 'array'); + $resolver->setAllowedTypes('second_options', 'array'); + } + + public function getBlockPrefix(): string + { + return 'repeated'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/ResetType.php b/lib/symfony/form/Extension/Core/Type/ResetType.php new file mode 100644 index 000000000..9a53a3dc6 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/ResetType.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ButtonTypeInterface; + +/** + * A reset button. + * + * @author Bernhard Schussek + */ +class ResetType extends AbstractType implements ButtonTypeInterface +{ + public function getParent(): ?string + { + return ButtonType::class; + } + + public function getBlockPrefix(): string + { + return 'reset'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/SearchType.php b/lib/symfony/form/Extension/Core/Type/SearchType.php new file mode 100644 index 000000000..0dca6e42a --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/SearchType.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class SearchType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'invalid_message' => 'Please enter a valid search term.', + ]); + } + + public function getParent(): ?string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'search'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/SubmitType.php b/lib/symfony/form/Extension/Core/Type/SubmitType.php new file mode 100644 index 000000000..3f1b5f95c --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/SubmitType.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\SubmitButtonTypeInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * A submit button. + * + * @author Bernhard Schussek + */ +class SubmitType extends AbstractType implements SubmitButtonTypeInterface +{ + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['clicked'] = $form->isClicked(); + + if (!$options['validate']) { + $view->vars['attr']['formnovalidate'] = true; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('validate', true); + $resolver->setAllowedTypes('validate', 'bool'); + } + + public function getParent(): ?string + { + return ButtonType::class; + } + + public function getBlockPrefix(): string + { + return 'submit'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/TelType.php b/lib/symfony/form/Extension/Core/Type/TelType.php new file mode 100644 index 000000000..05fdd4162 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/TelType.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class TelType extends AbstractType +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'invalid_message' => 'Please provide a valid phone number.', + ]); + } + + public function getParent(): ?string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'tel'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/TextType.php b/lib/symfony/form/Extension/Core/Type/TextType.php new file mode 100644 index 000000000..479ce054d --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/TextType.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class TextType extends AbstractType implements DataTransformerInterface +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + // When empty_data is explicitly set to an empty string, + // a string should always be returned when NULL is submitted + // This gives more control and thus helps preventing some issues + // with PHP 7 which allows type hinting strings in functions + // See https://github.com/symfony/symfony/issues/5906#issuecomment-203189375 + if ('' === $options['empty_data']) { + $builder->addViewTransformer($this); + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'compound' => false, + ]); + } + + public function getBlockPrefix(): string + { + return 'text'; + } + + public function transform(mixed $data): mixed + { + // Model data should not be transformed + return $data; + } + + public function reverseTransform(mixed $data): mixed + { + return $data ?? ''; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/TextareaType.php b/lib/symfony/form/Extension/Core/Type/TextareaType.php new file mode 100644 index 000000000..40e7580d8 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/TextareaType.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; + +class TextareaType extends AbstractType +{ + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['pattern'] = null; + unset($view->vars['attr']['pattern']); + } + + public function getParent(): ?string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'textarea'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/TimeType.php b/lib/symfony/form/Extension/Core/Type/TimeType.php new file mode 100644 index 000000000..512a830bb --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/TimeType.php @@ -0,0 +1,398 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Exception\InvalidConfigurationException; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeImmutableToDateTimeTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToArrayTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; +use Symfony\Component\Form\Event\PreSubmitEvent; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\ReversedTransformer; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class TimeType extends AbstractType +{ + private const WIDGETS = [ + 'text' => TextType::class, + 'choice' => ChoiceType::class, + ]; + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $parts = ['hour']; + $format = 'H'; + + if ($options['with_seconds'] && !$options['with_minutes']) { + throw new InvalidConfigurationException('You cannot disable minutes if you have enabled seconds.'); + } + + if (null !== $options['reference_date'] && $options['reference_date']->getTimezone()->getName() !== $options['model_timezone']) { + throw new InvalidConfigurationException(sprintf('The configured "model_timezone" (%s) must match the timezone of the "reference_date" (%s).', $options['model_timezone'], $options['reference_date']->getTimezone()->getName())); + } + + if ($options['with_minutes']) { + $format .= ':i'; + $parts[] = 'minute'; + } + + if ($options['with_seconds']) { + $format .= ':s'; + $parts[] = 'second'; + } + + if ('single_text' === $options['widget']) { + $builder->addEventListener(FormEvents::PRE_SUBMIT, static function (FormEvent $e) use ($options) { + /** @var PreSubmitEvent $event */ + $data = $e->getData(); + if ($data && preg_match('/^(?P\d{2}):(?P\d{2})(?::(?P\d{2})(?:\.\d+)?)?$/', $data, $matches)) { + if ($options['with_seconds']) { + // handle seconds ignored by user's browser when with_seconds enabled + // https://codereview.chromium.org/450533009/ + $e->setData(sprintf('%s:%s:%s', $matches['hours'], $matches['minutes'], $matches['seconds'] ?? '00')); + } else { + $e->setData(sprintf('%s:%s', $matches['hours'], $matches['minutes'])); + } + } + }); + + $parseFormat = null; + + if (null !== $options['reference_date']) { + $parseFormat = 'Y-m-d '.$format; + + $builder->addEventListener(FormEvents::PRE_SUBMIT, static function (FormEvent $event) use ($options) { + $data = $event->getData(); + + if (preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $data)) { + $event->setData($options['reference_date']->format('Y-m-d ').$data); + } + }); + } + + $builder->addViewTransformer(new DateTimeToStringTransformer($options['model_timezone'], $options['view_timezone'], $format, $parseFormat)); + } else { + $hourOptions = $minuteOptions = $secondOptions = [ + 'error_bubbling' => true, + 'empty_data' => '', + ]; + // when the form is compound the entries of the array are ignored in favor of children data + // so we need to handle the cascade setting here + $emptyData = $builder->getEmptyData() ?: []; + + if ($emptyData instanceof \Closure) { + $lazyEmptyData = static fn ($option) => static function (FormInterface $form) use ($emptyData, $option) { + $emptyData = $emptyData($form->getParent()); + + return $emptyData[$option] ?? ''; + }; + + $hourOptions['empty_data'] = $lazyEmptyData('hour'); + } elseif (isset($emptyData['hour'])) { + $hourOptions['empty_data'] = $emptyData['hour']; + } + + if (isset($options['invalid_message'])) { + $hourOptions['invalid_message'] = $options['invalid_message']; + $minuteOptions['invalid_message'] = $options['invalid_message']; + $secondOptions['invalid_message'] = $options['invalid_message']; + } + + if (isset($options['invalid_message_parameters'])) { + $hourOptions['invalid_message_parameters'] = $options['invalid_message_parameters']; + $minuteOptions['invalid_message_parameters'] = $options['invalid_message_parameters']; + $secondOptions['invalid_message_parameters'] = $options['invalid_message_parameters']; + } + + if ('choice' === $options['widget']) { + $hours = $minutes = []; + + foreach ($options['hours'] as $hour) { + $hours[str_pad($hour, 2, '0', \STR_PAD_LEFT)] = $hour; + } + + // Only pass a subset of the options to children + $hourOptions['choices'] = $hours; + $hourOptions['placeholder'] = $options['placeholder']['hour']; + $hourOptions['choice_translation_domain'] = $options['choice_translation_domain']['hour']; + + if ($options['with_minutes']) { + foreach ($options['minutes'] as $minute) { + $minutes[str_pad($minute, 2, '0', \STR_PAD_LEFT)] = $minute; + } + + $minuteOptions['choices'] = $minutes; + $minuteOptions['placeholder'] = $options['placeholder']['minute']; + $minuteOptions['choice_translation_domain'] = $options['choice_translation_domain']['minute']; + } + + if ($options['with_seconds']) { + $seconds = []; + + foreach ($options['seconds'] as $second) { + $seconds[str_pad($second, 2, '0', \STR_PAD_LEFT)] = $second; + } + + $secondOptions['choices'] = $seconds; + $secondOptions['placeholder'] = $options['placeholder']['second']; + $secondOptions['choice_translation_domain'] = $options['choice_translation_domain']['second']; + } + + // Append generic carry-along options + foreach (['required', 'translation_domain'] as $passOpt) { + $hourOptions[$passOpt] = $options[$passOpt]; + + if ($options['with_minutes']) { + $minuteOptions[$passOpt] = $options[$passOpt]; + } + + if ($options['with_seconds']) { + $secondOptions[$passOpt] = $options[$passOpt]; + } + } + } + + $builder->add('hour', self::WIDGETS[$options['widget']], $hourOptions); + + if ($options['with_minutes']) { + if ($emptyData instanceof \Closure) { + $minuteOptions['empty_data'] = $lazyEmptyData('minute'); + } elseif (isset($emptyData['minute'])) { + $minuteOptions['empty_data'] = $emptyData['minute']; + } + $builder->add('minute', self::WIDGETS[$options['widget']], $minuteOptions); + } + + if ($options['with_seconds']) { + if ($emptyData instanceof \Closure) { + $secondOptions['empty_data'] = $lazyEmptyData('second'); + } elseif (isset($emptyData['second'])) { + $secondOptions['empty_data'] = $emptyData['second']; + } + $builder->add('second', self::WIDGETS[$options['widget']], $secondOptions); + } + + $builder->addViewTransformer(new DateTimeToArrayTransformer($options['model_timezone'], $options['view_timezone'], $parts, 'text' === $options['widget'], $options['reference_date'])); + } + + if ('datetime_immutable' === $options['input']) { + $builder->addModelTransformer(new DateTimeImmutableToDateTimeTransformer()); + } elseif ('string' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToStringTransformer($options['model_timezone'], $options['model_timezone'], $options['input_format']) + )); + } elseif ('timestamp' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToTimestampTransformer($options['model_timezone'], $options['model_timezone']) + )); + } elseif ('array' === $options['input']) { + $builder->addModelTransformer(new ReversedTransformer( + new DateTimeToArrayTransformer($options['model_timezone'], $options['model_timezone'], $parts, 'text' === $options['widget'], $options['reference_date']) + )); + } + + if (\in_array($options['input'], ['datetime', 'datetime_immutable'], true) && null !== $options['model_timezone']) { + $builder->addEventListener(FormEvents::POST_SET_DATA, static function (FormEvent $event) use ($options): void { + $date = $event->getData(); + + if (!$date instanceof \DateTimeInterface) { + return; + } + + if ($date->getTimezone()->getName() !== $options['model_timezone']) { + trigger_deprecation('symfony/form', '6.4', sprintf('Using a "%s" instance with a timezone ("%s") not matching the configured model timezone "%s" is deprecated.', $date::class, $date->getTimezone()->getName(), $options['model_timezone'])); + // throw new LogicException(sprintf('Using a "%s" instance with a timezone ("%s") not matching the configured model timezone "%s" is not supported.', $date::class, $date->getTimezone()->getName(), $options['model_timezone'])); + } + }); + } + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars = array_replace($view->vars, [ + 'widget' => $options['widget'], + 'with_minutes' => $options['with_minutes'], + 'with_seconds' => $options['with_seconds'], + ]); + + // Change the input to an HTML5 time input if + // * the widget is set to "single_text" + // * the html5 is set to true + if ($options['html5'] && 'single_text' === $options['widget']) { + $view->vars['type'] = 'time'; + + // we need to force the browser to display the seconds by + // adding the HTML attribute step if not already defined. + // Otherwise the browser will not display and so not send the seconds + // therefore the value will always be considered as invalid. + if (!isset($view->vars['attr']['step'])) { + if ($options['with_seconds']) { + $view->vars['attr']['step'] = 1; + } elseif (!$options['with_minutes']) { + $view->vars['attr']['step'] = 3600; + } + } + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $compound = static fn (Options $options) => 'single_text' !== $options['widget']; + + $placeholderDefault = static fn (Options $options) => $options['required'] ? null : ''; + + $placeholderNormalizer = static function (Options $options, $placeholder) use ($placeholderDefault) { + if (\is_array($placeholder)) { + $default = $placeholderDefault($options); + + return array_merge( + ['hour' => $default, 'minute' => $default, 'second' => $default], + $placeholder + ); + } + + return [ + 'hour' => $placeholder, + 'minute' => $placeholder, + 'second' => $placeholder, + ]; + }; + + $choiceTranslationDomainNormalizer = static function (Options $options, $choiceTranslationDomain) { + if (\is_array($choiceTranslationDomain)) { + $default = false; + + return array_replace( + ['hour' => $default, 'minute' => $default, 'second' => $default], + $choiceTranslationDomain + ); + } + + return [ + 'hour' => $choiceTranslationDomain, + 'minute' => $choiceTranslationDomain, + 'second' => $choiceTranslationDomain, + ]; + }; + + $modelTimezone = static function (Options $options, $value): ?string { + if (null !== $value) { + return $value; + } + + if (null !== $options['reference_date']) { + return $options['reference_date']->getTimezone()->getName(); + } + + return null; + }; + + $viewTimezone = static function (Options $options, $value): ?string { + if (null !== $value) { + return $value; + } + + if (null !== $options['model_timezone'] && null === $options['reference_date']) { + return $options['model_timezone']; + } + + return null; + }; + + $resolver->setDefaults([ + 'hours' => range(0, 23), + 'minutes' => range(0, 59), + 'seconds' => range(0, 59), + 'widget' => static function (Options $options) { + trigger_deprecation('symfony/form', '6.3', 'Not configuring the "widget" option of form type "time" is deprecated. It will default to "single_text" in Symfony 7.0.'); + + return 'choice'; + }, + 'input' => 'datetime', + 'input_format' => 'H:i:s', + 'with_minutes' => true, + 'with_seconds' => false, + 'model_timezone' => $modelTimezone, + 'view_timezone' => $viewTimezone, + 'reference_date' => null, + 'placeholder' => $placeholderDefault, + 'html5' => true, + // Don't modify \DateTime classes by reference, we treat + // them like immutable value objects + 'by_reference' => false, + 'error_bubbling' => false, + // If initialized with a \DateTime object, FormType initializes + // this option to "\DateTime". Since the internal, normalized + // representation is not \DateTime, but an array, we need to unset + // this option. + 'data_class' => null, + 'empty_data' => static fn (Options $options) => $options['compound'] ? [] : '', + 'compound' => $compound, + 'choice_translation_domain' => false, + 'invalid_message' => 'Please enter a valid time.', + ]); + + $resolver->setNormalizer('view_timezone', static function (Options $options, $viewTimezone): ?string { + if (null !== $options['model_timezone'] && $viewTimezone !== $options['model_timezone'] && null === $options['reference_date']) { + throw new LogicException('Using different values for the "model_timezone" and "view_timezone" options without configuring a reference date is not supported.'); + } + + return $viewTimezone; + }); + + $resolver->setNormalizer('placeholder', $placeholderNormalizer); + $resolver->setNormalizer('choice_translation_domain', $choiceTranslationDomainNormalizer); + + $resolver->setAllowedValues('input', [ + 'datetime', + 'datetime_immutable', + 'string', + 'timestamp', + 'array', + ]); + $resolver->setAllowedValues('widget', [ + 'single_text', + 'text', + 'choice', + ]); + + $resolver->setAllowedTypes('hours', 'array'); + $resolver->setAllowedTypes('minutes', 'array'); + $resolver->setAllowedTypes('seconds', 'array'); + $resolver->setAllowedTypes('input_format', 'string'); + $resolver->setAllowedTypes('model_timezone', ['null', 'string']); + $resolver->setAllowedTypes('view_timezone', ['null', 'string']); + $resolver->setAllowedTypes('reference_date', ['null', \DateTimeInterface::class]); + } + + public function getBlockPrefix(): string + { + return 'time'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/TimezoneType.php b/lib/symfony/form/Extension/Core/Type/TimezoneType.php new file mode 100644 index 000000000..b0913a04a --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/TimezoneType.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\ChoiceList\ChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\IntlCallbackChoiceLoader; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeZoneToStringTransformer; +use Symfony\Component\Form\Extension\Core\DataTransformer\IntlTimeZoneToStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Intl\Intl; +use Symfony\Component\Intl\Timezones; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class TimezoneType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if ('datetimezone' === $options['input']) { + $builder->addModelTransformer(new DateTimeZoneToStringTransformer($options['multiple'])); + } elseif ('intltimezone' === $options['input']) { + $builder->addModelTransformer(new IntlTimeZoneToStringTransformer($options['multiple'])); + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'intl' => false, + 'choice_loader' => function (Options $options) { + $input = $options['input']; + + if ($options['intl']) { + if (!class_exists(Intl::class)) { + throw new LogicException(sprintf('The "symfony/intl" component is required to use "%s" with option "intl=true". Try running "composer require symfony/intl".', static::class)); + } + + $choiceTranslationLocale = $options['choice_translation_locale']; + + return ChoiceList::loader($this, new IntlCallbackChoiceLoader(static fn () => self::getIntlTimezones($input, $choiceTranslationLocale)), [$input, $choiceTranslationLocale]); + } + + return ChoiceList::lazy($this, static fn () => self::getPhpTimezones($input), $input); + }, + 'choice_translation_domain' => false, + 'choice_translation_locale' => null, + 'input' => 'string', + 'invalid_message' => 'Please select a valid timezone.', + 'regions' => \DateTimeZone::ALL, + ]); + + $resolver->setAllowedTypes('intl', ['bool']); + + $resolver->setAllowedTypes('choice_translation_locale', ['null', 'string']); + $resolver->setNormalizer('choice_translation_locale', static function (Options $options, $value) { + if (null !== $value && !$options['intl']) { + throw new LogicException('The "choice_translation_locale" option can only be used if the "intl" option is set to true.'); + } + + return $value; + }); + + $resolver->setAllowedValues('input', ['string', 'datetimezone', 'intltimezone']); + $resolver->setNormalizer('input', static function (Options $options, $value) { + if ('intltimezone' === $value && !class_exists(\IntlTimeZone::class)) { + throw new LogicException('Cannot use "intltimezone" input because the PHP intl extension is not available.'); + } + + return $value; + }); + } + + public function getParent(): ?string + { + return ChoiceType::class; + } + + public function getBlockPrefix(): string + { + return 'timezone'; + } + + private static function getPhpTimezones(string $input): array + { + $timezones = []; + + foreach (\DateTimeZone::listIdentifiers(\DateTimeZone::ALL) as $timezone) { + if ('intltimezone' === $input && 'Etc/Unknown' === \IntlTimeZone::createTimeZone($timezone)->getID()) { + continue; + } + + $timezones[str_replace(['/', '_'], [' / ', ' '], $timezone)] = $timezone; + } + + return $timezones; + } + + private static function getIntlTimezones(string $input, ?string $locale = null): array + { + $timezones = array_flip(Timezones::getNames($locale)); + + if ('intltimezone' === $input) { + foreach ($timezones as $name => $timezone) { + if ('Etc/Unknown' === \IntlTimeZone::createTimeZone($timezone)->getID()) { + unset($timezones[$name]); + } + } + } + + return $timezones; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/TransformationFailureExtension.php b/lib/symfony/form/Extension/Core/Type/TransformationFailureExtension.php new file mode 100644 index 000000000..579f419c4 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/TransformationFailureExtension.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\EventListener\TransformationFailureListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @author Christian Flothmann + */ +class TransformationFailureExtension extends AbstractTypeExtension +{ + private ?TranslatorInterface $translator; + + public function __construct(?TranslatorInterface $translator = null) + { + $this->translator = $translator; + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if (!isset($options['constraints'])) { + $builder->addEventSubscriber(new TransformationFailureListener($this->translator)); + } + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/UlidType.php b/lib/symfony/form/Extension/Core/Type/UlidType.php new file mode 100644 index 000000000..ea3da07c0 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/UlidType.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\DataTransformer\UlidToStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Pavel Dyakonov + */ +class UlidType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->addViewTransformer(new UlidToStringTransformer()) + ; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'compound' => false, + 'invalid_message' => 'Please enter a valid ULID.', + ]); + } +} diff --git a/lib/symfony/form/Extension/Core/Type/UrlType.php b/lib/symfony/form/Extension/Core/Type/UrlType.php new file mode 100644 index 000000000..385c7a25f --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/UrlType.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\EventListener\FixUrlProtocolListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class UrlType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if (null !== $options['default_protocol']) { + $builder->addEventSubscriber(new FixUrlProtocolListener($options['default_protocol'])); + } + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + if ($options['default_protocol']) { + $view->vars['attr']['inputmode'] = 'url'; + $view->vars['type'] = 'text'; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'default_protocol' => 'http', + 'invalid_message' => 'Please enter a valid URL.', + ]); + + $resolver->setAllowedTypes('default_protocol', ['null', 'string']); + } + + public function getParent(): ?string + { + return TextType::class; + } + + public function getBlockPrefix(): string + { + return 'url'; + } +} diff --git a/lib/symfony/form/Extension/Core/Type/UuidType.php b/lib/symfony/form/Extension/Core/Type/UuidType.php new file mode 100644 index 000000000..7c0f65b9a --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/UuidType.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\DataTransformer\UuidToStringTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Pavel Dyakonov + */ +class UuidType extends AbstractType +{ + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->addViewTransformer(new UuidToStringTransformer()) + ; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'compound' => false, + 'invalid_message' => 'Please enter a valid UUID.', + ]); + } +} diff --git a/lib/symfony/form/Extension/Core/Type/WeekType.php b/lib/symfony/form/Extension/Core/Type/WeekType.php new file mode 100644 index 000000000..8027a41a9 --- /dev/null +++ b/lib/symfony/form/Extension/Core/Type/WeekType.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Core\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\DataTransformer\WeekToArrayTransformer; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\ReversedTransformer; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class WeekType extends AbstractType +{ + private const WIDGETS = [ + 'text' => IntegerType::class, + 'choice' => ChoiceType::class, + ]; + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if ('string' === $options['input']) { + $builder->addModelTransformer(new WeekToArrayTransformer()); + } + + if ('single_text' === $options['widget']) { + $builder->addViewTransformer(new ReversedTransformer(new WeekToArrayTransformer())); + } else { + $yearOptions = $weekOptions = [ + 'error_bubbling' => true, + 'empty_data' => '', + ]; + // when the form is compound the entries of the array are ignored in favor of children data + // so we need to handle the cascade setting here + $emptyData = $builder->getEmptyData() ?: []; + + $yearOptions['empty_data'] = $emptyData['year'] ?? ''; + $weekOptions['empty_data'] = $emptyData['week'] ?? ''; + + if (isset($options['invalid_message'])) { + $yearOptions['invalid_message'] = $options['invalid_message']; + $weekOptions['invalid_message'] = $options['invalid_message']; + } + + if (isset($options['invalid_message_parameters'])) { + $yearOptions['invalid_message_parameters'] = $options['invalid_message_parameters']; + $weekOptions['invalid_message_parameters'] = $options['invalid_message_parameters']; + } + + if ('choice' === $options['widget']) { + // Only pass a subset of the options to children + $yearOptions['choices'] = array_combine($options['years'], $options['years']); + $yearOptions['placeholder'] = $options['placeholder']['year']; + $yearOptions['choice_translation_domain'] = $options['choice_translation_domain']['year']; + + $weekOptions['choices'] = array_combine($options['weeks'], $options['weeks']); + $weekOptions['placeholder'] = $options['placeholder']['week']; + $weekOptions['choice_translation_domain'] = $options['choice_translation_domain']['week']; + + // Append generic carry-along options + foreach (['required', 'translation_domain'] as $passOpt) { + $yearOptions[$passOpt] = $options[$passOpt]; + $weekOptions[$passOpt] = $options[$passOpt]; + } + } + + $builder->add('year', self::WIDGETS[$options['widget']], $yearOptions); + $builder->add('week', self::WIDGETS[$options['widget']], $weekOptions); + } + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $view->vars['widget'] = $options['widget']; + + if ($options['html5']) { + $view->vars['type'] = 'week'; + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $compound = static fn (Options $options) => 'single_text' !== $options['widget']; + + $placeholderDefault = static fn (Options $options) => $options['required'] ? null : ''; + + $placeholderNormalizer = static function (Options $options, $placeholder) use ($placeholderDefault) { + if (\is_array($placeholder)) { + $default = $placeholderDefault($options); + + return array_merge( + ['year' => $default, 'week' => $default], + $placeholder + ); + } + + return [ + 'year' => $placeholder, + 'week' => $placeholder, + ]; + }; + + $choiceTranslationDomainNormalizer = static function (Options $options, $choiceTranslationDomain) { + if (\is_array($choiceTranslationDomain)) { + $default = false; + + return array_replace( + ['year' => $default, 'week' => $default], + $choiceTranslationDomain + ); + } + + return [ + 'year' => $choiceTranslationDomain, + 'week' => $choiceTranslationDomain, + ]; + }; + + $resolver->setDefaults([ + 'years' => range(date('Y') - 10, date('Y') + 10), + 'weeks' => array_combine(range(1, 53), range(1, 53)), + 'widget' => 'single_text', + 'input' => 'array', + 'placeholder' => $placeholderDefault, + 'html5' => static fn (Options $options) => 'single_text' === $options['widget'], + 'error_bubbling' => false, + 'empty_data' => static fn (Options $options) => $options['compound'] ? [] : '', + 'compound' => $compound, + 'choice_translation_domain' => false, + 'invalid_message' => 'Please enter a valid week.', + ]); + + $resolver->setNormalizer('placeholder', $placeholderNormalizer); + $resolver->setNormalizer('choice_translation_domain', $choiceTranslationDomainNormalizer); + $resolver->setNormalizer('html5', static function (Options $options, $html5) { + if ($html5 && 'single_text' !== $options['widget']) { + throw new LogicException(sprintf('The "widget" option of "%s" must be set to "single_text" when the "html5" option is enabled.', self::class)); + } + + return $html5; + }); + + $resolver->setAllowedValues('input', [ + 'string', + 'array', + ]); + + $resolver->setAllowedValues('widget', [ + 'single_text', + 'text', + 'choice', + ]); + + $resolver->setAllowedTypes('years', 'int[]'); + $resolver->setAllowedTypes('weeks', 'int[]'); + } + + public function getBlockPrefix(): string + { + return 'week'; + } +} diff --git a/lib/symfony/form/Extension/Csrf/CsrfExtension.php b/lib/symfony/form/Extension/Csrf/CsrfExtension.php new file mode 100644 index 000000000..0a648f834 --- /dev/null +++ b/lib/symfony/form/Extension/Csrf/CsrfExtension.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Csrf; + +use Symfony\Component\Form\AbstractExtension; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * This extension protects forms by using a CSRF token. + * + * @author Bernhard Schussek + */ +class CsrfExtension extends AbstractExtension +{ + private CsrfTokenManagerInterface $tokenManager; + private ?TranslatorInterface $translator; + private ?string $translationDomain; + + public function __construct(CsrfTokenManagerInterface $tokenManager, ?TranslatorInterface $translator = null, ?string $translationDomain = null) + { + $this->tokenManager = $tokenManager; + $this->translator = $translator; + $this->translationDomain = $translationDomain; + } + + protected function loadTypeExtensions(): array + { + return [ + new Type\FormTypeCsrfExtension($this->tokenManager, true, '_token', $this->translator, $this->translationDomain), + ]; + } +} diff --git a/lib/symfony/form/Extension/Csrf/EventListener/CsrfValidationListener.php b/lib/symfony/form/Extension/Csrf/EventListener/CsrfValidationListener.php new file mode 100644 index 000000000..4cfef76bc --- /dev/null +++ b/lib/symfony/form/Extension/Csrf/EventListener/CsrfValidationListener.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Csrf\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\Util\ServerParams; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @author Bernhard Schussek + */ +class CsrfValidationListener implements EventSubscriberInterface +{ + private string $fieldName; + private CsrfTokenManagerInterface $tokenManager; + private string $tokenId; + private string $errorMessage; + private ?TranslatorInterface $translator; + private ?string $translationDomain; + private ServerParams $serverParams; + + public static function getSubscribedEvents(): array + { + return [ + FormEvents::PRE_SUBMIT => 'preSubmit', + ]; + } + + public function __construct(string $fieldName, CsrfTokenManagerInterface $tokenManager, string $tokenId, string $errorMessage, ?TranslatorInterface $translator = null, ?string $translationDomain = null, ?ServerParams $serverParams = null) + { + $this->fieldName = $fieldName; + $this->tokenManager = $tokenManager; + $this->tokenId = $tokenId; + $this->errorMessage = $errorMessage; + $this->translator = $translator; + $this->translationDomain = $translationDomain; + $this->serverParams = $serverParams ?? new ServerParams(); + } + + /** + * @return void + */ + public function preSubmit(FormEvent $event) + { + $form = $event->getForm(); + $postRequestSizeExceeded = 'POST' === $form->getConfig()->getMethod() && $this->serverParams->hasPostMaxSizeBeenExceeded(); + + if ($form->isRoot() && $form->getConfig()->getOption('compound') && !$postRequestSizeExceeded) { + $data = $event->getData(); + + $csrfValue = \is_string($data[$this->fieldName] ?? null) ? $data[$this->fieldName] : null; + $csrfToken = new CsrfToken($this->tokenId, $csrfValue); + + if (null === $csrfValue || !$this->tokenManager->isTokenValid($csrfToken)) { + $errorMessage = $this->errorMessage; + + if (null !== $this->translator) { + $errorMessage = $this->translator->trans($errorMessage, [], $this->translationDomain); + } + + $form->addError(new FormError($errorMessage, $errorMessage, [], null, $csrfToken)); + } + + if (\is_array($data)) { + unset($data[$this->fieldName]); + $event->setData($data); + } + } + } +} diff --git a/lib/symfony/form/Extension/Csrf/Type/FormTypeCsrfExtension.php b/lib/symfony/form/Extension/Csrf/Type/FormTypeCsrfExtension.php new file mode 100644 index 000000000..09056cc8d --- /dev/null +++ b/lib/symfony/form/Extension/Csrf/Type/FormTypeCsrfExtension.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Csrf\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\Extension\Csrf\EventListener\CsrfValidationListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\Util\ServerParams; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @author Bernhard Schussek + */ +class FormTypeCsrfExtension extends AbstractTypeExtension +{ + private CsrfTokenManagerInterface $defaultTokenManager; + private bool $defaultEnabled; + private string $defaultFieldName; + private ?TranslatorInterface $translator; + private ?string $translationDomain; + private ?ServerParams $serverParams; + + public function __construct(CsrfTokenManagerInterface $defaultTokenManager, bool $defaultEnabled = true, string $defaultFieldName = '_token', ?TranslatorInterface $translator = null, ?string $translationDomain = null, ?ServerParams $serverParams = null) + { + $this->defaultTokenManager = $defaultTokenManager; + $this->defaultEnabled = $defaultEnabled; + $this->defaultFieldName = $defaultFieldName; + $this->translator = $translator; + $this->translationDomain = $translationDomain; + $this->serverParams = $serverParams; + } + + /** + * Adds a CSRF field to the form when the CSRF protection is enabled. + * + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if (!$options['csrf_protection']) { + return; + } + + $builder + ->addEventSubscriber(new CsrfValidationListener( + $options['csrf_field_name'], + $options['csrf_token_manager'], + $options['csrf_token_id'] ?: ($builder->getName() ?: $builder->getType()->getInnerType()::class), + $options['csrf_message'], + $this->translator, + $this->translationDomain, + $this->serverParams + )) + ; + } + + /** + * Adds a CSRF field to the root form view. + * + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + if ($options['csrf_protection'] && !$view->parent && $options['compound']) { + $factory = $form->getConfig()->getFormFactory(); + $tokenId = $options['csrf_token_id'] ?: ($form->getName() ?: $form->getConfig()->getType()->getInnerType()::class); + $data = (string) $options['csrf_token_manager']->getToken($tokenId); + + $csrfForm = $factory->createNamed($options['csrf_field_name'], HiddenType::class, $data, [ + 'block_prefix' => 'csrf_token', + 'mapped' => false, + ]); + + $view->children[$options['csrf_field_name']] = $csrfForm->createView($view); + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'csrf_protection' => $this->defaultEnabled, + 'csrf_field_name' => $this->defaultFieldName, + 'csrf_message' => 'The CSRF token is invalid. Please try to resubmit the form.', + 'csrf_token_manager' => $this->defaultTokenManager, + 'csrf_token_id' => null, + ]); + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/lib/symfony/form/Extension/DataCollector/DataCollectorExtension.php b/lib/symfony/form/Extension/DataCollector/DataCollectorExtension.php new file mode 100644 index 000000000..50b36bd67 --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/DataCollectorExtension.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector; + +use Symfony\Component\Form\AbstractExtension; + +/** + * Extension for collecting data of the forms on a page. + * + * @author Robert Schönthal + * @author Bernhard Schussek + */ +class DataCollectorExtension extends AbstractExtension +{ + private FormDataCollectorInterface $dataCollector; + + public function __construct(FormDataCollectorInterface $dataCollector) + { + $this->dataCollector = $dataCollector; + } + + protected function loadTypeExtensions(): array + { + return [ + new Type\DataCollectorTypeExtension($this->dataCollector), + ]; + } +} diff --git a/lib/symfony/form/Extension/DataCollector/EventListener/DataCollectorListener.php b/lib/symfony/form/Extension/DataCollector/EventListener/DataCollectorListener.php new file mode 100644 index 000000000..41a52e091 --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/EventListener/DataCollectorListener.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\Extension\DataCollector\FormDataCollectorInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; + +/** + * Listener that invokes a data collector for the {@link FormEvents::POST_SET_DATA} + * and {@link FormEvents::POST_SUBMIT} events. + * + * @author Bernhard Schussek + */ +class DataCollectorListener implements EventSubscriberInterface +{ + private FormDataCollectorInterface $dataCollector; + + public function __construct(FormDataCollectorInterface $dataCollector) + { + $this->dataCollector = $dataCollector; + } + + public static function getSubscribedEvents(): array + { + return [ + // High priority in order to be called as soon as possible + FormEvents::POST_SET_DATA => ['postSetData', 255], + // Low priority in order to be called as late as possible + FormEvents::POST_SUBMIT => ['postSubmit', -255], + ]; + } + + /** + * Listener for the {@link FormEvents::POST_SET_DATA} event. + * + * @return void + */ + public function postSetData(FormEvent $event) + { + if ($event->getForm()->isRoot()) { + // Collect basic information about each form + $this->dataCollector->collectConfiguration($event->getForm()); + + // Collect the default data + $this->dataCollector->collectDefaultData($event->getForm()); + } + } + + /** + * Listener for the {@link FormEvents::POST_SUBMIT} event. + * + * @return void + */ + public function postSubmit(FormEvent $event) + { + if ($event->getForm()->isRoot()) { + // Collect the submitted data of each form + $this->dataCollector->collectSubmittedData($event->getForm()); + + // Assemble a form tree + // This is done again after the view is built, but we need it here as the view is not always created. + $this->dataCollector->buildPreliminaryFormTree($event->getForm()); + } + } +} diff --git a/lib/symfony/form/Extension/DataCollector/FormDataCollector.php b/lib/symfony/form/Extension/DataCollector/FormDataCollector.php new file mode 100644 index 000000000..1343592b1 --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/FormDataCollector.php @@ -0,0 +1,302 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector; + +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Component\VarDumper\Caster\Caster; +use Symfony\Component\VarDumper\Caster\ClassStub; +use Symfony\Component\VarDumper\Caster\StubCaster; +use Symfony\Component\VarDumper\Cloner\Data; +use Symfony\Component\VarDumper\Cloner\Stub; + +/** + * Data collector for {@link FormInterface} instances. + * + * @author Robert Schönthal + * @author Bernhard Schussek + * + * @final + */ +class FormDataCollector extends DataCollector implements FormDataCollectorInterface +{ + private FormDataExtractorInterface $dataExtractor; + + /** + * Stores the collected data per {@link FormInterface} instance. + * + * Uses the hashes of the forms as keys. This is preferable over using + * {@link \SplObjectStorage}, because in this way no references are kept + * to the {@link FormInterface} instances. + */ + private array $dataByForm; + + /** + * Stores the collected data per {@link FormView} instance. + * + * Uses the hashes of the views as keys. This is preferable over using + * {@link \SplObjectStorage}, because in this way no references are kept + * to the {@link FormView} instances. + */ + private array $dataByView; + + /** + * Connects {@link FormView} with {@link FormInterface} instances. + * + * Uses the hashes of the views as keys and the hashes of the forms as + * values. This is preferable over storing the objects directly, because + * this way they can safely be discarded by the GC. + */ + private array $formsByView; + + public function __construct(FormDataExtractorInterface $dataExtractor) + { + if (!class_exists(ClassStub::class)) { + throw new \LogicException(sprintf('The VarDumper component is needed for using the "%s" class. Install symfony/var-dumper version 3.4 or above.', __CLASS__)); + } + + $this->dataExtractor = $dataExtractor; + + $this->reset(); + } + + /** + * Does nothing. The data is collected during the form event listeners. + */ + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + } + + public function reset(): void + { + $this->data = [ + 'forms' => [], + 'forms_by_hash' => [], + 'nb_errors' => 0, + ]; + } + + public function associateFormWithView(FormInterface $form, FormView $view): void + { + $this->formsByView[spl_object_hash($view)] = spl_object_hash($form); + } + + public function collectConfiguration(FormInterface $form): void + { + $hash = spl_object_hash($form); + + if (!isset($this->dataByForm[$hash])) { + $this->dataByForm[$hash] = []; + } + + $this->dataByForm[$hash] = array_replace( + $this->dataByForm[$hash], + $this->dataExtractor->extractConfiguration($form) + ); + + foreach ($form as $child) { + $this->collectConfiguration($child); + } + } + + public function collectDefaultData(FormInterface $form): void + { + $hash = spl_object_hash($form); + + if (!isset($this->dataByForm[$hash])) { + // field was created by form event + $this->collectConfiguration($form); + } + + $this->dataByForm[$hash] = array_replace( + $this->dataByForm[$hash], + $this->dataExtractor->extractDefaultData($form) + ); + + foreach ($form as $child) { + $this->collectDefaultData($child); + } + } + + public function collectSubmittedData(FormInterface $form): void + { + $hash = spl_object_hash($form); + + if (!isset($this->dataByForm[$hash])) { + // field was created by form event + $this->collectConfiguration($form); + $this->collectDefaultData($form); + } + + $this->dataByForm[$hash] = array_replace( + $this->dataByForm[$hash], + $this->dataExtractor->extractSubmittedData($form) + ); + + // Count errors + if (isset($this->dataByForm[$hash]['errors'])) { + $this->data['nb_errors'] += \count($this->dataByForm[$hash]['errors']); + } + + foreach ($form as $child) { + $this->collectSubmittedData($child); + + // Expand current form if there are children with errors + if (empty($this->dataByForm[$hash]['has_children_error'])) { + $childData = $this->dataByForm[spl_object_hash($child)]; + $this->dataByForm[$hash]['has_children_error'] = !empty($childData['has_children_error']) || !empty($childData['errors']); + } + } + } + + public function collectViewVariables(FormView $view): void + { + $hash = spl_object_hash($view); + + if (!isset($this->dataByView[$hash])) { + $this->dataByView[$hash] = []; + } + + $this->dataByView[$hash] = array_replace( + $this->dataByView[$hash], + $this->dataExtractor->extractViewVariables($view) + ); + + foreach ($view->children as $child) { + $this->collectViewVariables($child); + } + } + + public function buildPreliminaryFormTree(FormInterface $form): void + { + $this->data['forms'][$form->getName()] = &$this->recursiveBuildPreliminaryFormTree($form, $this->data['forms_by_hash']); + } + + public function buildFinalFormTree(FormInterface $form, FormView $view): void + { + $this->data['forms'][$form->getName()] = &$this->recursiveBuildFinalFormTree($form, $view, $this->data['forms_by_hash']); + } + + public function getName(): string + { + return 'form'; + } + + public function getData(): array|Data + { + return $this->data; + } + + /** + * @internal + */ + public function __sleep(): array + { + foreach ($this->data['forms_by_hash'] as &$form) { + if (isset($form['type_class']) && !$form['type_class'] instanceof ClassStub) { + $form['type_class'] = new ClassStub($form['type_class']); + } + } + + $this->data = $this->cloneVar($this->data); + + return parent::__sleep(); + } + + protected function getCasters(): array + { + return parent::getCasters() + [ + \Exception::class => static function (\Exception $e, array $a, Stub $s) { + foreach (["\0Exception\0previous", "\0Exception\0trace"] as $k) { + if (isset($a[$k])) { + unset($a[$k]); + ++$s->cut; + } + } + + return $a; + }, + FormInterface::class => static fn (FormInterface $f, array $a) => [ + Caster::PREFIX_VIRTUAL.'name' => $f->getName(), + Caster::PREFIX_VIRTUAL.'type_class' => new ClassStub($f->getConfig()->getType()->getInnerType()::class), + ], + FormView::class => StubCaster::cutInternals(...), + ConstraintViolationInterface::class => static fn (ConstraintViolationInterface $v, array $a) => [ + Caster::PREFIX_VIRTUAL.'root' => $v->getRoot(), + Caster::PREFIX_VIRTUAL.'path' => $v->getPropertyPath(), + Caster::PREFIX_VIRTUAL.'value' => $v->getInvalidValue(), + ], + ]; + } + + private function &recursiveBuildPreliminaryFormTree(FormInterface $form, array &$outputByHash): array + { + $hash = spl_object_hash($form); + + $output = &$outputByHash[$hash]; + $output = $this->dataByForm[$hash] + ?? []; + + $output['children'] = []; + + foreach ($form as $name => $child) { + $output['children'][$name] = &$this->recursiveBuildPreliminaryFormTree($child, $outputByHash); + } + + return $output; + } + + private function &recursiveBuildFinalFormTree(?FormInterface $form, FormView $view, array &$outputByHash): array + { + $viewHash = spl_object_hash($view); + $formHash = null; + + if (null !== $form) { + $formHash = spl_object_hash($form); + } elseif (isset($this->formsByView[$viewHash])) { + // The FormInterface instance of the CSRF token is never contained in + // the FormInterface tree of the form, so we need to get the + // corresponding FormInterface instance for its view in a different way + $formHash = $this->formsByView[$viewHash]; + } + if (null !== $formHash) { + $output = &$outputByHash[$formHash]; + } + + $output = $this->dataByView[$viewHash] + ?? []; + + if (null !== $formHash) { + $output = array_replace( + $output, + $this->dataByForm[$formHash] + ?? [] + ); + } + + $output['children'] = []; + + foreach ($view->children as $name => $childView) { + // The CSRF token, for example, is never added to the form tree. + // It is only present in the view. + $childForm = $form?->has($name) ? $form->get($name) : null; + + $output['children'][$name] = &$this->recursiveBuildFinalFormTree($childForm, $childView, $outputByHash); + } + + return $output; + } +} diff --git a/lib/symfony/form/Extension/DataCollector/FormDataCollectorInterface.php b/lib/symfony/form/Extension/DataCollector/FormDataCollectorInterface.php new file mode 100644 index 000000000..346c101fe --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/FormDataCollectorInterface.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector; + +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; +use Symfony\Component\VarDumper\Cloner\Data; + +/** + * Collects and structures information about forms. + * + * @author Bernhard Schussek + */ +interface FormDataCollectorInterface extends DataCollectorInterface +{ + /** + * Stores configuration data of the given form and its children. + * + * @return void + */ + public function collectConfiguration(FormInterface $form); + + /** + * Stores the default data of the given form and its children. + * + * @return void + */ + public function collectDefaultData(FormInterface $form); + + /** + * Stores the submitted data of the given form and its children. + * + * @return void + */ + public function collectSubmittedData(FormInterface $form); + + /** + * Stores the view variables of the given form view and its children. + * + * @return void + */ + public function collectViewVariables(FormView $view); + + /** + * Specifies that the given objects represent the same conceptual form. + * + * @return void + */ + public function associateFormWithView(FormInterface $form, FormView $view); + + /** + * Assembles the data collected about the given form and its children as + * a tree-like data structure. + * + * The result can be queried using {@link getData()}. + * + * @return void + */ + public function buildPreliminaryFormTree(FormInterface $form); + + /** + * Assembles the data collected about the given form and its children as + * a tree-like data structure. + * + * The result can be queried using {@link getData()}. + * + * Contrary to {@link buildPreliminaryFormTree()}, a {@link FormView} + * object has to be passed. The tree structure of this view object will be + * used for structuring the resulting data. That means, if a child is + * present in the view, but not in the form, it will be present in the final + * data array anyway. + * + * When {@link FormView} instances are present in the view tree, for which + * no corresponding {@link FormInterface} objects can be found in the form + * tree, only the view data will be included in the result. If a + * corresponding {@link FormInterface} exists otherwise, call + * {@link associateFormWithView()} before calling this method. + * + * @return void + */ + public function buildFinalFormTree(FormInterface $form, FormView $view); + + /** + * Returns all collected data. + */ + public function getData(): array|Data; +} diff --git a/lib/symfony/form/Extension/DataCollector/FormDataExtractor.php b/lib/symfony/form/Extension/DataCollector/FormDataExtractor.php new file mode 100644 index 000000000..158cf3210 --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/FormDataExtractor.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector; + +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Validator\ConstraintViolationInterface; + +/** + * Default implementation of {@link FormDataExtractorInterface}. + * + * @author Bernhard Schussek + */ +class FormDataExtractor implements FormDataExtractorInterface +{ + public function extractConfiguration(FormInterface $form): array + { + $data = [ + 'id' => $this->buildId($form), + 'name' => $form->getName(), + 'type_class' => $form->getConfig()->getType()->getInnerType()::class, + 'synchronized' => $form->isSynchronized(), + 'passed_options' => [], + 'resolved_options' => [], + ]; + + foreach ($form->getConfig()->getAttribute('data_collector/passed_options', []) as $option => $value) { + $data['passed_options'][$option] = $value; + } + + foreach ($form->getConfig()->getOptions() as $option => $value) { + $data['resolved_options'][$option] = $value; + } + + ksort($data['passed_options']); + ksort($data['resolved_options']); + + return $data; + } + + public function extractDefaultData(FormInterface $form): array + { + $data = [ + 'default_data' => [ + 'norm' => $form->getNormData(), + ], + 'submitted_data' => [], + ]; + + if ($form->getData() !== $form->getNormData()) { + $data['default_data']['model'] = $form->getData(); + } + + if ($form->getViewData() !== $form->getNormData()) { + $data['default_data']['view'] = $form->getViewData(); + } + + return $data; + } + + public function extractSubmittedData(FormInterface $form): array + { + $data = [ + 'submitted_data' => [ + 'norm' => $form->getNormData(), + ], + 'errors' => [], + ]; + + if ($form->getViewData() !== $form->getNormData()) { + $data['submitted_data']['view'] = $form->getViewData(); + } + + if ($form->getData() !== $form->getNormData()) { + $data['submitted_data']['model'] = $form->getData(); + } + + foreach ($form->getErrors() as $error) { + $errorData = [ + 'message' => $error->getMessage(), + 'origin' => \is_object($error->getOrigin()) + ? spl_object_hash($error->getOrigin()) + : null, + 'trace' => [], + ]; + + $cause = $error->getCause(); + + while (null !== $cause) { + if ($cause instanceof ConstraintViolationInterface) { + $errorData['trace'][] = $cause; + $cause = method_exists($cause, 'getCause') ? $cause->getCause() : null; + + continue; + } + + if ($cause instanceof \Exception) { + $errorData['trace'][] = $cause; + $cause = $cause->getPrevious(); + + continue; + } + + $errorData['trace'][] = $cause; + + break; + } + + $data['errors'][] = $errorData; + } + + $data['synchronized'] = $form->isSynchronized(); + + return $data; + } + + public function extractViewVariables(FormView $view): array + { + $data = [ + 'id' => $view->vars['id'] ?? null, + 'name' => $view->vars['name'] ?? null, + 'view_vars' => [], + ]; + + foreach ($view->vars as $varName => $value) { + $data['view_vars'][$varName] = $value; + } + + ksort($data['view_vars']); + + return $data; + } + + /** + * Recursively builds an HTML ID for a form. + */ + private function buildId(FormInterface $form): string + { + $id = $form->getName(); + + if (null !== $form->getParent()) { + $id = $this->buildId($form->getParent()).'_'.$id; + } + + return $id; + } +} diff --git a/lib/symfony/form/Extension/DataCollector/FormDataExtractorInterface.php b/lib/symfony/form/Extension/DataCollector/FormDataExtractorInterface.php new file mode 100644 index 000000000..d6e46d467 --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/FormDataExtractorInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector; + +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; + +/** + * Extracts arrays of information out of forms. + * + * @author Bernhard Schussek + */ +interface FormDataExtractorInterface +{ + /** + * Extracts the configuration data of a form. + */ + public function extractConfiguration(FormInterface $form): array; + + /** + * Extracts the default data of a form. + */ + public function extractDefaultData(FormInterface $form): array; + + /** + * Extracts the submitted data of a form. + */ + public function extractSubmittedData(FormInterface $form): array; + + /** + * Extracts the view variables of a form. + */ + public function extractViewVariables(FormView $view): array; +} diff --git a/lib/symfony/form/Extension/DataCollector/Proxy/ResolvedTypeDataCollectorProxy.php b/lib/symfony/form/Extension/DataCollector/Proxy/ResolvedTypeDataCollectorProxy.php new file mode 100644 index 000000000..181a41022 --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/Proxy/ResolvedTypeDataCollectorProxy.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector\Proxy; + +use Symfony\Component\Form\Extension\DataCollector\FormDataCollectorInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\Form\ResolvedFormTypeInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Proxy that invokes a data collector when creating a form and its view. + * + * @author Bernhard Schussek + */ +class ResolvedTypeDataCollectorProxy implements ResolvedFormTypeInterface +{ + private ResolvedFormTypeInterface $proxiedType; + private FormDataCollectorInterface $dataCollector; + + public function __construct(ResolvedFormTypeInterface $proxiedType, FormDataCollectorInterface $dataCollector) + { + $this->proxiedType = $proxiedType; + $this->dataCollector = $dataCollector; + } + + public function getBlockPrefix(): string + { + return $this->proxiedType->getBlockPrefix(); + } + + public function getParent(): ?ResolvedFormTypeInterface + { + return $this->proxiedType->getParent(); + } + + public function getInnerType(): FormTypeInterface + { + return $this->proxiedType->getInnerType(); + } + + public function getTypeExtensions(): array + { + return $this->proxiedType->getTypeExtensions(); + } + + public function createBuilder(FormFactoryInterface $factory, string $name, array $options = []): FormBuilderInterface + { + $builder = $this->proxiedType->createBuilder($factory, $name, $options); + + $builder->setAttribute('data_collector/passed_options', $options); + $builder->setType($this); + + return $builder; + } + + public function createView(FormInterface $form, ?FormView $parent = null): FormView + { + return $this->proxiedType->createView($form, $parent); + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $this->proxiedType->buildForm($builder, $options); + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $this->proxiedType->buildView($view, $form, $options); + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + $this->proxiedType->finishView($view, $form, $options); + + // Remember which view belongs to which form instance, so that we can + // get the collected data for a view when its form instance is not + // available (e.g. CSRF token) + $this->dataCollector->associateFormWithView($form, $view); + + // Since the CSRF token is only present in the FormView tree, we also + // need to check the FormView tree instead of calling isRoot() on the + // FormInterface tree + if (null === $view->parent) { + $this->dataCollector->collectViewVariables($view); + + // Re-assemble data, in case FormView instances were added, for + // which no FormInterface instances were present (e.g. CSRF token). + // Since finishView() is called after finishing the views of all + // children, we can safely assume that information has been + // collected about the complete form tree. + $this->dataCollector->buildFinalFormTree($form, $view); + } + } + + public function getOptionsResolver(): OptionsResolver + { + return $this->proxiedType->getOptionsResolver(); + } +} diff --git a/lib/symfony/form/Extension/DataCollector/Proxy/ResolvedTypeFactoryDataCollectorProxy.php b/lib/symfony/form/Extension/DataCollector/Proxy/ResolvedTypeFactoryDataCollectorProxy.php new file mode 100644 index 000000000..f93448412 --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/Proxy/ResolvedTypeFactoryDataCollectorProxy.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector\Proxy; + +use Symfony\Component\Form\Extension\DataCollector\FormDataCollectorInterface; +use Symfony\Component\Form\FormTypeInterface; +use Symfony\Component\Form\ResolvedFormTypeFactoryInterface; +use Symfony\Component\Form\ResolvedFormTypeInterface; + +/** + * Proxy that wraps resolved types into {@link ResolvedTypeDataCollectorProxy} + * instances. + * + * @author Bernhard Schussek + */ +class ResolvedTypeFactoryDataCollectorProxy implements ResolvedFormTypeFactoryInterface +{ + private ResolvedFormTypeFactoryInterface $proxiedFactory; + private FormDataCollectorInterface $dataCollector; + + public function __construct(ResolvedFormTypeFactoryInterface $proxiedFactory, FormDataCollectorInterface $dataCollector) + { + $this->proxiedFactory = $proxiedFactory; + $this->dataCollector = $dataCollector; + } + + public function createResolvedType(FormTypeInterface $type, array $typeExtensions, ?ResolvedFormTypeInterface $parent = null): ResolvedFormTypeInterface + { + return new ResolvedTypeDataCollectorProxy( + $this->proxiedFactory->createResolvedType($type, $typeExtensions, $parent), + $this->dataCollector + ); + } +} diff --git a/lib/symfony/form/Extension/DataCollector/Type/DataCollectorTypeExtension.php b/lib/symfony/form/Extension/DataCollector/Type/DataCollectorTypeExtension.php new file mode 100644 index 000000000..f1e3c903e --- /dev/null +++ b/lib/symfony/form/Extension/DataCollector/Type/DataCollectorTypeExtension.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DataCollector\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\DataCollector\EventListener\DataCollectorListener; +use Symfony\Component\Form\Extension\DataCollector\FormDataCollectorInterface; +use Symfony\Component\Form\FormBuilderInterface; + +/** + * Type extension for collecting data of a form with this type. + * + * @author Robert Schönthal + * @author Bernhard Schussek + */ +class DataCollectorTypeExtension extends AbstractTypeExtension +{ + private DataCollectorListener $listener; + + public function __construct(FormDataCollectorInterface $dataCollector) + { + $this->listener = new DataCollectorListener($dataCollector); + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addEventSubscriber($this->listener); + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/lib/symfony/form/Extension/DependencyInjection/DependencyInjectionExtension.php b/lib/symfony/form/Extension/DependencyInjection/DependencyInjectionExtension.php new file mode 100644 index 000000000..6564bd565 --- /dev/null +++ b/lib/symfony/form/Extension/DependencyInjection/DependencyInjectionExtension.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\DependencyInjection; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\FormExtensionInterface; +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeGuesserChain; +use Symfony\Component\Form\FormTypeGuesserInterface; +use Symfony\Component\Form\FormTypeInterface; + +class DependencyInjectionExtension implements FormExtensionInterface +{ + private ?FormTypeGuesserChain $guesser = null; + private bool $guesserLoaded = false; + private ContainerInterface $typeContainer; + private array $typeExtensionServices; + private iterable $guesserServices; + + /** + * @param array> $typeExtensionServices + */ + public function __construct(ContainerInterface $typeContainer, array $typeExtensionServices, iterable $guesserServices) + { + $this->typeContainer = $typeContainer; + $this->typeExtensionServices = $typeExtensionServices; + $this->guesserServices = $guesserServices; + } + + public function getType(string $name): FormTypeInterface + { + if (!$this->typeContainer->has($name)) { + throw new InvalidArgumentException(sprintf('The field type "%s" is not registered in the service container.', $name)); + } + + return $this->typeContainer->get($name); + } + + public function hasType(string $name): bool + { + return $this->typeContainer->has($name); + } + + public function getTypeExtensions(string $name): array + { + $extensions = []; + + if (isset($this->typeExtensionServices[$name])) { + foreach ($this->typeExtensionServices[$name] as $extension) { + $extensions[] = $extension; + + $extendedTypes = []; + foreach ($extension::getExtendedTypes() as $extendedType) { + $extendedTypes[] = $extendedType; + } + + // validate the result of getExtendedTypes() to ensure it is consistent with the service definition + if (!\in_array($name, $extendedTypes, true)) { + throw new InvalidArgumentException(sprintf('The extended type "%s" specified for the type extension class "%s" does not match any of the actual extended types (["%s"]).', $name, $extension::class, implode('", "', $extendedTypes))); + } + } + } + + return $extensions; + } + + public function hasTypeExtensions(string $name): bool + { + return isset($this->typeExtensionServices[$name]); + } + + public function getTypeGuesser(): ?FormTypeGuesserInterface + { + if (!$this->guesserLoaded) { + $this->guesserLoaded = true; + $guessers = []; + + foreach ($this->guesserServices as $serviceId => $service) { + $guessers[] = $service; + } + + if ($guessers) { + $this->guesser = new FormTypeGuesserChain($guessers); + } + } + + return $this->guesser; + } +} diff --git a/lib/symfony/form/Extension/HtmlSanitizer/HtmlSanitizerExtension.php b/lib/symfony/form/Extension/HtmlSanitizer/HtmlSanitizerExtension.php new file mode 100644 index 000000000..6c4bf49d6 --- /dev/null +++ b/lib/symfony/form/Extension/HtmlSanitizer/HtmlSanitizerExtension.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\HtmlSanitizer; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Form\AbstractExtension; + +/** + * Integrates the HtmlSanitizer component with the Form library. + * + * @author Nicolas Grekas + */ +class HtmlSanitizerExtension extends AbstractExtension +{ + public function __construct( + private ContainerInterface $sanitizers, + private string $defaultSanitizer = 'default', + ) { + } + + protected function loadTypeExtensions(): array + { + return [ + new Type\TextTypeHtmlSanitizerExtension($this->sanitizers, $this->defaultSanitizer), + ]; + } +} diff --git a/lib/symfony/form/Extension/HtmlSanitizer/Type/TextTypeHtmlSanitizerExtension.php b/lib/symfony/form/Extension/HtmlSanitizer/Type/TextTypeHtmlSanitizerExtension.php new file mode 100644 index 000000000..8e92ea74a --- /dev/null +++ b/lib/symfony/form/Extension/HtmlSanitizer/Type/TextTypeHtmlSanitizerExtension.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\Extension\HtmlSanitizer\Type; + +use Psr\Container\ContainerInterface; +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Titouan Galopin + */ +class TextTypeHtmlSanitizerExtension extends AbstractTypeExtension +{ + public function __construct( + private ContainerInterface $sanitizers, + private string $defaultSanitizer = 'default', + ) { + } + + public static function getExtendedTypes(): iterable + { + return [TextType::class]; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setDefaults(['sanitize_html' => false, 'sanitizer' => null]) + ->setAllowedTypes('sanitize_html', 'bool') + ->setAllowedTypes('sanitizer', ['string', 'null']) + ; + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if (!$options['sanitize_html']) { + return; + } + + $sanitizers = $this->sanitizers; + $sanitizer = $options['sanitizer'] ?? $this->defaultSanitizer; + + $builder->addEventListener( + FormEvents::PRE_SUBMIT, + static function (FormEvent $event) use ($sanitizers, $sanitizer) { + if (\is_scalar($data = $event->getData()) && '' !== trim($data)) { + $event->setData($sanitizers->get($sanitizer)->sanitize($data)); + } + }, + 10000 /* as soon as possible */ + ); + } +} diff --git a/lib/symfony/form/Extension/HttpFoundation/HttpFoundationExtension.php b/lib/symfony/form/Extension/HttpFoundation/HttpFoundationExtension.php new file mode 100644 index 000000000..85bc4f472 --- /dev/null +++ b/lib/symfony/form/Extension/HttpFoundation/HttpFoundationExtension.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\HttpFoundation; + +use Symfony\Component\Form\AbstractExtension; + +/** + * Integrates the HttpFoundation component with the Form library. + * + * @author Bernhard Schussek + */ +class HttpFoundationExtension extends AbstractExtension +{ + protected function loadTypeExtensions(): array + { + return [ + new Type\FormTypeHttpFoundationExtension(), + ]; + } +} diff --git a/lib/symfony/form/Extension/HttpFoundation/HttpFoundationRequestHandler.php b/lib/symfony/form/Extension/HttpFoundation/HttpFoundationRequestHandler.php new file mode 100644 index 000000000..fd2ecb018 --- /dev/null +++ b/lib/symfony/form/Extension/HttpFoundation/HttpFoundationRequestHandler.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\HttpFoundation; + +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\RequestHandlerInterface; +use Symfony\Component\Form\Util\FormUtil; +use Symfony\Component\Form\Util\ServerParams; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; +use Symfony\Component\HttpFoundation\Request; + +/** + * A request processor using the {@link Request} class of the HttpFoundation + * component. + * + * @author Bernhard Schussek + */ +class HttpFoundationRequestHandler implements RequestHandlerInterface +{ + private ServerParams $serverParams; + + public function __construct(?ServerParams $serverParams = null) + { + $this->serverParams = $serverParams ?? new ServerParams(); + } + + /** + * @return void + */ + public function handleRequest(FormInterface $form, mixed $request = null) + { + if (!$request instanceof Request) { + throw new UnexpectedTypeException($request, Request::class); + } + + $name = $form->getName(); + $method = $form->getConfig()->getMethod(); + + if ($method !== $request->getMethod()) { + return; + } + + // For request methods that must not have a request body we fetch data + // from the query string. Otherwise we look for data in the request body. + if ('GET' === $method || 'HEAD' === $method || 'TRACE' === $method) { + if ('' === $name) { + $data = $request->query->all(); + } else { + // Don't submit GET requests if the form's name does not exist + // in the request + if (!$request->query->has($name)) { + return; + } + + $data = $request->query->all()[$name]; + } + } else { + // Mark the form with an error if the uploaded size was too large + // This is done here and not in FormValidator because $_POST is + // empty when that error occurs. Hence the form is never submitted. + if ($this->serverParams->hasPostMaxSizeBeenExceeded()) { + // Submit the form, but don't clear the default values + $form->submit(null, false); + + $form->addError(new FormError( + $form->getConfig()->getOption('upload_max_size_message')(), + null, + ['{{ max }}' => $this->serverParams->getNormalizedIniPostMaxSize()] + )); + + return; + } + + if ('' === $name) { + $params = $request->request->all(); + $files = $request->files->all(); + } elseif ($request->request->has($name) || $request->files->has($name)) { + $default = $form->getConfig()->getCompound() ? [] : null; + $params = $request->request->all()[$name] ?? $default; + $files = $request->files->get($name, $default); + } else { + // Don't submit the form if it is not present in the request + return; + } + + if (\is_array($params) && \is_array($files)) { + $data = FormUtil::mergeParamsAndFiles($params, $files); + } else { + $data = $params ?: $files; + } + } + + // Don't auto-submit the form unless at least one field is present. + if ('' === $name && \count(array_intersect_key($data, $form->all())) <= 0) { + return; + } + + $form->submit($data, 'PATCH' !== $method); + } + + public function isFileUpload(mixed $data): bool + { + return $data instanceof File; + } + + public function getUploadFileError(mixed $data): ?int + { + if (!$data instanceof UploadedFile || $data->isValid()) { + return null; + } + + return $data->getError(); + } +} diff --git a/lib/symfony/form/Extension/HttpFoundation/Type/FormTypeHttpFoundationExtension.php b/lib/symfony/form/Extension/HttpFoundation/Type/FormTypeHttpFoundationExtension.php new file mode 100644 index 000000000..822265519 --- /dev/null +++ b/lib/symfony/form/Extension/HttpFoundation/Type/FormTypeHttpFoundationExtension.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\HttpFoundation\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\RequestHandlerInterface; + +/** + * @author Bernhard Schussek + */ +class FormTypeHttpFoundationExtension extends AbstractTypeExtension +{ + private RequestHandlerInterface $requestHandler; + + public function __construct(?RequestHandlerInterface $requestHandler = null) + { + $this->requestHandler = $requestHandler ?? new HttpFoundationRequestHandler(); + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->setRequestHandler($this->requestHandler); + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/lib/symfony/form/Extension/PasswordHasher/EventListener/PasswordHasherListener.php b/lib/symfony/form/Extension/PasswordHasher/EventListener/PasswordHasherListener.php new file mode 100644 index 000000000..4854dd3e7 --- /dev/null +++ b/lib/symfony/form/Extension/PasswordHasher/EventListener/PasswordHasherListener.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\PasswordHasher\EventListener; + +use Symfony\Component\Form\Exception\InvalidConfigurationException; +use Symfony\Component\Form\Extension\Core\Type\RepeatedType; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; + +/** + * @author Sébastien Alfaiate + * @author Gábor Egyed + */ +class PasswordHasherListener +{ + private array $passwords = []; + + public function __construct( + private UserPasswordHasherInterface $passwordHasher, + private ?PropertyAccessorInterface $propertyAccessor = null, + ) { + $this->propertyAccessor ??= PropertyAccess::createPropertyAccessor(); + } + + /** + * @return void + */ + public function registerPassword(FormEvent $event) + { + if (null === $event->getData() || '' === $event->getData()) { + return; + } + + $this->assertNotMapped($event->getForm()); + + $this->passwords[] = [ + 'form' => $event->getForm(), + 'property_path' => $event->getForm()->getConfig()->getOption('hash_property_path'), + 'password' => $event->getData(), + ]; + } + + /** + * @return void + */ + public function hashPasswords(FormEvent $event) + { + $form = $event->getForm(); + + if (!$form->isRoot()) { + return; + } + + if ($form->isValid()) { + foreach ($this->passwords as $password) { + $user = $this->getUser($password['form']); + + $this->propertyAccessor->setValue( + $user, + $password['property_path'], + $this->passwordHasher->hashPassword($user, $password['password']) + ); + } + } + + $this->passwords = []; + } + + private function getTargetForm(FormInterface $form): FormInterface + { + if (!$parentForm = $form->getParent()) { + return $form; + } + + $parentType = $parentForm->getConfig()->getType(); + + do { + if ($parentType->getInnerType() instanceof RepeatedType) { + return $parentForm; + } + } while ($parentType = $parentType->getParent()); + + return $form; + } + + private function getUser(FormInterface $form): PasswordAuthenticatedUserInterface + { + $parent = $this->getTargetForm($form)->getParent(); + + if (!($user = $parent?->getData()) || !$user instanceof PasswordAuthenticatedUserInterface) { + throw new InvalidConfigurationException(sprintf('The "hash_property_path" option only supports "%s" objects, "%s" given.', PasswordAuthenticatedUserInterface::class, get_debug_type($user))); + } + + return $user; + } + + private function assertNotMapped(FormInterface $form): void + { + if ($this->getTargetForm($form)->getConfig()->getMapped()) { + throw new InvalidConfigurationException('The "hash_property_path" option cannot be used on mapped field.'); + } + } +} diff --git a/lib/symfony/form/Extension/PasswordHasher/PasswordHasherExtension.php b/lib/symfony/form/Extension/PasswordHasher/PasswordHasherExtension.php new file mode 100644 index 000000000..b9675c215 --- /dev/null +++ b/lib/symfony/form/Extension/PasswordHasher/PasswordHasherExtension.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\PasswordHasher; + +use Symfony\Component\Form\AbstractExtension; +use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener; + +/** + * Integrates the PasswordHasher component with the Form library. + * + * @author Sébastien Alfaiate + */ +class PasswordHasherExtension extends AbstractExtension +{ + public function __construct( + private PasswordHasherListener $passwordHasherListener, + ) { + } + + protected function loadTypeExtensions(): array + { + return [ + new Type\FormTypePasswordHasherExtension($this->passwordHasherListener), + new Type\PasswordTypePasswordHasherExtension($this->passwordHasherListener), + ]; + } +} diff --git a/lib/symfony/form/Extension/PasswordHasher/Type/FormTypePasswordHasherExtension.php b/lib/symfony/form/Extension/PasswordHasher/Type/FormTypePasswordHasherExtension.php new file mode 100644 index 000000000..530899286 --- /dev/null +++ b/lib/symfony/form/Extension/PasswordHasher/Type/FormTypePasswordHasherExtension.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\PasswordHasher\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvents; + +/** + * @author Sébastien Alfaiate + */ +class FormTypePasswordHasherExtension extends AbstractTypeExtension +{ + public function __construct( + private PasswordHasherListener $passwordHasherListener, + ) { + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addEventListener(FormEvents::POST_SUBMIT, [$this->passwordHasherListener, 'hashPasswords']); + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/lib/symfony/form/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtension.php b/lib/symfony/form/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtension.php new file mode 100644 index 000000000..6f022fb1b --- /dev/null +++ b/lib/symfony/form/Extension/PasswordHasher/Type/PasswordTypePasswordHasherExtension.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\PasswordHasher\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\Extension\PasswordHasher\EventListener\PasswordHasherListener; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\PropertyAccess\PropertyPath; + +/** + * @author Sébastien Alfaiate + */ +class PasswordTypePasswordHasherExtension extends AbstractTypeExtension +{ + public function __construct( + private PasswordHasherListener $passwordHasherListener, + ) { + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + if ($options['hash_property_path']) { + $builder->addEventListener(FormEvents::POST_SUBMIT, [$this->passwordHasherListener, 'registerPassword']); + } + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'hash_property_path' => null, + ]); + + $resolver->setAllowedTypes('hash_property_path', ['null', 'string', PropertyPath::class]); + + $resolver->setInfo('hash_property_path', 'A valid PropertyAccess syntax where the hashed password will be set.'); + } + + public static function getExtendedTypes(): iterable + { + return [PasswordType::class]; + } +} diff --git a/lib/symfony/form/Extension/Validator/Constraints/Form.php b/lib/symfony/form/Extension/Validator/Constraints/Form.php new file mode 100644 index 000000000..6dec01be2 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/Constraints/Form.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; + +/** + * @author Bernhard Schussek + */ +class Form extends Constraint +{ + public const NOT_SYNCHRONIZED_ERROR = '1dafa156-89e1-4736-b832-419c2e501fca'; + public const NO_SUCH_FIELD_ERROR = '6e5212ed-a197-4339-99aa-5654798a4854'; + + protected const ERROR_NAMES = [ + self::NOT_SYNCHRONIZED_ERROR => 'NOT_SYNCHRONIZED_ERROR', + self::NO_SUCH_FIELD_ERROR => 'NO_SUCH_FIELD_ERROR', + ]; + + /** + * @deprecated since Symfony 6.1, use const ERROR_NAMES instead + */ + protected static $errorNames = self::ERROR_NAMES; + + public function getTargets(): string|array + { + return self::CLASS_CONSTRAINT; + } +} diff --git a/lib/symfony/form/Extension/Validator/Constraints/FormValidator.php b/lib/symfony/form/Extension/Validator/Constraints/FormValidator.php new file mode 100644 index 000000000..d664e9b50 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/Constraints/FormValidator.php @@ -0,0 +1,279 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\Constraints; + +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Composite; +use Symfony\Component\Validator\Constraints\GroupSequence; +use Symfony\Component\Validator\Constraints\Valid; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * @author Bernhard Schussek + */ +class FormValidator extends ConstraintValidator +{ + /** + * @var \SplObjectStorage> + */ + private \SplObjectStorage $resolvedGroups; + + /** + * @return void + */ + public function validate(mixed $form, Constraint $formConstraint) + { + if (!$formConstraint instanceof Form) { + throw new UnexpectedTypeException($formConstraint, Form::class); + } + + if (!$form instanceof FormInterface) { + return; + } + + /* @var FormInterface $form */ + $config = $form->getConfig(); + + $validator = $this->context->getValidator()->inContext($this->context); + + if ($form->isSubmitted() && $form->isSynchronized()) { + // Validate the form data only if transformation succeeded + $groups = $this->getValidationGroups($form); + + if (!$groups) { + return; + } + + $data = $form->getData(); + // Validate the data against its own constraints + $validateDataGraph = $form->isRoot() + && (\is_object($data) || \is_array($data)) + && (($groups && \is_array($groups)) || ($groups instanceof GroupSequence && $groups->groups)) + ; + + // Validate the data against the constraints defined in the form + /** @var Constraint[] $constraints */ + $constraints = $config->getOption('constraints', []); + + $hasChildren = $form->count() > 0; + + if ($hasChildren && $form->isRoot()) { + $this->resolvedGroups = new \SplObjectStorage(); + } + + if ($groups instanceof GroupSequence) { + // Validate the data, the form AND nested fields in sequence + $violationsCount = $this->context->getViolations()->count(); + + foreach ($groups->groups as $group) { + if ($validateDataGraph) { + $validator->atPath('data')->validate($data, null, $group); + } + + if ($groupedConstraints = self::getConstraintsInGroups($constraints, $group)) { + $validator->atPath('data')->validate($data, $groupedConstraints, $group); + } + + foreach ($form->all() as $field) { + if ($field->isSubmitted()) { + // remember to validate this field in one group only + // otherwise resolving the groups would reuse the same + // sequence recursively, thus some fields could fail + // in different steps without breaking early enough + $this->resolvedGroups[$field] = (array) $group; + $fieldFormConstraint = new Form(); + $fieldFormConstraint->groups = $group; + $this->context->setNode($this->context->getValue(), $field, $this->context->getMetadata(), $this->context->getPropertyPath()); + $validator->atPath(sprintf('children[%s]', $field->getName()))->validate($field, $fieldFormConstraint, $group); + } + } + + if ($violationsCount < $this->context->getViolations()->count()) { + break; + } + } + } else { + if ($validateDataGraph) { + $validator->atPath('data')->validate($data, null, $groups); + } + + $groupedConstraints = []; + + foreach ($constraints as $constraint) { + // For the "Valid" constraint, validate the data in all groups + if ($constraint instanceof Valid) { + if (\is_object($data) || \is_array($data)) { + $validator->atPath('data')->validate($data, $constraint, $groups); + } + + continue; + } + + // Otherwise validate a constraint only once for the first + // matching group + foreach ($groups as $group) { + if (\in_array($group, $constraint->groups)) { + $groupedConstraints[$group][] = $constraint; + + // Prevent duplicate validation + if (!$constraint instanceof Composite) { + continue 2; + } + } + } + } + + foreach ($groupedConstraints as $group => $constraint) { + $validator->atPath('data')->validate($data, $constraint, $group); + } + + foreach ($form->all() as $field) { + if ($field->isSubmitted()) { + $this->resolvedGroups[$field] = $groups; + $this->context->setNode($this->context->getValue(), $field, $this->context->getMetadata(), $this->context->getPropertyPath()); + $validator->atPath(sprintf('children[%s]', $field->getName()))->validate($field, $formConstraint); + } + } + } + + if ($hasChildren && $form->isRoot()) { + // destroy storage to avoid memory leaks + $this->resolvedGroups = new \SplObjectStorage(); + } + } elseif (!$form->isSynchronized()) { + $childrenSynchronized = true; + + /** @var FormInterface $child */ + foreach ($form as $child) { + if (!$child->isSynchronized()) { + $childrenSynchronized = false; + $this->context->setNode($this->context->getValue(), $child, $this->context->getMetadata(), $this->context->getPropertyPath()); + $validator->atPath(sprintf('children[%s]', $child->getName()))->validate($child, $formConstraint); + } + } + + // Mark the form with an error if it is not synchronized BUT all + // of its children are synchronized. If any child is not + // synchronized, an error is displayed there already and showing + // a second error in its parent form is pointless, or worse, may + // lead to duplicate errors if error bubbling is enabled on the + // child. + // See also https://github.com/symfony/symfony/issues/4359 + if ($childrenSynchronized) { + $clientDataAsString = \is_scalar($form->getViewData()) + ? (string) $form->getViewData() + : get_debug_type($form->getViewData()); + + $failure = $form->getTransformationFailure(); + + $this->context->setConstraint($formConstraint); + $this->context->buildViolation($failure->getInvalidMessage() ?? $config->getOption('invalid_message')) + ->setParameters(array_replace( + ['{{ value }}' => $clientDataAsString], + $config->getOption('invalid_message_parameters'), + $failure->getInvalidMessageParameters() + )) + ->setInvalidValue($form->getViewData()) + ->setCode(Form::NOT_SYNCHRONIZED_ERROR) + ->setCause($failure) + ->addViolation(); + } + } + + // Mark the form with an error if it contains extra fields + if (!$config->getOption('allow_extra_fields') && \count($form->getExtraData()) > 0) { + $this->context->setConstraint($formConstraint); + $this->context->buildViolation($config->getOption('extra_fields_message', '')) + ->setParameter('{{ extra_fields }}', '"'.implode('", "', array_keys($form->getExtraData())).'"') + ->setPlural(\count($form->getExtraData())) + ->setInvalidValue($form->getExtraData()) + ->setCode(Form::NO_SUCH_FIELD_ERROR) + ->addViolation(); + } + } + + /** + * Returns the validation groups of the given form. + * + * @return string|GroupSequence|array + */ + private function getValidationGroups(FormInterface $form): string|GroupSequence|array + { + // Determine the clicked button of the complete form tree + $clickedButton = null; + + if (method_exists($form, 'getClickedButton')) { + $clickedButton = $form->getClickedButton(); + } + + if (null !== $clickedButton) { + $groups = $clickedButton->getConfig()->getOption('validation_groups'); + + if (null !== $groups) { + return self::resolveValidationGroups($groups, $form); + } + } + + do { + $groups = $form->getConfig()->getOption('validation_groups'); + + if (null !== $groups) { + return self::resolveValidationGroups($groups, $form); + } + + if (isset($this->resolvedGroups[$form])) { + return $this->resolvedGroups[$form]; + } + + $form = $form->getParent(); + } while (null !== $form); + + return [Constraint::DEFAULT_GROUP]; + } + + /** + * Post-processes the validation groups option for a given form. + * + * @param string|GroupSequence|array|callable $groups The validation groups + * + * @return GroupSequence|array + */ + private static function resolveValidationGroups(string|GroupSequence|array|callable $groups, FormInterface $form): GroupSequence|array + { + if (!\is_string($groups) && \is_callable($groups)) { + $groups = $groups($form); + } + + if ($groups instanceof GroupSequence) { + return $groups; + } + + return (array) $groups; + } + + private static function getConstraintsInGroups(array $constraints, string|array $group): array + { + $groups = (array) $group; + + return array_filter($constraints, static function (Constraint $constraint) use ($groups) { + foreach ($groups as $group) { + if (\in_array($group, $constraint->groups, true)) { + return true; + } + } + + return false; + }); + } +} diff --git a/lib/symfony/form/Extension/Validator/EventListener/ValidationListener.php b/lib/symfony/form/Extension/Validator/EventListener/ValidationListener.php new file mode 100644 index 000000000..e2d435762 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/EventListener/ValidationListener.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\Extension\Validator\Constraints\Form; +use Symfony\Component\Form\Extension\Validator\ViolationMapper\ViolationMapperInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * @author Bernhard Schussek + */ +class ValidationListener implements EventSubscriberInterface +{ + private ValidatorInterface $validator; + private ViolationMapperInterface $violationMapper; + + public static function getSubscribedEvents(): array + { + return [FormEvents::POST_SUBMIT => 'validateForm']; + } + + public function __construct(ValidatorInterface $validator, ViolationMapperInterface $violationMapper) + { + $this->validator = $validator; + $this->violationMapper = $violationMapper; + } + + /** + * @return void + */ + public function validateForm(FormEvent $event) + { + $form = $event->getForm(); + + if ($form->isRoot()) { + // Form groups are validated internally (FormValidator). Here we don't set groups as they are retrieved into the validator. + foreach ($this->validator->validate($form) as $violation) { + // Allow the "invalid" constraint to be put onto + // non-synchronized forms + $allowNonSynchronized = $violation->getConstraint() instanceof Form && Form::NOT_SYNCHRONIZED_ERROR === $violation->getCode(); + + $this->violationMapper->mapViolation($violation, $form, $allowNonSynchronized); + } + } + } +} diff --git a/lib/symfony/form/Extension/Validator/Type/BaseValidatorExtension.php b/lib/symfony/form/Extension/Validator/Type/BaseValidatorExtension.php new file mode 100644 index 000000000..ea01d0369 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/Type/BaseValidatorExtension.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\GroupSequence; + +/** + * Encapsulates common logic of {@link FormTypeValidatorExtension} and + * {@link SubmitTypeValidatorExtension}. + * + * @author Bernhard Schussek + */ +abstract class BaseValidatorExtension extends AbstractTypeExtension +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + // Make sure that validation groups end up as null, closure or array + $validationGroupsNormalizer = static function (Options $options, $groups) { + if (false === $groups) { + return []; + } + + if (empty($groups)) { + return null; + } + + if (\is_callable($groups)) { + return $groups; + } + + if ($groups instanceof GroupSequence) { + return $groups; + } + + return (array) $groups; + }; + + $resolver->setDefaults([ + 'validation_groups' => null, + ]); + + $resolver->setNormalizer('validation_groups', $validationGroupsNormalizer); + } +} diff --git a/lib/symfony/form/Extension/Validator/Type/FormTypeValidatorExtension.php b/lib/symfony/form/Extension/Validator/Type/FormTypeValidatorExtension.php new file mode 100644 index 000000000..a1fd686d5 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/Type/FormTypeValidatorExtension.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\Type; + +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Validator\EventListener\ValidationListener; +use Symfony\Component\Form\Extension\Validator\ViolationMapper\ViolationMapper; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormRendererInterface; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @author Bernhard Schussek + */ +class FormTypeValidatorExtension extends BaseValidatorExtension +{ + private ValidatorInterface $validator; + private ViolationMapper $violationMapper; + private bool $legacyErrorMessages; + + public function __construct(ValidatorInterface $validator, bool $legacyErrorMessages = true, ?FormRendererInterface $formRenderer = null, ?TranslatorInterface $translator = null) + { + $this->validator = $validator; + $this->violationMapper = new ViolationMapper($formRenderer, $translator); + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->addEventSubscriber(new ValidationListener($this->validator, $this->violationMapper)); + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + // Constraint should always be converted to an array + $constraintsNormalizer = static fn (Options $options, $constraints) => \is_object($constraints) ? [$constraints] : (array) $constraints; + + $resolver->setDefaults([ + 'error_mapping' => [], + 'constraints' => [], + 'invalid_message' => 'This value is not valid.', + 'invalid_message_parameters' => [], + 'allow_extra_fields' => false, + 'extra_fields_message' => 'This form should not contain extra fields.', + ]); + $resolver->setAllowedTypes('constraints', [Constraint::class, Constraint::class.'[]']); + $resolver->setNormalizer('constraints', $constraintsNormalizer); + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/lib/symfony/form/Extension/Validator/Type/RepeatedTypeValidatorExtension.php b/lib/symfony/form/Extension/Validator/Type/RepeatedTypeValidatorExtension.php new file mode 100644 index 000000000..d41dc0168 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/Type/RepeatedTypeValidatorExtension.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\RepeatedType; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Bernhard Schussek + */ +class RepeatedTypeValidatorExtension extends AbstractTypeExtension +{ + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + // Map errors to the first field + $errorMapping = static fn (Options $options) => ['.' => $options['first_name']]; + + $resolver->setDefaults([ + 'error_mapping' => $errorMapping, + ]); + } + + public static function getExtendedTypes(): iterable + { + return [RepeatedType::class]; + } +} diff --git a/lib/symfony/form/Extension/Validator/Type/SubmitTypeValidatorExtension.php b/lib/symfony/form/Extension/Validator/Type/SubmitTypeValidatorExtension.php new file mode 100644 index 000000000..8efae7d52 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/Type/SubmitTypeValidatorExtension.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\Type; + +use Symfony\Component\Form\Extension\Core\Type\SubmitType; + +/** + * @author Bernhard Schussek + */ +class SubmitTypeValidatorExtension extends BaseValidatorExtension +{ + public static function getExtendedTypes(): iterable + { + return [SubmitType::class]; + } +} diff --git a/lib/symfony/form/Extension/Validator/Type/UploadValidatorExtension.php b/lib/symfony/form/Extension/Validator/Type/UploadValidatorExtension.php new file mode 100644 index 000000000..184bebbaf --- /dev/null +++ b/lib/symfony/form/Extension/Validator/Type/UploadValidatorExtension.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @author Abdellatif Ait boudad + * @author David Badura + */ +class UploadValidatorExtension extends AbstractTypeExtension +{ + private TranslatorInterface $translator; + private ?string $translationDomain; + + public function __construct(TranslatorInterface $translator, ?string $translationDomain = null) + { + $this->translator = $translator; + $this->translationDomain = $translationDomain; + } + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver) + { + $translator = $this->translator; + $translationDomain = $this->translationDomain; + $resolver->setNormalizer('upload_max_size_message', static fn (Options $options, $message) => static fn () => $translator->trans($message(), [], $translationDomain)); + } + + public static function getExtendedTypes(): iterable + { + return [FormType::class]; + } +} diff --git a/lib/symfony/form/Extension/Validator/ValidatorExtension.php b/lib/symfony/form/Extension/Validator/ValidatorExtension.php new file mode 100644 index 000000000..d7745be07 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/ValidatorExtension.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\Validator; + +use Symfony\Component\Form\AbstractExtension; +use Symfony\Component\Form\Extension\Validator\Constraints\Form; +use Symfony\Component\Form\FormRendererInterface; +use Symfony\Component\Form\FormTypeGuesserInterface; +use Symfony\Component\Validator\Constraints\Traverse; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Extension supporting the Symfony Validator component in forms. + * + * @author Bernhard Schussek + */ +class ValidatorExtension extends AbstractExtension +{ + private ValidatorInterface $validator; + private ?FormRendererInterface $formRenderer; + private ?TranslatorInterface $translator; + private bool $legacyErrorMessages; + + public function __construct(ValidatorInterface $validator, bool $legacyErrorMessages = true, ?FormRendererInterface $formRenderer = null, ?TranslatorInterface $translator = null) + { + $this->legacyErrorMessages = $legacyErrorMessages; + + $metadata = $validator->getMetadataFor(\Symfony\Component\Form\Form::class); + + // Register the form constraints in the validator programmatically. + // This functionality is required when using the Form component without + // the DIC, where the XML file is loaded automatically. Thus the following + // code must be kept synchronized with validation.xml + + /* @var $metadata ClassMetadata */ + $metadata->addConstraint(new Form()); + $metadata->addConstraint(new Traverse(false)); + + $this->validator = $validator; + $this->formRenderer = $formRenderer; + $this->translator = $translator; + } + + public function loadTypeGuesser(): ?FormTypeGuesserInterface + { + return new ValidatorTypeGuesser($this->validator); + } + + protected function loadTypeExtensions(): array + { + return [ + new Type\FormTypeValidatorExtension($this->validator, $this->legacyErrorMessages, $this->formRenderer, $this->translator), + new Type\RepeatedTypeValidatorExtension(), + new Type\SubmitTypeValidatorExtension(), + ]; + } +} diff --git a/lib/symfony/form/Extension/Validator/ValidatorTypeGuesser.php b/lib/symfony/form/Extension/Validator/ValidatorTypeGuesser.php new file mode 100644 index 000000000..57bccaa39 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/ValidatorTypeGuesser.php @@ -0,0 +1,293 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator; + +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\LanguageType; +use Symfony\Component\Form\Extension\Core\Type\LocaleType; +use Symfony\Component\Form\Extension\Core\Type\NumberType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\TimeType; +use Symfony\Component\Form\Extension\Core\Type\UrlType; +use Symfony\Component\Form\FormTypeGuesserInterface; +use Symfony\Component\Form\Guess\Guess; +use Symfony\Component\Form\Guess\TypeGuess; +use Symfony\Component\Form\Guess\ValueGuess; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Count; +use Symfony\Component\Validator\Constraints\Country; +use Symfony\Component\Validator\Constraints\Currency; +use Symfony\Component\Validator\Constraints\Date; +use Symfony\Component\Validator\Constraints\DateTime; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\File; +use Symfony\Component\Validator\Constraints\Image; +use Symfony\Component\Validator\Constraints\Ip; +use Symfony\Component\Validator\Constraints\IsFalse; +use Symfony\Component\Validator\Constraints\IsTrue; +use Symfony\Component\Validator\Constraints\Language; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\Locale; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Range; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Time; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Constraints\Url; +use Symfony\Component\Validator\Mapping\ClassMetadataInterface; +use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; + +class ValidatorTypeGuesser implements FormTypeGuesserInterface +{ + private MetadataFactoryInterface $metadataFactory; + + public function __construct(MetadataFactoryInterface $metadataFactory) + { + $this->metadataFactory = $metadataFactory; + } + + public function guessType(string $class, string $property): ?TypeGuess + { + return $this->guess($class, $property, $this->guessTypeForConstraint(...)); + } + + public function guessRequired(string $class, string $property): ?ValueGuess + { + // If we don't find any constraint telling otherwise, we can assume + // that a field is not required (with LOW_CONFIDENCE) + return $this->guess($class, $property, $this->guessRequiredForConstraint(...), false); + } + + public function guessMaxLength(string $class, string $property): ?ValueGuess + { + return $this->guess($class, $property, $this->guessMaxLengthForConstraint(...)); + } + + public function guessPattern(string $class, string $property): ?ValueGuess + { + return $this->guess($class, $property, $this->guessPatternForConstraint(...)); + } + + /** + * Guesses a field class name for a given constraint. + */ + public function guessTypeForConstraint(Constraint $constraint): ?TypeGuess + { + switch ($constraint::class) { + case Type::class: + switch ($constraint->type) { + case 'array': + return new TypeGuess(CollectionType::class, [], Guess::MEDIUM_CONFIDENCE); + case 'boolean': + case 'bool': + return new TypeGuess(CheckboxType::class, [], Guess::MEDIUM_CONFIDENCE); + + case 'double': + case 'float': + case 'numeric': + case 'real': + return new TypeGuess(NumberType::class, [], Guess::MEDIUM_CONFIDENCE); + + case 'integer': + case 'int': + case 'long': + return new TypeGuess(IntegerType::class, [], Guess::MEDIUM_CONFIDENCE); + + case \DateTime::class: + case '\DateTime': + return new TypeGuess(DateType::class, [], Guess::MEDIUM_CONFIDENCE); + + case \DateTimeImmutable::class: + case '\DateTimeImmutable': + case \DateTimeInterface::class: + case '\DateTimeInterface': + return new TypeGuess(DateType::class, ['input' => 'datetime_immutable'], Guess::MEDIUM_CONFIDENCE); + + case 'string': + return new TypeGuess(TextType::class, [], Guess::LOW_CONFIDENCE); + } + break; + + case Country::class: + return new TypeGuess(CountryType::class, [], Guess::HIGH_CONFIDENCE); + + case Currency::class: + return new TypeGuess(CurrencyType::class, [], Guess::HIGH_CONFIDENCE); + + case Date::class: + return new TypeGuess(DateType::class, ['input' => 'string'], Guess::HIGH_CONFIDENCE); + + case DateTime::class: + return new TypeGuess(DateTimeType::class, ['input' => 'string'], Guess::HIGH_CONFIDENCE); + + case Email::class: + return new TypeGuess(EmailType::class, [], Guess::HIGH_CONFIDENCE); + + case File::class: + case Image::class: + $options = []; + if ($constraint->mimeTypes) { + $options = ['attr' => ['accept' => implode(',', (array) $constraint->mimeTypes)]]; + } + + return new TypeGuess(FileType::class, $options, Guess::HIGH_CONFIDENCE); + + case Language::class: + return new TypeGuess(LanguageType::class, [], Guess::HIGH_CONFIDENCE); + + case Locale::class: + return new TypeGuess(LocaleType::class, [], Guess::HIGH_CONFIDENCE); + + case Time::class: + return new TypeGuess(TimeType::class, ['input' => 'string'], Guess::HIGH_CONFIDENCE); + + case Url::class: + return new TypeGuess(UrlType::class, [], Guess::HIGH_CONFIDENCE); + + case Ip::class: + return new TypeGuess(TextType::class, [], Guess::MEDIUM_CONFIDENCE); + + case Length::class: + case Regex::class: + return new TypeGuess(TextType::class, [], Guess::LOW_CONFIDENCE); + + case Range::class: + return new TypeGuess(NumberType::class, [], Guess::LOW_CONFIDENCE); + + case Count::class: + return new TypeGuess(CollectionType::class, [], Guess::LOW_CONFIDENCE); + + case IsTrue::class: + case IsFalse::class: + return new TypeGuess(CheckboxType::class, [], Guess::MEDIUM_CONFIDENCE); + } + + return null; + } + + /** + * Guesses whether a field is required based on the given constraint. + */ + public function guessRequiredForConstraint(Constraint $constraint): ?ValueGuess + { + return match ($constraint::class) { + NotNull::class, + NotBlank::class, + IsTrue::class => new ValueGuess(true, Guess::HIGH_CONFIDENCE), + default => null, + }; + } + + /** + * Guesses a field's maximum length based on the given constraint. + */ + public function guessMaxLengthForConstraint(Constraint $constraint): ?ValueGuess + { + switch ($constraint::class) { + case Length::class: + if (is_numeric($constraint->max)) { + return new ValueGuess($constraint->max, Guess::HIGH_CONFIDENCE); + } + break; + + case Type::class: + if (\in_array($constraint->type, ['double', 'float', 'numeric', 'real'])) { + return new ValueGuess(null, Guess::MEDIUM_CONFIDENCE); + } + break; + + case Range::class: + if (is_numeric($constraint->max)) { + return new ValueGuess(\strlen((string) $constraint->max), Guess::LOW_CONFIDENCE); + } + break; + } + + return null; + } + + /** + * Guesses a field's pattern based on the given constraint. + */ + public function guessPatternForConstraint(Constraint $constraint): ?ValueGuess + { + switch ($constraint::class) { + case Length::class: + if (is_numeric($constraint->min)) { + return new ValueGuess(sprintf('.{%s,}', (string) $constraint->min), Guess::LOW_CONFIDENCE); + } + break; + + case Regex::class: + $htmlPattern = $constraint->getHtmlPattern(); + + if (null !== $htmlPattern) { + return new ValueGuess($htmlPattern, Guess::HIGH_CONFIDENCE); + } + break; + + case Range::class: + if (is_numeric($constraint->min)) { + return new ValueGuess(sprintf('.{%s,}', \strlen((string) $constraint->min)), Guess::LOW_CONFIDENCE); + } + break; + + case Type::class: + if (\in_array($constraint->type, ['double', 'float', 'numeric', 'real'])) { + return new ValueGuess(null, Guess::MEDIUM_CONFIDENCE); + } + break; + } + + return null; + } + + /** + * Iterates over the constraints of a property, executes a constraints on + * them and returns the best guess. + * + * @param \Closure $closure The closure that returns a guess + * for a given constraint + * @param mixed $defaultValue The default value assumed if no other value + * can be guessed + */ + protected function guess(string $class, string $property, \Closure $closure, mixed $defaultValue = null): ?Guess + { + $guesses = []; + $classMetadata = $this->metadataFactory->getMetadataFor($class); + + if ($classMetadata instanceof ClassMetadataInterface && $classMetadata->hasPropertyMetadata($property)) { + foreach ($classMetadata->getPropertyMetadata($property) as $memberMetadata) { + foreach ($memberMetadata->getConstraints() as $constraint) { + if ($guess = $closure($constraint)) { + $guesses[] = $guess; + } + } + } + } + + if (null !== $defaultValue) { + $guesses[] = new ValueGuess($defaultValue, Guess::LOW_CONFIDENCE); + } + + return Guess::getBestGuess($guesses); + } +} diff --git a/lib/symfony/form/Extension/Validator/ViolationMapper/MappingRule.php b/lib/symfony/form/Extension/Validator/ViolationMapper/MappingRule.php new file mode 100644 index 000000000..6e33f2229 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/ViolationMapper/MappingRule.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\ViolationMapper; + +use Symfony\Component\Form\Exception\ErrorMappingException; +use Symfony\Component\Form\FormInterface; + +/** + * @author Bernhard Schussek + */ +class MappingRule +{ + private FormInterface $origin; + private string $propertyPath; + private string $targetPath; + + public function __construct(FormInterface $origin, string $propertyPath, string $targetPath) + { + $this->origin = $origin; + $this->propertyPath = $propertyPath; + $this->targetPath = $targetPath; + } + + public function getOrigin(): FormInterface + { + return $this->origin; + } + + /** + * Matches a property path against the rule path. + * + * If the rule matches, the form mapped by the rule is returned. + * Otherwise this method returns false. + */ + public function match(string $propertyPath): ?FormInterface + { + return $propertyPath === $this->propertyPath ? $this->getTarget() : null; + } + + /** + * Matches a property path against a prefix of the rule path. + */ + public function isPrefix(string $propertyPath): bool + { + $length = \strlen($propertyPath); + $prefix = substr($this->propertyPath, 0, $length); + $next = $this->propertyPath[$length] ?? null; + + return $prefix === $propertyPath && ('[' === $next || '.' === $next); + } + + /** + * @throws ErrorMappingException + */ + public function getTarget(): FormInterface + { + $childNames = explode('.', $this->targetPath); + $target = $this->origin; + + foreach ($childNames as $childName) { + if (!$target->has($childName)) { + throw new ErrorMappingException(sprintf('The child "%s" of "%s" mapped by the rule "%s" in "%s" does not exist.', $childName, $target->getName(), $this->targetPath, $this->origin->getName())); + } + $target = $target->get($childName); + } + + return $target; + } +} diff --git a/lib/symfony/form/Extension/Validator/ViolationMapper/RelativePath.php b/lib/symfony/form/Extension/Validator/ViolationMapper/RelativePath.php new file mode 100644 index 000000000..0384edb44 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/ViolationMapper/RelativePath.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\Extension\Validator\ViolationMapper; + +use Symfony\Component\Form\FormInterface; +use Symfony\Component\PropertyAccess\PropertyPath; + +/** + * @author Bernhard Schussek + */ +class RelativePath extends PropertyPath +{ + private FormInterface $root; + + public function __construct(FormInterface $root, string $propertyPath) + { + parent::__construct($propertyPath); + + $this->root = $root; + } + + public function getRoot(): FormInterface + { + return $this->root; + } +} diff --git a/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationMapper.php b/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationMapper.php new file mode 100644 index 000000000..fd53697b1 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationMapper.php @@ -0,0 +1,343 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\ViolationMapper; + +use Symfony\Component\Form\FileUploadError; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormRendererInterface; +use Symfony\Component\Form\Util\InheritDataAwareIterator; +use Symfony\Component\PropertyAccess\PropertyPathBuilder; +use Symfony\Component\PropertyAccess\PropertyPathIterator; +use Symfony\Component\PropertyAccess\PropertyPathIteratorInterface; +use Symfony\Component\Validator\Constraints\File; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * @author Bernhard Schussek + */ +class ViolationMapper implements ViolationMapperInterface +{ + private ?FormRendererInterface $formRenderer; + private ?TranslatorInterface $translator; + private bool $allowNonSynchronized = false; + + public function __construct(?FormRendererInterface $formRenderer = null, ?TranslatorInterface $translator = null) + { + $this->formRenderer = $formRenderer; + $this->translator = $translator; + } + + /** + * @return void + */ + public function mapViolation(ConstraintViolation $violation, FormInterface $form, bool $allowNonSynchronized = false) + { + $this->allowNonSynchronized = $allowNonSynchronized; + + // The scope is the currently found most specific form that + // an error should be mapped to. After setting the scope, the + // mapper will try to continue to find more specific matches in + // the children of scope. If it cannot, the error will be + // mapped to this scope. + $scope = null; + + $violationPath = null; + $relativePath = null; + $match = false; + + // Don't create a ViolationPath instance for empty property paths + if ('' !== $violation->getPropertyPath()) { + $violationPath = new ViolationPath($violation->getPropertyPath()); + $relativePath = $this->reconstructPath($violationPath, $form); + } + + // This case happens if the violation path is empty and thus + // the violation should be mapped to the root form + if (null === $violationPath) { + $scope = $form; + } + + // In general, mapping happens from the root form to the leaf forms + // First, the rules of the root form are applied to determine + // the subsequent descendant. The rules of this descendant are then + // applied to find the next and so on, until we have found the + // most specific form that matches the violation. + + // If any of the forms found in this process is not synchronized, + // mapping is aborted. Non-synchronized forms could not reverse + // transform the value entered by the user, thus any further violations + // caused by the (invalid) reverse transformed value should be + // ignored. + + if (null !== $relativePath) { + // Set the scope to the root of the relative path + // This root will usually be $form. If the path contains + // an unmapped form though, the last unmapped form found + // will be the root of the path. + $scope = $relativePath->getRoot(); + $it = new PropertyPathIterator($relativePath); + + while ($this->acceptsErrors($scope) && null !== ($child = $this->matchChild($scope, $it))) { + $scope = $child; + $it->next(); + $match = true; + } + } + + // This case happens if an error happened in the data under a + // form inheriting its parent data that does not match any of the + // children of that form. + if (null !== $violationPath && !$match) { + // If we could not map the error to anything more specific + // than the root element, map it to the innermost directly + // mapped form of the violation path + // e.g. "children[foo].children[bar].data.baz" + // Here the innermost directly mapped child is "bar" + + $scope = $form; + $it = new ViolationPathIterator($violationPath); + + // Note: acceptsErrors() will always return true for forms inheriting + // their parent data, because these forms can never be non-synchronized + // (they don't do any data transformation on their own) + while ($this->acceptsErrors($scope) && $it->valid() && $it->mapsForm()) { + if (!$scope->has($it->current())) { + // Break if we find a reference to a non-existing child + break; + } + + $scope = $scope->get($it->current()); + $it->next(); + } + } + + // Follow dot rules until we have the final target + $mapping = $scope->getConfig()->getOption('error_mapping'); + + while ($this->acceptsErrors($scope) && isset($mapping['.'])) { + $dotRule = new MappingRule($scope, '.', $mapping['.']); + $scope = $dotRule->getTarget(); + $mapping = $scope->getConfig()->getOption('error_mapping'); + } + + // Only add the error if the form is synchronized + if ($this->acceptsErrors($scope)) { + if ($violation->getConstraint() instanceof File && (string) \UPLOAD_ERR_INI_SIZE === $violation->getCode()) { + $errorsTarget = $scope; + + while (null !== $errorsTarget->getParent() && $errorsTarget->getConfig()->getErrorBubbling()) { + $errorsTarget = $errorsTarget->getParent(); + } + + $errors = $errorsTarget->getErrors(); + $errorsTarget->clearErrors(); + + foreach ($errors as $error) { + if (!$error instanceof FileUploadError) { + $errorsTarget->addError($error); + } + } + } + + $message = $violation->getMessage(); + $messageTemplate = $violation->getMessageTemplate(); + + if (str_contains($message, '{{ label }}') || str_contains($messageTemplate, '{{ label }}')) { + $form = $scope; + + do { + $labelFormat = $form->getConfig()->getOption('label_format'); + } while (null === $labelFormat && null !== $form = $form->getParent()); + + if (null !== $labelFormat) { + $label = str_replace( + [ + '%name%', + '%id%', + ], + [ + $scope->getName(), + (string) $scope->getPropertyPath(), + ], + $labelFormat + ); + } else { + $label = $scope->getConfig()->getOption('label'); + } + + if (false !== $label) { + if (null === $label && null !== $this->formRenderer) { + $label = $this->formRenderer->humanize($scope->getName()); + } else { + $label ??= $scope->getName(); + } + + if (null !== $this->translator) { + $form = $scope; + $translationParameters[] = $form->getConfig()->getOption('label_translation_parameters', []); + + do { + $translationDomain = $form->getConfig()->getOption('translation_domain'); + array_unshift( + $translationParameters, + $form->getConfig()->getOption('label_translation_parameters', []) + ); + } while (null === $translationDomain && null !== $form = $form->getParent()); + + $translationParameters = array_merge([], ...$translationParameters); + + $label = $this->translator->trans( + $label, + $translationParameters, + $translationDomain + ); + } + + $message = str_replace('{{ label }}', $label, $message); + $messageTemplate = str_replace('{{ label }}', $label, $messageTemplate); + } + } + + $scope->addError(new FormError( + $message, + $messageTemplate, + $violation->getParameters(), + $violation->getPlural(), + $violation + )); + } + } + + /** + * Tries to match the beginning of the property path at the + * current position against the children of the scope. + * + * If a matching child is found, it is returned. Otherwise + * null is returned. + */ + private function matchChild(FormInterface $form, PropertyPathIteratorInterface $it): ?FormInterface + { + $target = null; + $chunk = ''; + $foundAtIndex = null; + + // Construct mapping rules for the given form + $rules = []; + + foreach ($form->getConfig()->getOption('error_mapping') as $propertyPath => $targetPath) { + // Dot rules are considered at the very end + if ('.' !== $propertyPath) { + $rules[] = new MappingRule($form, $propertyPath, $targetPath); + } + } + + $children = iterator_to_array(new \RecursiveIteratorIterator(new InheritDataAwareIterator($form)), false); + + while ($it->valid()) { + if ($it->isIndex()) { + $chunk .= '['.$it->current().']'; + } else { + $chunk .= ('' === $chunk ? '' : '.').$it->current(); + } + + // Test mapping rules as long as we have any + foreach ($rules as $key => $rule) { + /* @var MappingRule $rule */ + + // Mapping rule matches completely, terminate. + if (null !== ($form = $rule->match($chunk))) { + return $form; + } + + // Keep only rules that have $chunk as prefix + if (!$rule->isPrefix($chunk)) { + unset($rules[$key]); + } + } + + /** @var FormInterface $child */ + foreach ($children as $i => $child) { + $childPath = (string) $child->getPropertyPath(); + if ($childPath === $chunk) { + $target = $child; + $foundAtIndex = $it->key(); + } elseif (str_starts_with($childPath, $chunk)) { + continue; + } + + unset($children[$i]); + } + + $it->next(); + } + + if (null !== $foundAtIndex) { + $it->seek($foundAtIndex); + } + + return $target; + } + + /** + * Reconstructs a property path from a violation path and a form tree. + */ + private function reconstructPath(ViolationPath $violationPath, FormInterface $origin): ?RelativePath + { + $propertyPathBuilder = new PropertyPathBuilder($violationPath); + $it = $violationPath->getIterator(); + $scope = $origin; + + // Remember the current index in the builder + $i = 0; + + // Expand elements that map to a form (like "children[address]") + for ($it->rewind(); $it->valid() && $it->mapsForm(); $it->next()) { + if (!$scope->has($it->current())) { + // Scope relates to a form that does not exist + // Bail out + break; + } + + // Process child form + $scope = $scope->get($it->current()); + + if ($scope->getConfig()->getInheritData()) { + // Form inherits its parent data + // Cut the piece out of the property path and proceed + $propertyPathBuilder->remove($i); + } else { + /* @var \Symfony\Component\PropertyAccess\PropertyPathInterface $propertyPath */ + $propertyPath = $scope->getPropertyPath(); + + if (null === $propertyPath) { + // Property path of a mapped form is null + // Should not happen, bail out + break; + } + + $propertyPathBuilder->replace($i, 1, $propertyPath); + $i += $propertyPath->getLength(); + } + } + + $finalPath = $propertyPathBuilder->getPropertyPath(); + + return null !== $finalPath ? new RelativePath($origin, $finalPath) : null; + } + + private function acceptsErrors(FormInterface $form): bool + { + return $this->allowNonSynchronized || $form->isSynchronized(); + } +} diff --git a/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationMapperInterface.php b/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationMapperInterface.php new file mode 100644 index 000000000..a72d41df9 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationMapperInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\ViolationMapper; + +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Validator\ConstraintViolation; + +/** + * @author Bernhard Schussek + */ +interface ViolationMapperInterface +{ + /** + * Maps a constraint violation to a form in the form tree under + * the given form. + * + * @param bool $allowNonSynchronized Whether to allow mapping to non-synchronized forms + * + * @return void + */ + public function mapViolation(ConstraintViolation $violation, FormInterface $form, bool $allowNonSynchronized = false); +} diff --git a/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationPath.php b/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationPath.php new file mode 100644 index 000000000..a9a0f15d6 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationPath.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\ViolationMapper; + +use Symfony\Component\Form\Exception\OutOfBoundsException; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * @author Bernhard Schussek + * + * @implements \IteratorAggregate + */ +class ViolationPath implements \IteratorAggregate, PropertyPathInterface +{ + /** @var list */ + private array $elements = []; + private array $isIndex = []; + private array $mapsForm = []; + private string $pathAsString = ''; + private int $length = 0; + + /** + * Creates a new violation path from a string. + * + * @param string $violationPath The property path of a {@link \Symfony\Component\Validator\ConstraintViolation} object + */ + public function __construct(string $violationPath) + { + $path = new PropertyPath($violationPath); + $elements = $path->getElements(); + $data = false; + + for ($i = 0, $l = \count($elements); $i < $l; ++$i) { + if (!$data) { + // The element "data" has not yet been passed + if ('children' === $elements[$i] && $path->isProperty($i)) { + // Skip element "children" + ++$i; + + // Next element must exist and must be an index + // Otherwise consider this the end of the path + if ($i >= $l || !$path->isIndex($i)) { + break; + } + + // All the following index items (regardless if .children is + // explicitly used) are children and grand-children + for (; $i < $l && $path->isIndex($i); ++$i) { + $this->elements[] = $elements[$i]; + $this->isIndex[] = true; + $this->mapsForm[] = true; + } + + // Rewind the pointer as the last element above didn't match + // (even if the pointer was moved forward) + --$i; + } elseif ('data' === $elements[$i] && $path->isProperty($i)) { + // Skip element "data" + ++$i; + + // End of path + if ($i >= $l) { + break; + } + + $this->elements[] = $elements[$i]; + $this->isIndex[] = $path->isIndex($i); + $this->mapsForm[] = false; + $data = true; + } else { + // Neither "children" nor "data" property found + // Consider this the end of the path + break; + } + } else { + // Already after the "data" element + // Pick everything as is + $this->elements[] = $elements[$i]; + $this->isIndex[] = $path->isIndex($i); + $this->mapsForm[] = false; + } + } + + $this->length = \count($this->elements); + + $this->buildString(); + } + + public function __toString(): string + { + return $this->pathAsString; + } + + public function getLength(): int + { + return $this->length; + } + + public function getParent(): ?PropertyPathInterface + { + if ($this->length <= 1) { + return null; + } + + $parent = clone $this; + + --$parent->length; + array_pop($parent->elements); + array_pop($parent->isIndex); + array_pop($parent->mapsForm); + + $parent->buildString(); + + return $parent; + } + + public function getElements(): array + { + return $this->elements; + } + + public function getElement(int $index): string + { + if (!isset($this->elements[$index])) { + throw new OutOfBoundsException(sprintf('The index "%s" is not within the violation path.', $index)); + } + + return $this->elements[$index]; + } + + public function isProperty(int $index): bool + { + if (!isset($this->isIndex[$index])) { + throw new OutOfBoundsException(sprintf('The index "%s" is not within the violation path.', $index)); + } + + return !$this->isIndex[$index]; + } + + public function isIndex(int $index): bool + { + if (!isset($this->isIndex[$index])) { + throw new OutOfBoundsException(sprintf('The index "%s" is not within the violation path.', $index)); + } + + return $this->isIndex[$index]; + } + + public function isNullSafe(int $index): bool + { + return false; + } + + /** + * Returns whether an element maps directly to a form. + * + * Consider the following violation path: + * + * children[address].children[office].data.street + * + * In this example, "address" and "office" map to forms, while + * "street does not. + * + * @throws OutOfBoundsException if the offset is invalid + */ + public function mapsForm(int $index): bool + { + if (!isset($this->mapsForm[$index])) { + throw new OutOfBoundsException(sprintf('The index "%s" is not within the violation path.', $index)); + } + + return $this->mapsForm[$index]; + } + + /** + * Returns a new iterator for this path. + */ + public function getIterator(): ViolationPathIterator + { + return new ViolationPathIterator($this); + } + + /** + * Builds the string representation from the elements. + */ + private function buildString(): void + { + $this->pathAsString = ''; + $data = false; + + foreach ($this->elements as $index => $element) { + if ($this->mapsForm[$index]) { + $this->pathAsString .= ".children[$element]"; + } elseif (!$data) { + $this->pathAsString .= '.data'.($this->isIndex[$index] ? "[$element]" : ".$element"); + $data = true; + } else { + $this->pathAsString .= $this->isIndex[$index] ? "[$element]" : ".$element"; + } + } + + if ('' !== $this->pathAsString) { + // remove leading dot + $this->pathAsString = substr($this->pathAsString, 1); + } + } +} diff --git a/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationPathIterator.php b/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationPathIterator.php new file mode 100644 index 000000000..ed363a7b1 --- /dev/null +++ b/lib/symfony/form/Extension/Validator/ViolationMapper/ViolationPathIterator.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\Validator\ViolationMapper; + +use Symfony\Component\PropertyAccess\PropertyPathIterator; + +/** + * @author Bernhard Schussek + */ +class ViolationPathIterator extends PropertyPathIterator +{ + public function __construct(ViolationPath $violationPath) + { + parent::__construct($violationPath); + } + + /** + * @return bool + */ + public function mapsForm() + { + return $this->path->mapsForm($this->key()); + } +} diff --git a/lib/symfony/form/FileUploadError.php b/lib/symfony/form/FileUploadError.php new file mode 100644 index 000000000..20142b203 --- /dev/null +++ b/lib/symfony/form/FileUploadError.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * @internal + */ +class FileUploadError extends FormError +{ +} diff --git a/lib/symfony/form/Form.php b/lib/symfony/form/Form.php new file mode 100644 index 000000000..070d0445a --- /dev/null +++ b/lib/symfony/form/Form.php @@ -0,0 +1,1022 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Event\PostSetDataEvent; +use Symfony\Component\Form\Event\PostSubmitEvent; +use Symfony\Component\Form\Event\PreSetDataEvent; +use Symfony\Component\Form\Event\PreSubmitEvent; +use Symfony\Component\Form\Event\SubmitEvent; +use Symfony\Component\Form\Exception\AlreadySubmittedException; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Exception\OutOfBoundsException; +use Symfony\Component\Form\Exception\RuntimeException; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Util\FormUtil; +use Symfony\Component\Form\Util\InheritDataAwareIterator; +use Symfony\Component\Form\Util\OrderedHashMap; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * Form represents a form. + * + * To implement your own form fields, you need to have a thorough understanding + * of the data flow within a form. A form stores its data in three different + * representations: + * + * (1) the "model" format required by the form's object + * (2) the "normalized" format for internal processing + * (3) the "view" format used for display simple fields + * or map children model data for compound fields + * + * A date field, for example, may store a date as "Y-m-d" string (1) in the + * object. To facilitate processing in the field, this value is normalized + * to a DateTime object (2). In the HTML representation of your form, a + * localized string (3) may be presented to and modified by the user, or it could be an array of values + * to be mapped to choices fields. + * + * In most cases, format (1) and format (2) will be the same. For example, + * a checkbox field uses a Boolean value for both internal processing and + * storage in the object. In these cases you need to set a view transformer + * to convert between formats (2) and (3). You can do this by calling + * addViewTransformer(). + * + * In some cases though it makes sense to make format (1) configurable. To + * demonstrate this, let's extend our above date field to store the value + * either as "Y-m-d" string or as timestamp. Internally we still want to + * use a DateTime object for processing. To convert the data from string/integer + * to DateTime you can set a model transformer by calling + * addModelTransformer(). The normalized data is then converted to the displayed + * data as described before. + * + * The conversions (1) -> (2) -> (3) use the transform methods of the transformers. + * The conversions (3) -> (2) -> (1) use the reverseTransform methods of the transformers. + * + * @author Fabien Potencier + * @author Bernhard Schussek + * + * @implements \IteratorAggregate + */ +class Form implements \IteratorAggregate, FormInterface, ClearableErrorsInterface +{ + private FormConfigInterface $config; + private ?FormInterface $parent = null; + + /** + * A map of FormInterface instances. + * + * @var OrderedHashMap + */ + private OrderedHashMap $children; + + /** + * @var FormError[] + */ + private array $errors = []; + + private bool $submitted = false; + + /** + * The button that was used to submit the form. + */ + private FormInterface|ClickableInterface|null $clickedButton = null; + + private mixed $modelData = null; + private mixed $normData = null; + private mixed $viewData = null; + + /** + * The submitted values that don't belong to any children. + */ + private array $extraData = []; + + /** + * The transformation failure generated during submission, if any. + */ + private ?TransformationFailedException $transformationFailure = null; + + /** + * Whether the form's data has been initialized. + * + * When the data is initialized with its default value, that default value + * is passed through the transformer chain in order to synchronize the + * model, normalized and view format for the first time. This is done + * lazily in order to save performance when {@link setData()} is called + * manually, making the initialization with the configured default value + * superfluous. + */ + private bool $defaultDataSet = false; + + /** + * Whether setData() is currently being called. + */ + private bool $lockSetData = false; + + private string $name = ''; + + /** + * Whether the form inherits its underlying data from its parent. + */ + private bool $inheritData; + + private ?PropertyPathInterface $propertyPath = null; + + /** + * @throws LogicException if a data mapper is not provided for a compound form + */ + public function __construct(FormConfigInterface $config) + { + // Compound forms always need a data mapper, otherwise calls to + // `setData` and `add` will not lead to the correct population of + // the child forms. + if ($config->getCompound() && !$config->getDataMapper()) { + throw new LogicException('Compound forms need a data mapper.'); + } + + // If the form inherits the data from its parent, it is not necessary + // to call setData() with the default data. + if ($this->inheritData = $config->getInheritData()) { + $this->defaultDataSet = true; + } + + $this->config = $config; + $this->children = new OrderedHashMap(); + $this->name = $config->getName(); + } + + public function __clone() + { + $this->children = clone $this->children; + + foreach ($this->children as $key => $child) { + $this->children[$key] = clone $child; + } + } + + public function getConfig(): FormConfigInterface + { + return $this->config; + } + + public function getName(): string + { + return $this->name; + } + + public function getPropertyPath(): ?PropertyPathInterface + { + if ($this->propertyPath || $this->propertyPath = $this->config->getPropertyPath()) { + return $this->propertyPath; + } + + if ('' === $this->name) { + return null; + } + + $parent = $this->parent; + + while ($parent?->getConfig()->getInheritData()) { + $parent = $parent->getParent(); + } + + if ($parent && null === $parent->getConfig()->getDataClass()) { + $this->propertyPath = new PropertyPath('['.$this->name.']'); + } else { + $this->propertyPath = new PropertyPath($this->name); + } + + return $this->propertyPath; + } + + public function isRequired(): bool + { + if (null === $this->parent || $this->parent->isRequired()) { + return $this->config->getRequired(); + } + + return false; + } + + public function isDisabled(): bool + { + if (null === $this->parent || !$this->parent->isDisabled()) { + return $this->config->getDisabled(); + } + + return true; + } + + public function setParent(?FormInterface $parent = null): static + { + if (1 > \func_num_args()) { + trigger_deprecation('symfony/form', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); + } + + if ($this->submitted) { + throw new AlreadySubmittedException('You cannot set the parent of a submitted form.'); + } + + if (null !== $parent && '' === $this->name) { + throw new LogicException('A form with an empty name cannot have a parent form.'); + } + + $this->parent = $parent; + + return $this; + } + + public function getParent(): ?FormInterface + { + return $this->parent; + } + + public function getRoot(): FormInterface + { + return $this->parent ? $this->parent->getRoot() : $this; + } + + public function isRoot(): bool + { + return null === $this->parent; + } + + public function setData(mixed $modelData): static + { + // If the form is submitted while disabled, it is set to submitted, but the data is not + // changed. In such cases (i.e. when the form is not initialized yet) don't + // abort this method. + if ($this->submitted && $this->defaultDataSet) { + throw new AlreadySubmittedException('You cannot change the data of a submitted form.'); + } + + // If the form inherits its parent's data, disallow data setting to + // prevent merge conflicts + if ($this->inheritData) { + throw new RuntimeException('You cannot change the data of a form inheriting its parent data.'); + } + + // Don't allow modifications of the configured data if the data is locked + if ($this->config->getDataLocked() && $modelData !== $this->config->getData()) { + return $this; + } + + if (\is_object($modelData) && !$this->config->getByReference()) { + $modelData = clone $modelData; + } + + if ($this->lockSetData) { + throw new RuntimeException('A cycle was detected. Listeners to the PRE_SET_DATA event must not call setData(). You should call setData() on the FormEvent object instead.'); + } + + $this->lockSetData = true; + $dispatcher = $this->config->getEventDispatcher(); + + // Hook to change content of the model data before transformation and mapping children + if ($dispatcher->hasListeners(FormEvents::PRE_SET_DATA)) { + $event = new PreSetDataEvent($this, $modelData); + $dispatcher->dispatch($event, FormEvents::PRE_SET_DATA); + $modelData = $event->getData(); + } + + // Treat data as strings unless a transformer exists + if (\is_scalar($modelData) && !$this->config->getViewTransformers() && !$this->config->getModelTransformers()) { + $modelData = (string) $modelData; + } + + // Synchronize representations - must not change the content! + // Transformation exceptions are not caught on initialization + $normData = $this->modelToNorm($modelData); + $viewData = $this->normToView($normData); + + // Validate if view data matches data class (unless empty) + if (!FormUtil::isEmpty($viewData)) { + $dataClass = $this->config->getDataClass(); + + if (null !== $dataClass && !$viewData instanceof $dataClass) { + $actualType = get_debug_type($viewData); + + throw new LogicException('The form\'s view data is expected to be a "'.$dataClass.'", but it is a "'.$actualType.'". You can avoid this error by setting the "data_class" option to null or by adding a view transformer that transforms "'.$actualType.'" to an instance of "'.$dataClass.'".'); + } + } + + $this->modelData = $modelData; + $this->normData = $normData; + $this->viewData = $viewData; + $this->defaultDataSet = true; + $this->lockSetData = false; + + // Compound forms don't need to invoke this method if they don't have children + if (\count($this->children) > 0) { + // Update child forms from the data (unless their config data is locked) + $this->config->getDataMapper()->mapDataToForms($viewData, new \RecursiveIteratorIterator(new InheritDataAwareIterator($this->children))); + } + + if ($dispatcher->hasListeners(FormEvents::POST_SET_DATA)) { + $event = new PostSetDataEvent($this, $modelData); + $dispatcher->dispatch($event, FormEvents::POST_SET_DATA); + } + + return $this; + } + + public function getData(): mixed + { + if ($this->inheritData) { + if (!$this->parent) { + throw new RuntimeException('The form is configured to inherit its parent\'s data, but does not have a parent.'); + } + + return $this->parent->getData(); + } + + if (!$this->defaultDataSet) { + if ($this->lockSetData) { + throw new RuntimeException('A cycle was detected. Listeners to the PRE_SET_DATA event must not call getData() if the form data has not already been set. You should call getData() on the FormEvent object instead.'); + } + + $this->setData($this->config->getData()); + } + + return $this->modelData; + } + + public function getNormData(): mixed + { + if ($this->inheritData) { + if (!$this->parent) { + throw new RuntimeException('The form is configured to inherit its parent\'s data, but does not have a parent.'); + } + + return $this->parent->getNormData(); + } + + if (!$this->defaultDataSet) { + if ($this->lockSetData) { + throw new RuntimeException('A cycle was detected. Listeners to the PRE_SET_DATA event must not call getNormData() if the form data has not already been set.'); + } + + $this->setData($this->config->getData()); + } + + return $this->normData; + } + + public function getViewData(): mixed + { + if ($this->inheritData) { + if (!$this->parent) { + throw new RuntimeException('The form is configured to inherit its parent\'s data, but does not have a parent.'); + } + + return $this->parent->getViewData(); + } + + if (!$this->defaultDataSet) { + if ($this->lockSetData) { + throw new RuntimeException('A cycle was detected. Listeners to the PRE_SET_DATA event must not call getViewData() if the form data has not already been set.'); + } + + $this->setData($this->config->getData()); + } + + return $this->viewData; + } + + public function getExtraData(): array + { + return $this->extraData; + } + + public function initialize(): static + { + if (null !== $this->parent) { + throw new RuntimeException('Only root forms should be initialized.'); + } + + // Guarantee that the *_SET_DATA events have been triggered once the + // form is initialized. This makes sure that dynamically added or + // removed fields are already visible after initialization. + if (!$this->defaultDataSet) { + $this->setData($this->config->getData()); + } + + return $this; + } + + public function handleRequest(mixed $request = null): static + { + $this->config->getRequestHandler()->handleRequest($this, $request); + + return $this; + } + + public function submit(mixed $submittedData, bool $clearMissing = true): static + { + if ($this->submitted) { + throw new AlreadySubmittedException('A form can only be submitted once.'); + } + + // Initialize errors in the very beginning so we're sure + // they are collectable during submission only + $this->errors = []; + + // Obviously, a disabled form should not change its data upon submission. + if ($this->isDisabled()) { + $this->submitted = true; + + return $this; + } + + // The data must be initialized if it was not initialized yet. + // This is necessary to guarantee that the *_SET_DATA listeners + // are always invoked before submit() takes place. + if (!$this->defaultDataSet) { + $this->setData($this->config->getData()); + } + + // Treat false as NULL to support binding false to checkboxes. + // Don't convert NULL to a string here in order to determine later + // whether an empty value has been submitted or whether no value has + // been submitted at all. This is important for processing checkboxes + // and radio buttons with empty values. + if (false === $submittedData) { + $submittedData = null; + } elseif (\is_scalar($submittedData)) { + $submittedData = (string) $submittedData; + } elseif ($this->config->getRequestHandler()->isFileUpload($submittedData)) { + if (!$this->config->getOption('allow_file_upload')) { + $submittedData = null; + $this->transformationFailure = new TransformationFailedException('Submitted data was expected to be text or number, file upload given.'); + } + } elseif (\is_array($submittedData) && !$this->config->getCompound() && !$this->config->getOption('multiple', false)) { + $submittedData = null; + $this->transformationFailure = new TransformationFailedException('Submitted data was expected to be text or number, array given.'); + } + + $dispatcher = $this->config->getEventDispatcher(); + + $modelData = null; + $normData = null; + $viewData = null; + + try { + if (null !== $this->transformationFailure) { + throw $this->transformationFailure; + } + + // Hook to change content of the data submitted by the browser + if ($dispatcher->hasListeners(FormEvents::PRE_SUBMIT)) { + $event = new PreSubmitEvent($this, $submittedData); + $dispatcher->dispatch($event, FormEvents::PRE_SUBMIT); + $submittedData = $event->getData(); + } + + // Check whether the form is compound. + // This check is preferable over checking the number of children, + // since forms without children may also be compound. + // (think of empty collection forms) + if ($this->config->getCompound()) { + if (!\is_array($submittedData ??= [])) { + throw new TransformationFailedException('Compound forms expect an array or NULL on submission.'); + } + + foreach ($this->children as $name => $child) { + $isSubmitted = \array_key_exists($name, $submittedData); + + if ($isSubmitted || $clearMissing) { + $child->submit($isSubmitted ? $submittedData[$name] : null, $clearMissing); + unset($submittedData[$name]); + + if (null !== $this->clickedButton) { + continue; + } + + if ($child instanceof ClickableInterface && $child->isClicked()) { + $this->clickedButton = $child; + + continue; + } + + if (method_exists($child, 'getClickedButton') && null !== $child->getClickedButton()) { + $this->clickedButton = $child->getClickedButton(); + } + } + } + + $this->extraData = $submittedData; + } + + // Forms that inherit their parents' data also are not processed, + // because then it would be too difficult to merge the changes in + // the child and the parent form. Instead, the parent form also takes + // changes in the grandchildren (i.e. children of the form that inherits + // its parent's data) into account. + // (see InheritDataAwareIterator below) + if (!$this->inheritData) { + // If the form is compound, the view data is merged with the data + // of the children using the data mapper. + // If the form is not compound, the view data is assigned to the submitted data. + $viewData = $this->config->getCompound() ? $this->viewData : $submittedData; + + if (FormUtil::isEmpty($viewData)) { + $emptyData = $this->config->getEmptyData(); + + if ($emptyData instanceof \Closure) { + $emptyData = $emptyData($this, $viewData); + } + + $viewData = $emptyData; + } + + // Merge form data from children into existing view data + // It is not necessary to invoke this method if the form has no children, + // even if it is compound. + if (\count($this->children) > 0) { + // Use InheritDataAwareIterator to process children of + // descendants that inherit this form's data. + // These descendants will not be submitted normally (see the check + // for $this->config->getInheritData() above) + $this->config->getDataMapper()->mapFormsToData( + new \RecursiveIteratorIterator(new InheritDataAwareIterator($this->children)), + $viewData + ); + } + + // Normalize data to unified representation + $normData = $this->viewToNorm($viewData); + + // Hook to change content of the data in the normalized + // representation + if ($dispatcher->hasListeners(FormEvents::SUBMIT)) { + $event = new SubmitEvent($this, $normData); + $dispatcher->dispatch($event, FormEvents::SUBMIT); + $normData = $event->getData(); + } + + // Synchronize representations - must not change the content! + $modelData = $this->normToModel($normData); + $viewData = $this->normToView($normData); + } + } catch (TransformationFailedException $e) { + $this->transformationFailure = $e; + + // If $viewData was not yet set, set it to $submittedData so that + // the erroneous data is accessible on the form. + // Forms that inherit data never set any data, because the getters + // forward to the parent form's getters anyway. + if (null === $viewData && !$this->inheritData) { + $viewData = $submittedData; + } + } + + $this->submitted = true; + $this->modelData = $modelData; + $this->normData = $normData; + $this->viewData = $viewData; + + if ($dispatcher->hasListeners(FormEvents::POST_SUBMIT)) { + $event = new PostSubmitEvent($this, $viewData); + $dispatcher->dispatch($event, FormEvents::POST_SUBMIT); + } + + return $this; + } + + public function addError(FormError $error): static + { + if (null === $error->getOrigin()) { + $error->setOrigin($this); + } + + if ($this->parent && $this->config->getErrorBubbling()) { + $this->parent->addError($error); + } else { + $this->errors[] = $error; + } + + return $this; + } + + public function isSubmitted(): bool + { + return $this->submitted; + } + + public function isSynchronized(): bool + { + return null === $this->transformationFailure; + } + + public function getTransformationFailure(): ?Exception\TransformationFailedException + { + return $this->transformationFailure; + } + + public function isEmpty(): bool + { + foreach ($this->children as $child) { + if (!$child->isEmpty()) { + return false; + } + } + + if (null !== $isEmptyCallback = $this->config->getIsEmptyCallback()) { + return $isEmptyCallback($this->modelData); + } + + return FormUtil::isEmpty($this->modelData) + // arrays, countables + || (is_countable($this->modelData) && 0 === \count($this->modelData)) + // traversables that are not countable + || ($this->modelData instanceof \Traversable && 0 === iterator_count($this->modelData)); + } + + public function isValid(): bool + { + if (!$this->submitted) { + throw new LogicException('Cannot check if an unsubmitted form is valid. Call Form::isSubmitted() and ensure that it\'s true before calling Form::isValid().'); + } + + if ($this->isDisabled()) { + return true; + } + + return 0 === \count($this->getErrors(true)); + } + + /** + * Returns the button that was used to submit the form. + */ + public function getClickedButton(): FormInterface|ClickableInterface|null + { + if ($this->clickedButton) { + return $this->clickedButton; + } + + return $this->parent && method_exists($this->parent, 'getClickedButton') ? $this->parent->getClickedButton() : null; + } + + public function getErrors(bool $deep = false, bool $flatten = true): FormErrorIterator + { + $errors = $this->errors; + + // Copy the errors of nested forms to the $errors array + if ($deep) { + foreach ($this as $child) { + /** @var FormInterface $child */ + if ($child->isSubmitted() && $child->isValid()) { + continue; + } + + $iterator = $child->getErrors(true, $flatten); + + if (0 === \count($iterator)) { + continue; + } + + if ($flatten) { + foreach ($iterator as $error) { + $errors[] = $error; + } + } else { + $errors[] = $iterator; + } + } + } + + return new FormErrorIterator($this, $errors); + } + + public function clearErrors(bool $deep = false): static + { + $this->errors = []; + + if ($deep) { + // Clear errors from children + foreach ($this as $child) { + if ($child instanceof ClearableErrorsInterface) { + $child->clearErrors(true); + } + } + } + + return $this; + } + + public function all(): array + { + return iterator_to_array($this->children); + } + + public function add(FormInterface|string $child, ?string $type = null, array $options = []): static + { + if ($this->submitted) { + throw new AlreadySubmittedException('You cannot add children to a submitted form.'); + } + + if (!$this->config->getCompound()) { + throw new LogicException('You cannot add children to a simple form. Maybe you should set the option "compound" to true?'); + } + + if (!$child instanceof FormInterface) { + if (!\is_string($child) && !\is_int($child)) { + throw new UnexpectedTypeException($child, 'string or Symfony\Component\Form\FormInterface'); + } + + $child = (string) $child; + + // Never initialize child forms automatically + $options['auto_initialize'] = false; + + if (null === $type && null === $this->config->getDataClass()) { + $type = TextType::class; + } + + if (null === $type) { + $child = $this->config->getFormFactory()->createForProperty($this->config->getDataClass(), $child, null, $options); + } else { + $child = $this->config->getFormFactory()->createNamed($child, $type, null, $options); + } + } elseif ($child->getConfig()->getAutoInitialize()) { + throw new RuntimeException(sprintf('Automatic initialization is only supported on root forms. You should set the "auto_initialize" option to false on the field "%s".', $child->getName())); + } + + $this->children[$child->getName()] = $child; + + $child->setParent($this); + + // If setData() is currently being called, there is no need to call + // mapDataToForms() here, as mapDataToForms() is called at the end + // of setData() anyway. Not doing this check leads to an endless + // recursion when initializing the form lazily and an event listener + // (such as ResizeFormListener) adds fields depending on the data: + // + // * setData() is called, the form is not initialized yet + // * add() is called by the listener (setData() is not complete, so + // the form is still not initialized) + // * getViewData() is called + // * setData() is called since the form is not initialized yet + // * ... endless recursion ... + // + // Also skip data mapping if setData() has not been called yet. + // setData() will be called upon form initialization and data mapping + // will take place by then. + if (!$this->lockSetData && $this->defaultDataSet && !$this->inheritData) { + $viewData = $this->getViewData(); + $this->config->getDataMapper()->mapDataToForms( + $viewData, + new \RecursiveIteratorIterator(new InheritDataAwareIterator(new \ArrayIterator([$child->getName() => $child]))) + ); + } + + return $this; + } + + public function remove(string $name): static + { + if ($this->submitted) { + throw new AlreadySubmittedException('You cannot remove children from a submitted form.'); + } + + if (isset($this->children[$name])) { + if (!$this->children[$name]->isSubmitted()) { + $this->children[$name]->setParent(null); + } + + unset($this->children[$name]); + } + + return $this; + } + + public function has(string $name): bool + { + return isset($this->children[$name]); + } + + public function get(string $name): FormInterface + { + if (isset($this->children[$name])) { + return $this->children[$name]; + } + + throw new OutOfBoundsException(sprintf('Child "%s" does not exist.', $name)); + } + + /** + * Returns whether a child with the given name exists (implements the \ArrayAccess interface). + * + * @param string $name The name of the child + */ + public function offsetExists(mixed $name): bool + { + return $this->has($name); + } + + /** + * Returns the child with the given name (implements the \ArrayAccess interface). + * + * @param string $name The name of the child + * + * @throws OutOfBoundsException if the named child does not exist + */ + public function offsetGet(mixed $name): FormInterface + { + return $this->get($name); + } + + /** + * Adds a child to the form (implements the \ArrayAccess interface). + * + * @param string $name Ignored. The name of the child is used + * @param FormInterface $child The child to be added + * + * @throws AlreadySubmittedException if the form has already been submitted + * @throws LogicException when trying to add a child to a non-compound form + * + * @see self::add() + */ + public function offsetSet(mixed $name, mixed $child): void + { + $this->add($child); + } + + /** + * Removes the child with the given name from the form (implements the \ArrayAccess interface). + * + * @param string $name The name of the child to remove + * + * @throws AlreadySubmittedException if the form has already been submitted + */ + public function offsetUnset(mixed $name): void + { + $this->remove($name); + } + + /** + * Returns the iterator for this group. + * + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return $this->children; + } + + /** + * Returns the number of form children (implements the \Countable interface). + */ + public function count(): int + { + return \count($this->children); + } + + public function createView(?FormView $parent = null): FormView + { + if (null === $parent && $this->parent) { + $parent = $this->parent->createView(); + } + + $type = $this->config->getType(); + $options = $this->config->getOptions(); + + // The methods createView(), buildView() and finishView() are called + // explicitly here in order to be able to override either of them + // in a custom resolved form type. + $view = $type->createView($this, $parent); + + $type->buildView($view, $this, $options); + + foreach ($this->children as $name => $child) { + $view->children[$name] = $child->createView($view); + } + + $this->sort($view->children); + + $type->finishView($view, $this, $options); + + return $view; + } + + /** + * Sorts view fields based on their priority value. + */ + private function sort(array &$children): void + { + $c = []; + $i = 0; + $needsSorting = false; + foreach ($children as $name => $child) { + $c[$name] = ['p' => $child->vars['priority'] ?? 0, 'i' => $i++]; + + if (0 !== $c[$name]['p']) { + $needsSorting = true; + } + } + + if (!$needsSorting) { + return; + } + + uksort($children, static fn ($a, $b): int => [$c[$b]['p'], $c[$a]['i']] <=> [$c[$a]['p'], $c[$b]['i']]); + } + + /** + * Normalizes the underlying data if a model transformer is set. + * + * @throws TransformationFailedException If the underlying data cannot be transformed to "normalized" format + */ + private function modelToNorm(mixed $value): mixed + { + try { + foreach ($this->config->getModelTransformers() as $transformer) { + $value = $transformer->transform($value); + } + } catch (TransformationFailedException $exception) { + throw new TransformationFailedException(sprintf('Unable to transform data for property path "%s": ', $this->getPropertyPath()).$exception->getMessage(), $exception->getCode(), $exception, $exception->getInvalidMessage(), $exception->getInvalidMessageParameters()); + } + + return $value; + } + + /** + * Reverse transforms a value if a model transformer is set. + * + * @throws TransformationFailedException If the value cannot be transformed to "model" format + */ + private function normToModel(mixed $value): mixed + { + try { + $transformers = $this->config->getModelTransformers(); + + for ($i = \count($transformers) - 1; $i >= 0; --$i) { + $value = $transformers[$i]->reverseTransform($value); + } + } catch (TransformationFailedException $exception) { + throw new TransformationFailedException(sprintf('Unable to reverse value for property path "%s": ', $this->getPropertyPath()).$exception->getMessage(), $exception->getCode(), $exception, $exception->getInvalidMessage(), $exception->getInvalidMessageParameters()); + } + + return $value; + } + + /** + * Transforms the value if a view transformer is set. + * + * @throws TransformationFailedException If the normalized value cannot be transformed to "view" format + */ + private function normToView(mixed $value): mixed + { + // Scalar values should be converted to strings to + // facilitate differentiation between empty ("") and zero (0). + // Only do this for simple forms, as the resulting value in + // compound forms is passed to the data mapper and thus should + // not be converted to a string before. + if (!($transformers = $this->config->getViewTransformers()) && !$this->config->getCompound()) { + return null === $value || \is_scalar($value) ? (string) $value : $value; + } + + try { + foreach ($transformers as $transformer) { + $value = $transformer->transform($value); + } + } catch (TransformationFailedException $exception) { + throw new TransformationFailedException(sprintf('Unable to transform value for property path "%s": ', $this->getPropertyPath()).$exception->getMessage(), $exception->getCode(), $exception, $exception->getInvalidMessage(), $exception->getInvalidMessageParameters()); + } + + return $value; + } + + /** + * Reverse transforms a value if a view transformer is set. + * + * @throws TransformationFailedException If the submitted value cannot be transformed to "normalized" format + */ + private function viewToNorm(mixed $value): mixed + { + if (!$transformers = $this->config->getViewTransformers()) { + return '' === $value ? null : $value; + } + + try { + for ($i = \count($transformers) - 1; $i >= 0; --$i) { + $value = $transformers[$i]->reverseTransform($value); + } + } catch (TransformationFailedException $exception) { + throw new TransformationFailedException(sprintf('Unable to reverse value for property path "%s": ', $this->getPropertyPath()).$exception->getMessage(), $exception->getCode(), $exception, $exception->getInvalidMessage(), $exception->getInvalidMessageParameters()); + } + + return $value; + } +} diff --git a/lib/symfony/form/FormBuilder.php b/lib/symfony/form/FormBuilder.php new file mode 100644 index 000000000..54a2104c4 --- /dev/null +++ b/lib/symfony/form/FormBuilder.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\Extension\Core\Type\TextType; + +/** + * A builder for creating {@link Form} instances. + * + * @author Bernhard Schussek + * + * @implements \IteratorAggregate + */ +class FormBuilder extends FormConfigBuilder implements \IteratorAggregate, FormBuilderInterface +{ + /** + * The children of the form builder. + * + * @var FormBuilderInterface[] + */ + private array $children = []; + + /** + * The data of children who haven't been converted to form builders yet. + */ + private array $unresolvedChildren = []; + + public function __construct(?string $name, ?string $dataClass, EventDispatcherInterface $dispatcher, FormFactoryInterface $factory, array $options = []) + { + parent::__construct($name, $dataClass, $dispatcher, $options); + + $this->setFormFactory($factory); + } + + public function add(FormBuilderInterface|string $child, ?string $type = null, array $options = []): static + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + if ($child instanceof FormBuilderInterface) { + $this->children[$child->getName()] = $child; + + // In case an unresolved child with the same name exists + unset($this->unresolvedChildren[$child->getName()]); + + return $this; + } + + if (!\is_string($child) && !\is_int($child)) { + throw new UnexpectedTypeException($child, 'string or Symfony\Component\Form\FormBuilderInterface'); + } + + // Add to "children" to maintain order + $this->children[$child] = null; + $this->unresolvedChildren[$child] = [$type, $options]; + + return $this; + } + + public function create(string $name, ?string $type = null, array $options = []): FormBuilderInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + if (null === $type && null === $this->getDataClass()) { + $type = TextType::class; + } + + if (null !== $type) { + return $this->getFormFactory()->createNamedBuilder($name, $type, null, $options); + } + + return $this->getFormFactory()->createBuilderForProperty($this->getDataClass(), $name, null, $options); + } + + public function get(string $name): FormBuilderInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + if (isset($this->unresolvedChildren[$name])) { + return $this->resolveChild($name); + } + + if (isset($this->children[$name])) { + return $this->children[$name]; + } + + throw new InvalidArgumentException(sprintf('The child with the name "%s" does not exist.', $name)); + } + + public function remove(string $name): static + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + unset($this->unresolvedChildren[$name], $this->children[$name]); + + return $this; + } + + public function has(string $name): bool + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + return isset($this->unresolvedChildren[$name]) || isset($this->children[$name]); + } + + public function all(): array + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->resolveChildren(); + + return $this->children; + } + + public function count(): int + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + return \count($this->children); + } + + public function getFormConfig(): FormConfigInterface + { + /** @var $config self */ + $config = parent::getFormConfig(); + + $config->children = []; + $config->unresolvedChildren = []; + + return $config; + } + + public function getForm(): FormInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->resolveChildren(); + + $form = new Form($this->getFormConfig()); + + foreach ($this->children as $child) { + // Automatic initialization is only supported on root forms + $form->add($child->setAutoInitialize(false)->getForm()); + } + + if ($this->getAutoInitialize()) { + // Automatically initialize the form if it is configured so + $form->initialize(); + } + + return $form; + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + if ($this->locked) { + throw new BadMethodCallException('FormBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + return new \ArrayIterator($this->all()); + } + + /** + * Converts an unresolved child into a {@link FormBuilderInterface} instance. + */ + private function resolveChild(string $name): FormBuilderInterface + { + [$type, $options] = $this->unresolvedChildren[$name]; + + unset($this->unresolvedChildren[$name]); + + return $this->children[$name] = $this->create($name, $type, $options); + } + + /** + * Converts all unresolved children into {@link FormBuilder} instances. + */ + private function resolveChildren(): void + { + foreach ($this->unresolvedChildren as $name => $info) { + $this->children[$name] = $this->create($name, $info[0], $info[1]); + } + + $this->unresolvedChildren = []; + } +} diff --git a/lib/symfony/form/FormBuilderInterface.php b/lib/symfony/form/FormBuilderInterface.php new file mode 100644 index 000000000..08d29303c --- /dev/null +++ b/lib/symfony/form/FormBuilderInterface.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * @author Bernhard Schussek + * + * @extends \Traversable + */ +interface FormBuilderInterface extends \Traversable, \Countable, FormConfigBuilderInterface +{ + /** + * Adds a new field to this group. A field must have a unique name within + * the group. Otherwise the existing field is overwritten. + * + * If you add a nested group, this group should also be represented in the + * object hierarchy. + * + * @param array $options + */ + public function add(string|self $child, ?string $type = null, array $options = []): static; + + /** + * Creates a form builder. + * + * @param string $name The name of the form or the name of the property + * @param string|null $type The type of the form or null if name is a property + * @param array $options + */ + public function create(string $name, ?string $type = null, array $options = []): self; + + /** + * Returns a child by name. + * + * @throws Exception\InvalidArgumentException if the given child does not exist + */ + public function get(string $name): self; + + /** + * Removes the field with the given name. + */ + public function remove(string $name): static; + + /** + * Returns whether a field with the given name exists. + */ + public function has(string $name): bool; + + /** + * Returns the children. + * + * @return array + */ + public function all(): array; + + /** + * Creates the form. + */ + public function getForm(): FormInterface; +} diff --git a/lib/symfony/form/FormConfigBuilder.php b/lib/symfony/form/FormConfigBuilder.php new file mode 100644 index 000000000..eb40aff2c --- /dev/null +++ b/lib/symfony/form/FormConfigBuilder.php @@ -0,0 +1,657 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\EventDispatcher\ImmutableEventDispatcher; +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * A basic form configuration. + * + * @author Bernhard Schussek + */ +class FormConfigBuilder implements FormConfigBuilderInterface +{ + /** + * Caches a globally unique {@link NativeRequestHandler} instance. + */ + private static NativeRequestHandler $nativeRequestHandler; + + /** @var bool */ + protected $locked = false; + + private EventDispatcherInterface $dispatcher; + private string $name; + private ?PropertyPathInterface $propertyPath = null; + private bool $mapped = true; + private bool $byReference = true; + private bool $inheritData = false; + private bool $compound = false; + private ResolvedFormTypeInterface $type; + private array $viewTransformers = []; + private array $modelTransformers = []; + private ?DataMapperInterface $dataMapper = null; + private bool $required = true; + private bool $disabled = false; + private bool $errorBubbling = false; + private mixed $emptyData = null; + private array $attributes = []; + private mixed $data = null; + private ?string $dataClass; + private bool $dataLocked = false; + private FormFactoryInterface $formFactory; + private string $action = ''; + private string $method = 'POST'; + private RequestHandlerInterface $requestHandler; + private bool $autoInitialize = false; + private array $options; + private ?\Closure $isEmptyCallback = null; + + /** + * Creates an empty form configuration. + * + * @param string|null $name The form name + * @param string|null $dataClass The class of the form's data + * + * @throws InvalidArgumentException if the data class is not a valid class or if + * the name contains invalid characters + */ + public function __construct(?string $name, ?string $dataClass, EventDispatcherInterface $dispatcher, array $options = []) + { + self::validateName($name); + + if (null !== $dataClass && !class_exists($dataClass) && !interface_exists($dataClass, false)) { + throw new InvalidArgumentException(sprintf('Class "%s" not found. Is the "data_class" form option set correctly?', $dataClass)); + } + + $this->name = (string) $name; + $this->dataClass = $dataClass; + $this->dispatcher = $dispatcher; + $this->options = $options; + } + + public function addEventListener(string $eventName, callable $listener, int $priority = 0): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->dispatcher->addListener($eventName, $listener, $priority); + + return $this; + } + + public function addEventSubscriber(EventSubscriberInterface $subscriber): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->dispatcher->addSubscriber($subscriber); + + return $this; + } + + public function addViewTransformer(DataTransformerInterface $viewTransformer, bool $forcePrepend = false): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + if ($forcePrepend) { + array_unshift($this->viewTransformers, $viewTransformer); + } else { + $this->viewTransformers[] = $viewTransformer; + } + + return $this; + } + + public function resetViewTransformers(): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->viewTransformers = []; + + return $this; + } + + public function addModelTransformer(DataTransformerInterface $modelTransformer, bool $forceAppend = false): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + if ($forceAppend) { + $this->modelTransformers[] = $modelTransformer; + } else { + array_unshift($this->modelTransformers, $modelTransformer); + } + + return $this; + } + + public function resetModelTransformers(): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->modelTransformers = []; + + return $this; + } + + public function getEventDispatcher(): EventDispatcherInterface + { + if ($this->locked && !$this->dispatcher instanceof ImmutableEventDispatcher) { + $this->dispatcher = new ImmutableEventDispatcher($this->dispatcher); + } + + return $this->dispatcher; + } + + public function getName(): string + { + return $this->name; + } + + public function getPropertyPath(): ?PropertyPathInterface + { + return $this->propertyPath; + } + + public function getMapped(): bool + { + return $this->mapped; + } + + public function getByReference(): bool + { + return $this->byReference; + } + + public function getInheritData(): bool + { + return $this->inheritData; + } + + public function getCompound(): bool + { + return $this->compound; + } + + public function getType(): ResolvedFormTypeInterface + { + return $this->type; + } + + public function getViewTransformers(): array + { + return $this->viewTransformers; + } + + public function getModelTransformers(): array + { + return $this->modelTransformers; + } + + public function getDataMapper(): ?DataMapperInterface + { + return $this->dataMapper; + } + + public function getRequired(): bool + { + return $this->required; + } + + public function getDisabled(): bool + { + return $this->disabled; + } + + public function getErrorBubbling(): bool + { + return $this->errorBubbling; + } + + public function getEmptyData(): mixed + { + return $this->emptyData; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function hasAttribute(string $name): bool + { + return \array_key_exists($name, $this->attributes); + } + + public function getAttribute(string $name, mixed $default = null): mixed + { + return \array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default; + } + + public function getData(): mixed + { + return $this->data; + } + + public function getDataClass(): ?string + { + return $this->dataClass; + } + + public function getDataLocked(): bool + { + return $this->dataLocked; + } + + public function getFormFactory(): FormFactoryInterface + { + if (!isset($this->formFactory)) { + throw new BadMethodCallException('The form factory must be set before retrieving it.'); + } + + return $this->formFactory; + } + + public function getAction(): string + { + return $this->action; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getRequestHandler(): RequestHandlerInterface + { + return $this->requestHandler ??= self::$nativeRequestHandler ??= new NativeRequestHandler(); + } + + public function getAutoInitialize(): bool + { + return $this->autoInitialize; + } + + public function getOptions(): array + { + return $this->options; + } + + public function hasOption(string $name): bool + { + return \array_key_exists($name, $this->options); + } + + public function getOption(string $name, mixed $default = null): mixed + { + return \array_key_exists($name, $this->options) ? $this->options[$name] : $default; + } + + public function getIsEmptyCallback(): ?callable + { + return $this->isEmptyCallback; + } + + /** + * @return $this + */ + public function setAttribute(string $name, mixed $value): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->attributes[$name] = $value; + + return $this; + } + + /** + * @return $this + */ + public function setAttributes(array $attributes): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->attributes = $attributes; + + return $this; + } + + /** + * @return $this + */ + public function setDataMapper(?DataMapperInterface $dataMapper = null): static + { + if (1 > \func_num_args()) { + trigger_deprecation('symfony/form', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); + } + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->dataMapper = $dataMapper; + + return $this; + } + + /** + * @return $this + */ + public function setDisabled(bool $disabled): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->disabled = $disabled; + + return $this; + } + + /** + * @return $this + */ + public function setEmptyData(mixed $emptyData): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->emptyData = $emptyData; + + return $this; + } + + /** + * @return $this + */ + public function setErrorBubbling(bool $errorBubbling): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->errorBubbling = $errorBubbling; + + return $this; + } + + /** + * @return $this + */ + public function setRequired(bool $required): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->required = $required; + + return $this; + } + + /** + * @return $this + */ + public function setPropertyPath(string|PropertyPathInterface|null $propertyPath): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + if (null !== $propertyPath && !$propertyPath instanceof PropertyPathInterface) { + $propertyPath = new PropertyPath($propertyPath); + } + + $this->propertyPath = $propertyPath; + + return $this; + } + + /** + * @return $this + */ + public function setMapped(bool $mapped): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->mapped = $mapped; + + return $this; + } + + /** + * @return $this + */ + public function setByReference(bool $byReference): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->byReference = $byReference; + + return $this; + } + + /** + * @return $this + */ + public function setInheritData(bool $inheritData): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->inheritData = $inheritData; + + return $this; + } + + /** + * @return $this + */ + public function setCompound(bool $compound): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->compound = $compound; + + return $this; + } + + /** + * @return $this + */ + public function setType(ResolvedFormTypeInterface $type): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->type = $type; + + return $this; + } + + /** + * @return $this + */ + public function setData(mixed $data): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->data = $data; + + return $this; + } + + /** + * @return $this + */ + public function setDataLocked(bool $locked): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->dataLocked = $locked; + + return $this; + } + + /** + * @return $this + */ + public function setFormFactory(FormFactoryInterface $formFactory) + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->formFactory = $formFactory; + + return $this; + } + + /** + * @return $this + */ + public function setAction(string $action): static + { + if ($this->locked) { + throw new BadMethodCallException('The config builder cannot be modified anymore.'); + } + + $this->action = $action; + + return $this; + } + + /** + * @return $this + */ + public function setMethod(string $method): static + { + if ($this->locked) { + throw new BadMethodCallException('The config builder cannot be modified anymore.'); + } + + $this->method = strtoupper($method); + + return $this; + } + + /** + * @return $this + */ + public function setRequestHandler(RequestHandlerInterface $requestHandler): static + { + if ($this->locked) { + throw new BadMethodCallException('The config builder cannot be modified anymore.'); + } + + $this->requestHandler = $requestHandler; + + return $this; + } + + /** + * @return $this + */ + public function setAutoInitialize(bool $initialize): static + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + $this->autoInitialize = $initialize; + + return $this; + } + + public function getFormConfig(): FormConfigInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormConfigBuilder methods cannot be accessed anymore once the builder is turned into a FormConfigInterface instance.'); + } + + // This method should be idempotent, so clone the builder + $config = clone $this; + $config->locked = true; + + return $config; + } + + /** + * @return $this + */ + public function setIsEmptyCallback(?callable $isEmptyCallback): static + { + $this->isEmptyCallback = null === $isEmptyCallback ? null : $isEmptyCallback(...); + + return $this; + } + + /** + * Validates whether the given variable is a valid form name. + * + * @throws InvalidArgumentException if the name contains invalid characters + * + * @internal + */ + final public static function validateName(?string $name): void + { + if (!self::isValidName($name)) { + throw new InvalidArgumentException(sprintf('The name "%s" contains illegal characters. Names should start with a letter, digit or underscore and only contain letters, digits, numbers, underscores ("_"), hyphens ("-") and colons (":").', $name)); + } + } + + /** + * Returns whether the given variable contains a valid form name. + * + * A name is accepted if it + * + * * is empty + * * starts with a letter, digit or underscore + * * contains only letters, digits, numbers, underscores ("_"), + * hyphens ("-") and colons (":") + */ + final public static function isValidName(?string $name): bool + { + return '' === $name || null === $name || preg_match('/^[a-zA-Z0-9_][a-zA-Z0-9_\-:]*$/D', $name); + } +} diff --git a/lib/symfony/form/FormConfigBuilderInterface.php b/lib/symfony/form/FormConfigBuilderInterface.php new file mode 100644 index 000000000..09b914980 --- /dev/null +++ b/lib/symfony/form/FormConfigBuilderInterface.php @@ -0,0 +1,260 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * @author Bernhard Schussek + */ +interface FormConfigBuilderInterface extends FormConfigInterface +{ + /** + * Adds an event listener to an event on this form. + * + * @param int $priority The priority of the listener. Listeners + * with a higher priority are called before + * listeners with a lower priority. + * + * @return $this + */ + public function addEventListener(string $eventName, callable $listener, int $priority = 0): static; + + /** + * Adds an event subscriber for events on this form. + * + * @return $this + */ + public function addEventSubscriber(EventSubscriberInterface $subscriber): static; + + /** + * Appends / prepends a transformer to the view transformer chain. + * + * The transform method of the transformer is used to convert data from the + * normalized to the view format. + * The reverseTransform method of the transformer is used to convert from the + * view to the normalized format. + * + * @param bool $forcePrepend If set to true, prepend instead of appending + * + * @return $this + */ + public function addViewTransformer(DataTransformerInterface $viewTransformer, bool $forcePrepend = false): static; + + /** + * Clears the view transformers. + * + * @return $this + */ + public function resetViewTransformers(): static; + + /** + * Prepends / appends a transformer to the normalization transformer chain. + * + * The transform method of the transformer is used to convert data from the + * model to the normalized format. + * The reverseTransform method of the transformer is used to convert from the + * normalized to the model format. + * + * @param bool $forceAppend If set to true, append instead of prepending + * + * @return $this + */ + public function addModelTransformer(DataTransformerInterface $modelTransformer, bool $forceAppend = false): static; + + /** + * Clears the normalization transformers. + * + * @return $this + */ + public function resetModelTransformers(): static; + + /** + * Sets the value for an attribute. + * + * @param mixed $value The value of the attribute + * + * @return $this + */ + public function setAttribute(string $name, mixed $value): static; + + /** + * Sets the attributes. + * + * @return $this + */ + public function setAttributes(array $attributes): static; + + /** + * Sets the data mapper used by the form. + * + * @return $this + */ + public function setDataMapper(?DataMapperInterface $dataMapper): static; + + /** + * Sets whether the form is disabled. + * + * @return $this + */ + public function setDisabled(bool $disabled): static; + + /** + * Sets the data used for the client data when no value is submitted. + * + * @param mixed $emptyData The empty data + * + * @return $this + */ + public function setEmptyData(mixed $emptyData): static; + + /** + * Sets whether errors bubble up to the parent. + * + * @return $this + */ + public function setErrorBubbling(bool $errorBubbling): static; + + /** + * Sets whether this field is required to be filled out when submitted. + * + * @return $this + */ + public function setRequired(bool $required): static; + + /** + * Sets the property path that the form should be mapped to. + * + * @param string|PropertyPathInterface|null $propertyPath The property path or null if the path should be set + * automatically based on the form's name + * + * @return $this + */ + public function setPropertyPath(string|PropertyPathInterface|null $propertyPath): static; + + /** + * Sets whether the form should be mapped to an element of its + * parent's data. + * + * @return $this + */ + public function setMapped(bool $mapped): static; + + /** + * Sets whether the form's data should be modified by reference. + * + * @return $this + */ + public function setByReference(bool $byReference): static; + + /** + * Sets whether the form should read and write the data of its parent. + * + * @return $this + */ + public function setInheritData(bool $inheritData): static; + + /** + * Sets whether the form should be compound. + * + * @return $this + * + * @see FormConfigInterface::getCompound() + */ + public function setCompound(bool $compound): static; + + /** + * Sets the resolved type. + * + * @return $this + */ + public function setType(ResolvedFormTypeInterface $type): static; + + /** + * Sets the initial data of the form. + * + * @param mixed $data The data of the form in model format + * + * @return $this + */ + public function setData(mixed $data): static; + + /** + * Locks the form's data to the data passed in the configuration. + * + * A form with locked data is restricted to the data passed in + * this configuration. The data can only be modified then by + * submitting the form or using PRE_SET_DATA event. + * + * It means data passed to a factory method or mapped from the + * parent will be ignored. + * + * @return $this + */ + public function setDataLocked(bool $locked): static; + + /** + * Sets the form factory used for creating new forms. + * + * @return $this + */ + public function setFormFactory(FormFactoryInterface $formFactory); + + /** + * Sets the target URL of the form. + * + * @return $this + */ + public function setAction(string $action): static; + + /** + * Sets the HTTP method used by the form. + * + * @return $this + */ + public function setMethod(string $method): static; + + /** + * Sets the request handler used by the form. + * + * @return $this + */ + public function setRequestHandler(RequestHandlerInterface $requestHandler): static; + + /** + * Sets whether the form should be initialized automatically. + * + * Should be set to true only for root forms. + * + * @param bool $initialize True to initialize the form automatically, + * false to suppress automatic initialization. + * In the second case, you need to call + * {@link FormInterface::initialize()} manually. + * + * @return $this + */ + public function setAutoInitialize(bool $initialize): static; + + /** + * Builds and returns the form configuration. + */ + public function getFormConfig(): FormConfigInterface; + + /** + * Sets the callback that will be called to determine if the model + * data of the form is empty or not. + * + * @return $this + */ + public function setIsEmptyCallback(?callable $isEmptyCallback): static; +} diff --git a/lib/symfony/form/FormConfigInterface.php b/lib/symfony/form/FormConfigInterface.php new file mode 100644 index 000000000..93d1998ec --- /dev/null +++ b/lib/symfony/form/FormConfigInterface.php @@ -0,0 +1,196 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * The configuration of a {@link Form} object. + * + * @author Bernhard Schussek + */ +interface FormConfigInterface +{ + /** + * Returns the event dispatcher used to dispatch form events. + */ + public function getEventDispatcher(): EventDispatcherInterface; + + /** + * Returns the name of the form used as HTTP parameter. + */ + public function getName(): string; + + /** + * Returns the property path that the form should be mapped to. + */ + public function getPropertyPath(): ?PropertyPathInterface; + + /** + * Returns whether the form should be mapped to an element of its + * parent's data. + */ + public function getMapped(): bool; + + /** + * Returns whether the form's data should be modified by reference. + */ + public function getByReference(): bool; + + /** + * Returns whether the form should read and write the data of its parent. + */ + public function getInheritData(): bool; + + /** + * Returns whether the form is compound. + * + * This property is independent of whether the form actually has + * children. A form can be compound and have no children at all, like + * for example an empty collection form. + * The contrary is not possible, a form which is not compound + * cannot have any children. + */ + public function getCompound(): bool; + + /** + * Returns the resolved form type used to construct the form. + */ + public function getType(): ResolvedFormTypeInterface; + + /** + * Returns the view transformers of the form. + * + * @return DataTransformerInterface[] + */ + public function getViewTransformers(): array; + + /** + * Returns the model transformers of the form. + * + * @return DataTransformerInterface[] + */ + public function getModelTransformers(): array; + + /** + * Returns the data mapper of the compound form or null for a simple form. + */ + public function getDataMapper(): ?DataMapperInterface; + + /** + * Returns whether the form is required. + */ + public function getRequired(): bool; + + /** + * Returns whether the form is disabled. + */ + public function getDisabled(): bool; + + /** + * Returns whether errors attached to the form will bubble to its parent. + */ + public function getErrorBubbling(): bool; + + /** + * Used when the view data is empty on submission. + * + * When the form is compound it will also be used to map the + * children data. + * + * The empty data must match the view format as it will passed to the first view transformer's + * "reverseTransform" method. + */ + public function getEmptyData(): mixed; + + /** + * Returns additional attributes of the form. + */ + public function getAttributes(): array; + + /** + * Returns whether the attribute with the given name exists. + */ + public function hasAttribute(string $name): bool; + + /** + * Returns the value of the given attribute. + */ + public function getAttribute(string $name, mixed $default = null): mixed; + + /** + * Returns the initial data of the form. + */ + public function getData(): mixed; + + /** + * Returns the class of the view data or null if the data is scalar or an array. + */ + public function getDataClass(): ?string; + + /** + * Returns whether the form's data is locked. + * + * A form with locked data is restricted to the data passed in + * this configuration. The data can only be modified then by + * submitting the form. + */ + public function getDataLocked(): bool; + + /** + * Returns the form factory used for creating new forms. + */ + public function getFormFactory(): FormFactoryInterface; + + /** + * Returns the target URL of the form. + */ + public function getAction(): string; + + /** + * Returns the HTTP method used by the form. + */ + public function getMethod(): string; + + /** + * Returns the request handler used by the form. + */ + public function getRequestHandler(): RequestHandlerInterface; + + /** + * Returns whether the form should be initialized upon creation. + */ + public function getAutoInitialize(): bool; + + /** + * Returns all options passed during the construction of the form. + * + * @return array The passed options + */ + public function getOptions(): array; + + /** + * Returns whether a specific option exists. + */ + public function hasOption(string $name): bool; + + /** + * Returns the value of a specific option. + */ + public function getOption(string $name, mixed $default = null): mixed; + + /** + * Returns a callable that takes the model data as argument and that returns if it is empty or not. + */ + public function getIsEmptyCallback(): ?callable; +} diff --git a/lib/symfony/form/FormError.php b/lib/symfony/form/FormError.php new file mode 100644 index 000000000..b9b326277 --- /dev/null +++ b/lib/symfony/form/FormError.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\BadMethodCallException; + +/** + * Wraps errors in forms. + * + * @author Bernhard Schussek + */ +class FormError +{ + protected $messageTemplate; + protected $messageParameters; + protected $messagePluralization; + + private string $message; + private mixed $cause; + + /** + * The form that spawned this error. + */ + private ?FormInterface $origin = null; + + /** + * Any array key in $messageParameters will be used as a placeholder in + * $messageTemplate. + * + * @param string $message The translated error message + * @param string|null $messageTemplate The template for the error message + * @param array $messageParameters The parameters that should be + * substituted in the message template + * @param int|null $messagePluralization The value for error message pluralization + * @param mixed $cause The cause of the error + * + * @see \Symfony\Component\Translation\Translator + */ + public function __construct(string $message, ?string $messageTemplate = null, array $messageParameters = [], ?int $messagePluralization = null, mixed $cause = null) + { + $this->message = $message; + $this->messageTemplate = $messageTemplate ?: $message; + $this->messageParameters = $messageParameters; + $this->messagePluralization = $messagePluralization; + $this->cause = $cause; + } + + /** + * Returns the error message. + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Returns the error message template. + */ + public function getMessageTemplate(): string + { + return $this->messageTemplate; + } + + /** + * Returns the parameters to be inserted in the message template. + */ + public function getMessageParameters(): array + { + return $this->messageParameters; + } + + /** + * Returns the value for error message pluralization. + */ + public function getMessagePluralization(): ?int + { + return $this->messagePluralization; + } + + /** + * Returns the cause of this error. + */ + public function getCause(): mixed + { + return $this->cause; + } + + /** + * Sets the form that caused this error. + * + * This method must only be called once. + * + * @return void + * + * @throws BadMethodCallException If the method is called more than once + */ + public function setOrigin(FormInterface $origin) + { + if (null !== $this->origin) { + throw new BadMethodCallException('setOrigin() must only be called once.'); + } + + $this->origin = $origin; + } + + /** + * Returns the form that caused this error. + */ + public function getOrigin(): ?FormInterface + { + return $this->origin; + } +} diff --git a/lib/symfony/form/FormErrorIterator.php b/lib/symfony/form/FormErrorIterator.php new file mode 100644 index 000000000..42de0c835 --- /dev/null +++ b/lib/symfony/form/FormErrorIterator.php @@ -0,0 +1,275 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Exception\OutOfBoundsException; +use Symfony\Component\Validator\ConstraintViolation; + +/** + * Iterates over the errors of a form. + * + * This class supports recursive iteration. In order to iterate recursively, + * pass a structure of {@link FormError} and {@link FormErrorIterator} objects + * to the $errors constructor argument. + * + * You can also wrap the iterator into a {@link \RecursiveIteratorIterator} to + * flatten the recursive structure into a flat list of errors. + * + * @author Bernhard Schussek + * + * @template T of FormError|FormErrorIterator + * + * @implements \ArrayAccess + * @implements \RecursiveIterator + * @implements \SeekableIterator + */ +class FormErrorIterator implements \RecursiveIterator, \SeekableIterator, \ArrayAccess, \Countable, \Stringable +{ + /** + * The prefix used for indenting nested error messages. + */ + public const INDENTATION = ' '; + + private FormInterface $form; + + /** + * @var list + */ + private array $errors; + + /** + * @param list $errors + * + * @throws InvalidArgumentException If the errors are invalid + */ + public function __construct(FormInterface $form, array $errors) + { + foreach ($errors as $error) { + if (!($error instanceof FormError || $error instanceof self)) { + throw new InvalidArgumentException(sprintf('The errors must be instances of "Symfony\Component\Form\FormError" or "%s". Got: "%s".', __CLASS__, get_debug_type($error))); + } + } + + $this->form = $form; + $this->errors = $errors; + } + + /** + * Returns all iterated error messages as string. + */ + public function __toString(): string + { + $string = ''; + + foreach ($this->errors as $error) { + if ($error instanceof FormError) { + $string .= 'ERROR: '.$error->getMessage()."\n"; + } else { + /* @var self $error */ + $string .= $error->getForm()->getName().":\n"; + $string .= self::indent((string) $error); + } + } + + return $string; + } + + /** + * Returns the iterated form. + */ + public function getForm(): FormInterface + { + return $this->form; + } + + /** + * Returns the current element of the iterator. + * + * @return T An error or an iterator containing nested errors + */ + public function current(): FormError|self + { + return current($this->errors); + } + + /** + * Advances the iterator to the next position. + */ + public function next(): void + { + next($this->errors); + } + + /** + * Returns the current position of the iterator. + */ + public function key(): int + { + return key($this->errors); + } + + /** + * Returns whether the iterator's position is valid. + */ + public function valid(): bool + { + return null !== key($this->errors); + } + + /** + * Sets the iterator's position to the beginning. + * + * This method detects if errors have been added to the form since the + * construction of the iterator. + */ + public function rewind(): void + { + reset($this->errors); + } + + /** + * Returns whether a position exists in the iterator. + * + * @param int $position The position + */ + public function offsetExists(mixed $position): bool + { + return isset($this->errors[$position]); + } + + /** + * Returns the element at a position in the iterator. + * + * @param int $position The position + * + * @return T + * + * @throws OutOfBoundsException If the given position does not exist + */ + public function offsetGet(mixed $position): FormError|self + { + if (!isset($this->errors[$position])) { + throw new OutOfBoundsException('The offset '.$position.' does not exist.'); + } + + return $this->errors[$position]; + } + + /** + * Unsupported method. + * + * @throws BadMethodCallException + */ + public function offsetSet(mixed $position, mixed $value): void + { + throw new BadMethodCallException('The iterator doesn\'t support modification of elements.'); + } + + /** + * Unsupported method. + * + * @throws BadMethodCallException + */ + public function offsetUnset(mixed $position): void + { + throw new BadMethodCallException('The iterator doesn\'t support modification of elements.'); + } + + /** + * Returns whether the current element of the iterator can be recursed + * into. + */ + public function hasChildren(): bool + { + return current($this->errors) instanceof self; + } + + public function getChildren(): self + { + if (!$this->hasChildren()) { + throw new LogicException(sprintf('The current element is not iterable. Use "%s" to get the current element.', self::class.'::current()')); + } + + /** @var self $children */ + $children = current($this->errors); + + return $children; + } + + /** + * Returns the number of elements in the iterator. + * + * Note that this is not the total number of errors, if the constructor + * parameter $deep was set to true! In that case, you should wrap the + * iterator into a {@link \RecursiveIteratorIterator} with the standard mode + * {@link \RecursiveIteratorIterator::LEAVES_ONLY} and count the result. + * + * $iterator = new \RecursiveIteratorIterator($form->getErrors(true)); + * $count = count(iterator_to_array($iterator)); + * + * Alternatively, set the constructor argument $flatten to true as well. + * + * $count = count($form->getErrors(true, true)); + */ + public function count(): int + { + return \count($this->errors); + } + + /** + * Sets the position of the iterator. + * + * @throws OutOfBoundsException If the position is invalid + */ + public function seek(int $position): void + { + if (!isset($this->errors[$position])) { + throw new OutOfBoundsException('The offset '.$position.' does not exist.'); + } + + reset($this->errors); + + while ($position !== key($this->errors)) { + next($this->errors); + } + } + + /** + * Creates iterator for errors with specific codes. + * + * @param string|string[] $codes The codes to find + */ + public function findByCodes(string|array $codes): static + { + $codes = (array) $codes; + $errors = []; + foreach ($this as $error) { + $cause = $error->getCause(); + if ($cause instanceof ConstraintViolation && \in_array($cause->getCode(), $codes, true)) { + $errors[] = $error; + } + } + + return new static($this->form, $errors); + } + + /** + * Utility function for indenting multi-line strings. + */ + private static function indent(string $string): string + { + return rtrim(self::INDENTATION.str_replace("\n", "\n".self::INDENTATION, $string), ' '); + } +} diff --git a/lib/symfony/form/FormEvent.php b/lib/symfony/form/FormEvent.php new file mode 100644 index 000000000..1e6aa34d6 --- /dev/null +++ b/lib/symfony/form/FormEvent.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Contracts\EventDispatcher\Event; + +/** + * @author Bernhard Schussek + */ +class FormEvent extends Event +{ + private FormInterface $form; + protected $data; + + public function __construct(FormInterface $form, mixed $data) + { + $this->form = $form; + $this->data = $data; + } + + /** + * Returns the form at the source of the event. + */ + public function getForm(): FormInterface + { + return $this->form; + } + + /** + * Returns the data associated with this event. + */ + public function getData(): mixed + { + return $this->data; + } + + /** + * Allows updating with some filtered data. + * + * @return void + */ + public function setData(mixed $data) + { + $this->data = $data; + } +} diff --git a/lib/symfony/form/FormEvents.php b/lib/symfony/form/FormEvents.php new file mode 100644 index 000000000..cf4d97f55 --- /dev/null +++ b/lib/symfony/form/FormEvents.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Event\PostSetDataEvent; +use Symfony\Component\Form\Event\PostSubmitEvent; +use Symfony\Component\Form\Event\PreSetDataEvent; +use Symfony\Component\Form\Event\PreSubmitEvent; +use Symfony\Component\Form\Event\SubmitEvent; + +/** + * To learn more about how form events work check the documentation + * entry at {@link https://symfony.com/doc/any/components/form/form_events.html}. + * + * To learn how to dynamically modify forms using events check the cookbook + * entry at {@link https://symfony.com/doc/any/cookbook/form/dynamic_form_modification.html}. + * + * @author Bernhard Schussek + */ +final class FormEvents +{ + /** + * The PRE_SUBMIT event is dispatched at the beginning of the Form::submit() method. + * + * It can be used to: + * - Change data from the request, before submitting the data to the form. + * - Add or remove form fields, before submitting the data to the form. + * + * @Event("Symfony\Component\Form\Event\PreSubmitEvent") + */ + public const PRE_SUBMIT = 'form.pre_submit'; + + /** + * The SUBMIT event is dispatched after the Form::submit() method + * has changed the view data by the request data, or submitted and mapped + * the children if the form is compound, and after reverse transformation + * to normalized representation. + * + * It's also dispatched just before the Form::submit() method transforms back + * the normalized data to the model and view data. + * + * So at this stage children of compound forms are submitted and synchronized, unless + * their transformation failed, but a parent would still be at the PRE_SUBMIT level. + * + * Since the current form is not synchronized yet, it is still possible to add and + * remove fields. + * + * @Event("Symfony\Component\Form\Event\SubmitEvent") + */ + public const SUBMIT = 'form.submit'; + + /** + * The FormEvents::POST_SUBMIT event is dispatched at the very end of the Form::submit(). + * + * It this stage the model and view data may have been denormalized. Otherwise the form + * is desynchronized because transformation failed during submission. + * + * It can be used to fetch data after denormalization. + * + * The event attaches the current view data. To know whether this is the renormalized data + * or the invalid request data, call Form::isSynchronized() first. + * + * @Event("Symfony\Component\Form\Event\PostSubmitEvent") + */ + public const POST_SUBMIT = 'form.post_submit'; + + /** + * The FormEvents::PRE_SET_DATA event is dispatched at the beginning of the Form::setData() method. + * + * It can be used to: + * - Modify the data given during pre-population; + * - Keep synchronized the form depending on the data (adding or removing fields dynamically). + * + * @Event("Symfony\Component\Form\Event\PreSetDataEvent") + */ + public const PRE_SET_DATA = 'form.pre_set_data'; + + /** + * The FormEvents::POST_SET_DATA event is dispatched at the end of the Form::setData() method. + * + * This event can be used to modify the form depending on the final state of the underlying data + * accessible in every representation: model, normalized and view. + * + * @Event("Symfony\Component\Form\Event\PostSetDataEvent") + */ + public const POST_SET_DATA = 'form.post_set_data'; + + /** + * Event aliases. + * + * These aliases can be consumed by RegisterListenersPass. + */ + public const ALIASES = [ + PreSubmitEvent::class => self::PRE_SUBMIT, + SubmitEvent::class => self::SUBMIT, + PostSubmitEvent::class => self::POST_SUBMIT, + PreSetDataEvent::class => self::PRE_SET_DATA, + PostSetDataEvent::class => self::POST_SET_DATA, + ]; + + private function __construct() + { + } +} diff --git a/lib/symfony/form/FormExtensionInterface.php b/lib/symfony/form/FormExtensionInterface.php new file mode 100644 index 000000000..e540e1825 --- /dev/null +++ b/lib/symfony/form/FormExtensionInterface.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * Interface for extensions which provide types, type extensions and a guesser. + */ +interface FormExtensionInterface +{ + /** + * Returns a type by name. + * + * @param string $name The name of the type + * + * @throws Exception\InvalidArgumentException if the given type is not supported by this extension + */ + public function getType(string $name): FormTypeInterface; + + /** + * Returns whether the given type is supported. + * + * @param string $name The name of the type + */ + public function hasType(string $name): bool; + + /** + * Returns the extensions for the given type. + * + * @param string $name The name of the type + * + * @return FormTypeExtensionInterface[] + */ + public function getTypeExtensions(string $name): array; + + /** + * Returns whether this extension provides type extensions for the given type. + * + * @param string $name The name of the type + */ + public function hasTypeExtensions(string $name): bool; + + /** + * Returns the type guesser provided by this extension. + */ + public function getTypeGuesser(): ?FormTypeGuesserInterface; +} diff --git a/lib/symfony/form/FormFactory.php b/lib/symfony/form/FormFactory.php new file mode 100644 index 000000000..9e1234f83 --- /dev/null +++ b/lib/symfony/form/FormFactory.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\TextType; + +class FormFactory implements FormFactoryInterface +{ + private FormRegistryInterface $registry; + + public function __construct(FormRegistryInterface $registry) + { + $this->registry = $registry; + } + + public function create(string $type = FormType::class, mixed $data = null, array $options = []): FormInterface + { + return $this->createBuilder($type, $data, $options)->getForm(); + } + + public function createNamed(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface + { + return $this->createNamedBuilder($name, $type, $data, $options)->getForm(); + } + + public function createForProperty(string $class, string $property, mixed $data = null, array $options = []): FormInterface + { + return $this->createBuilderForProperty($class, $property, $data, $options)->getForm(); + } + + public function createBuilder(string $type = FormType::class, mixed $data = null, array $options = []): FormBuilderInterface + { + return $this->createNamedBuilder($this->registry->getType($type)->getBlockPrefix(), $type, $data, $options); + } + + public function createNamedBuilder(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormBuilderInterface + { + if (null !== $data && !\array_key_exists('data', $options)) { + $options['data'] = $data; + } + + $type = $this->registry->getType($type); + + $builder = $type->createBuilder($this, $name, $options); + + // Explicitly call buildForm() in order to be able to override either + // createBuilder() or buildForm() in the resolved form type + $type->buildForm($builder, $builder->getOptions()); + + return $builder; + } + + public function createBuilderForProperty(string $class, string $property, mixed $data = null, array $options = []): FormBuilderInterface + { + if (null === $guesser = $this->registry->getTypeGuesser()) { + return $this->createNamedBuilder($property, TextType::class, $data, $options); + } + + $typeGuess = $guesser->guessType($class, $property); + $maxLengthGuess = $guesser->guessMaxLength($class, $property); + $requiredGuess = $guesser->guessRequired($class, $property); + $patternGuess = $guesser->guessPattern($class, $property); + + $type = $typeGuess ? $typeGuess->getType() : TextType::class; + + $maxLength = $maxLengthGuess?->getValue(); + $pattern = $patternGuess?->getValue(); + + if (null !== $pattern) { + $options = array_replace_recursive(['attr' => ['pattern' => $pattern]], $options); + } + + if (null !== $maxLength) { + $options = array_replace_recursive(['attr' => ['maxlength' => $maxLength]], $options); + } + + if ($requiredGuess) { + $options = array_merge(['required' => $requiredGuess->getValue()], $options); + } + + // user options may override guessed options + if ($typeGuess) { + $attrs = []; + $typeGuessOptions = $typeGuess->getOptions(); + if (isset($typeGuessOptions['attr']) && isset($options['attr'])) { + $attrs = ['attr' => array_merge($typeGuessOptions['attr'], $options['attr'])]; + } + + $options = array_merge($typeGuessOptions, $options, $attrs); + } + + return $this->createNamedBuilder($property, $type, $data, $options); + } +} diff --git a/lib/symfony/form/FormFactoryBuilder.php b/lib/symfony/form/FormFactoryBuilder.php new file mode 100644 index 000000000..42b8dec9f --- /dev/null +++ b/lib/symfony/form/FormFactoryBuilder.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Extension\Core\CoreExtension; + +/** + * The default implementation of FormFactoryBuilderInterface. + * + * @author Bernhard Schussek + */ +class FormFactoryBuilder implements FormFactoryBuilderInterface +{ + private bool $forceCoreExtension; + + private ResolvedFormTypeFactoryInterface $resolvedTypeFactory; + + /** + * @var FormExtensionInterface[] + */ + private array $extensions = []; + + /** + * @var FormTypeInterface[] + */ + private array $types = []; + + /** + * @var FormTypeExtensionInterface[][] + */ + private array $typeExtensions = []; + + /** + * @var FormTypeGuesserInterface[] + */ + private array $typeGuessers = []; + + public function __construct(bool $forceCoreExtension = false) + { + $this->forceCoreExtension = $forceCoreExtension; + } + + public function setResolvedTypeFactory(ResolvedFormTypeFactoryInterface $resolvedTypeFactory): static + { + $this->resolvedTypeFactory = $resolvedTypeFactory; + + return $this; + } + + public function addExtension(FormExtensionInterface $extension): static + { + $this->extensions[] = $extension; + + return $this; + } + + public function addExtensions(array $extensions): static + { + $this->extensions = array_merge($this->extensions, $extensions); + + return $this; + } + + public function addType(FormTypeInterface $type): static + { + $this->types[] = $type; + + return $this; + } + + public function addTypes(array $types): static + { + foreach ($types as $type) { + $this->types[] = $type; + } + + return $this; + } + + public function addTypeExtension(FormTypeExtensionInterface $typeExtension): static + { + foreach ($typeExtension::getExtendedTypes() as $extendedType) { + $this->typeExtensions[$extendedType][] = $typeExtension; + } + + return $this; + } + + public function addTypeExtensions(array $typeExtensions): static + { + foreach ($typeExtensions as $typeExtension) { + $this->addTypeExtension($typeExtension); + } + + return $this; + } + + public function addTypeGuesser(FormTypeGuesserInterface $typeGuesser): static + { + $this->typeGuessers[] = $typeGuesser; + + return $this; + } + + public function addTypeGuessers(array $typeGuessers): static + { + $this->typeGuessers = array_merge($this->typeGuessers, $typeGuessers); + + return $this; + } + + public function getFormFactory(): FormFactoryInterface + { + $extensions = $this->extensions; + + if ($this->forceCoreExtension) { + $hasCoreExtension = false; + + foreach ($extensions as $extension) { + if ($extension instanceof CoreExtension) { + $hasCoreExtension = true; + break; + } + } + + if (!$hasCoreExtension) { + array_unshift($extensions, new CoreExtension()); + } + } + + if (\count($this->types) > 0 || \count($this->typeExtensions) > 0 || \count($this->typeGuessers) > 0) { + if (\count($this->typeGuessers) > 1) { + $typeGuesser = new FormTypeGuesserChain($this->typeGuessers); + } else { + $typeGuesser = $this->typeGuessers[0] ?? null; + } + + $extensions[] = new PreloadedExtension($this->types, $this->typeExtensions, $typeGuesser); + } + + $registry = new FormRegistry($extensions, $this->resolvedTypeFactory ?? new ResolvedFormTypeFactory()); + + return new FormFactory($registry); + } +} diff --git a/lib/symfony/form/FormFactoryBuilderInterface.php b/lib/symfony/form/FormFactoryBuilderInterface.php new file mode 100644 index 000000000..70bdf507b --- /dev/null +++ b/lib/symfony/form/FormFactoryBuilderInterface.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * A builder for FormFactoryInterface objects. + * + * @author Bernhard Schussek + */ +interface FormFactoryBuilderInterface +{ + /** + * Sets the factory for creating ResolvedFormTypeInterface instances. + * + * @return $this + */ + public function setResolvedTypeFactory(ResolvedFormTypeFactoryInterface $resolvedTypeFactory): static; + + /** + * Adds an extension to be loaded by the factory. + * + * @return $this + */ + public function addExtension(FormExtensionInterface $extension): static; + + /** + * Adds a list of extensions to be loaded by the factory. + * + * @param FormExtensionInterface[] $extensions The extensions + * + * @return $this + */ + public function addExtensions(array $extensions): static; + + /** + * Adds a form type to the factory. + * + * @return $this + */ + public function addType(FormTypeInterface $type): static; + + /** + * Adds a list of form types to the factory. + * + * @param FormTypeInterface[] $types The form types + * + * @return $this + */ + public function addTypes(array $types): static; + + /** + * Adds a form type extension to the factory. + * + * @return $this + */ + public function addTypeExtension(FormTypeExtensionInterface $typeExtension): static; + + /** + * Adds a list of form type extensions to the factory. + * + * @param FormTypeExtensionInterface[] $typeExtensions The form type extensions + * + * @return $this + */ + public function addTypeExtensions(array $typeExtensions): static; + + /** + * Adds a type guesser to the factory. + * + * @return $this + */ + public function addTypeGuesser(FormTypeGuesserInterface $typeGuesser): static; + + /** + * Adds a list of type guessers to the factory. + * + * @param FormTypeGuesserInterface[] $typeGuessers The type guessers + * + * @return $this + */ + public function addTypeGuessers(array $typeGuessers): static; + + /** + * Builds and returns the factory. + */ + public function getFormFactory(): FormFactoryInterface; +} diff --git a/lib/symfony/form/FormFactoryInterface.php b/lib/symfony/form/FormFactoryInterface.php new file mode 100644 index 000000000..0f311c0e5 --- /dev/null +++ b/lib/symfony/form/FormFactoryInterface.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; + +/** + * Allows creating a form based on a name, a class or a property. + * + * @author Bernhard Schussek + */ +interface FormFactoryInterface +{ + /** + * Returns a form. + * + * @see createBuilder() + * + * @param mixed $data The initial data + * + * @throws InvalidOptionsException if any given option is not applicable to the given type + */ + public function create(string $type = FormType::class, mixed $data = null, array $options = []): FormInterface; + + /** + * Returns a form. + * + * @see createNamedBuilder() + * + * @param mixed $data The initial data + * + * @throws InvalidOptionsException if any given option is not applicable to the given type + */ + public function createNamed(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface; + + /** + * Returns a form for a property of a class. + * + * @see createBuilderForProperty() + * + * @param string $class The fully qualified class name + * @param string $property The name of the property to guess for + * @param mixed $data The initial data + * + * @throws InvalidOptionsException if any given option is not applicable to the form type + */ + public function createForProperty(string $class, string $property, mixed $data = null, array $options = []): FormInterface; + + /** + * Returns a form builder. + * + * @param mixed $data The initial data + * + * @throws InvalidOptionsException if any given option is not applicable to the given type + */ + public function createBuilder(string $type = FormType::class, mixed $data = null, array $options = []): FormBuilderInterface; + + /** + * Returns a form builder. + * + * @param mixed $data The initial data + * + * @throws InvalidOptionsException if any given option is not applicable to the given type + */ + public function createNamedBuilder(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormBuilderInterface; + + /** + * Returns a form builder for a property of a class. + * + * If any of the 'required' and type options can be guessed, + * and are not provided in the options argument, the guessed value is used. + * + * @param string $class The fully qualified class name + * @param string $property The name of the property to guess for + * @param mixed $data The initial data + * + * @throws InvalidOptionsException if any given option is not applicable to the form type + */ + public function createBuilderForProperty(string $class, string $property, mixed $data = null, array $options = []): FormBuilderInterface; +} diff --git a/lib/symfony/form/FormInterface.php b/lib/symfony/form/FormInterface.php new file mode 100644 index 000000000..23392c493 --- /dev/null +++ b/lib/symfony/form/FormInterface.php @@ -0,0 +1,289 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * A form group bundling multiple forms in a hierarchical structure. + * + * @author Bernhard Schussek + * + * @extends \ArrayAccess + * @extends \Traversable + */ +interface FormInterface extends \ArrayAccess, \Traversable, \Countable +{ + /** + * Sets the parent form. + * + * @param FormInterface|null $parent The parent form or null if it's the root + * + * @return $this + * + * @throws Exception\AlreadySubmittedException if the form has already been submitted + * @throws Exception\LogicException when trying to set a parent for a form with + * an empty name + */ + public function setParent(?self $parent): static; + + /** + * Returns the parent form. + */ + public function getParent(): ?self; + + /** + * Adds or replaces a child to the form. + * + * @param FormInterface|string $child The FormInterface instance or the name of the child + * @param string|null $type The child's type, if a name was passed + * @param array $options The child's options, if a name was passed + * + * @return $this + * + * @throws Exception\AlreadySubmittedException if the form has already been submitted + * @throws Exception\LogicException when trying to add a child to a non-compound form + * @throws Exception\UnexpectedTypeException if $child or $type has an unexpected type + */ + public function add(self|string $child, ?string $type = null, array $options = []): static; + + /** + * Returns the child with the given name. + * + * @throws Exception\OutOfBoundsException if the named child does not exist + */ + public function get(string $name): self; + + /** + * Returns whether a child with the given name exists. + */ + public function has(string $name): bool; + + /** + * Removes a child from the form. + * + * @return $this + * + * @throws Exception\AlreadySubmittedException if the form has already been submitted + */ + public function remove(string $name): static; + + /** + * Returns all children in this group. + * + * @return self[] + */ + public function all(): array; + + /** + * Returns the errors of this form. + * + * @param bool $deep Whether to include errors of child forms as well + * @param bool $flatten Whether to flatten the list of errors in case + * $deep is set to true + */ + public function getErrors(bool $deep = false, bool $flatten = true): FormErrorIterator; + + /** + * Updates the form with default model data. + * + * @param mixed $modelData The data formatted as expected for the underlying object + * + * @return $this + * + * @throws Exception\AlreadySubmittedException If the form has already been submitted + * @throws Exception\LogicException if the view data does not match the expected type + * according to {@link FormConfigInterface::getDataClass} + * @throws Exception\RuntimeException If listeners try to call setData in a cycle or if + * the form inherits data from its parent + * @throws Exception\TransformationFailedException if the synchronization failed + */ + public function setData(mixed $modelData): static; + + /** + * Returns the model data in the format needed for the underlying object. + * + * @return mixed When the field is not submitted, the default data is returned. + * When the field is submitted, the default data has been bound + * to the submitted view data. + * + * @throws Exception\RuntimeException If the form inherits data but has no parent + */ + public function getData(): mixed; + + /** + * Returns the normalized data of the field, used as internal bridge + * between model data and view data. + * + * @return mixed When the field is not submitted, the default data is returned. + * When the field is submitted, the normalized submitted data + * is returned if the field is synchronized with the view data, + * null otherwise. + * + * @throws Exception\RuntimeException If the form inherits data but has no parent + */ + public function getNormData(): mixed; + + /** + * Returns the view data of the field. + * + * It may be defined by {@link FormConfigInterface::getDataClass}. + * + * There are two cases: + * + * - When the form is compound the view data is mapped to the children. + * Each child will use its mapped data as model data. + * It can be an array, an object or null. + * + * - When the form is simple its view data is used to be bound + * to the submitted data. + * It can be a string or an array. + * + * In both cases the view data is the actual altered data on submission. + * + * @throws Exception\RuntimeException If the form inherits data but has no parent + */ + public function getViewData(): mixed; + + /** + * Returns the extra submitted data. + * + * @return array The submitted data which do not belong to a child + */ + public function getExtraData(): array; + + /** + * Returns the form's configuration. + */ + public function getConfig(): FormConfigInterface; + + /** + * Returns whether the form is submitted. + */ + public function isSubmitted(): bool; + + /** + * Returns the name by which the form is identified in forms. + * + * Only root forms are allowed to have an empty name. + */ + public function getName(): string; + + /** + * Returns the property path that the form is mapped to. + */ + public function getPropertyPath(): ?PropertyPathInterface; + + /** + * Adds an error to this form. + * + * @return $this + */ + public function addError(FormError $error): static; + + /** + * Returns whether the form and all children are valid. + * + * @throws Exception\LogicException if the form is not submitted + */ + public function isValid(): bool; + + /** + * Returns whether the form is required to be filled out. + * + * If the form has a parent and the parent is not required, this method + * will always return false. Otherwise the value set with setRequired() + * is returned. + */ + public function isRequired(): bool; + + /** + * Returns whether this form is disabled. + * + * The content of a disabled form is displayed, but not allowed to be + * modified. The validation of modified disabled forms should fail. + * + * Forms whose parents are disabled are considered disabled regardless of + * their own state. + */ + public function isDisabled(): bool; + + /** + * Returns whether the form is empty. + */ + public function isEmpty(): bool; + + /** + * Returns whether the data in the different formats is synchronized. + * + * If the data is not synchronized, you can get the transformation failure + * by calling {@link getTransformationFailure()}. + * + * If the form is not submitted, this method always returns true. + */ + public function isSynchronized(): bool; + + /** + * Returns the data transformation failure, if any, during submission. + */ + public function getTransformationFailure(): ?Exception\TransformationFailedException; + + /** + * Initializes the form tree. + * + * Should be called on the root form after constructing the tree. + * + * @return $this + * + * @throws Exception\RuntimeException If the form is not the root + */ + public function initialize(): static; + + /** + * Inspects the given request and calls {@link submit()} if the form was + * submitted. + * + * Internally, the request is forwarded to the configured + * {@link RequestHandlerInterface} instance, which determines whether to + * submit the form or not. + * + * @return $this + */ + public function handleRequest(mixed $request = null): static; + + /** + * Submits data to the form. + * + * @param string|array|null $submittedData The submitted data + * @param bool $clearMissing Whether to set fields to NULL + * when they are missing in the + * submitted data. This argument + * is only used in compound form + * + * @return $this + * + * @throws Exception\AlreadySubmittedException if the form has already been submitted + */ + public function submit(string|array|null $submittedData, bool $clearMissing = true): static; + + /** + * Returns the root of the form tree. + */ + public function getRoot(): self; + + /** + * Returns whether the field is the root of the form tree. + */ + public function isRoot(): bool; + + public function createView(?FormView $parent = null): FormView; +} diff --git a/lib/symfony/form/FormRegistry.php b/lib/symfony/form/FormRegistry.php new file mode 100644 index 000000000..ab3f55f9f --- /dev/null +++ b/lib/symfony/form/FormRegistry.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\ExceptionInterface; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Exception\UnexpectedTypeException; + +/** + * The central registry of the Form component. + * + * @author Bernhard Schussek + */ +class FormRegistry implements FormRegistryInterface +{ + /** + * @var FormExtensionInterface[] + */ + private array $extensions = []; + + /** + * @var ResolvedFormTypeInterface[] + */ + private array $types = []; + + private FormTypeGuesserInterface|null|false $guesser = false; + private ResolvedFormTypeFactoryInterface $resolvedTypeFactory; + private array $checkedTypes = []; + + /** + * @param FormExtensionInterface[] $extensions + * + * @throws UnexpectedTypeException if any extension does not implement FormExtensionInterface + */ + public function __construct(array $extensions, ResolvedFormTypeFactoryInterface $resolvedTypeFactory) + { + foreach ($extensions as $extension) { + if (!$extension instanceof FormExtensionInterface) { + throw new UnexpectedTypeException($extension, FormExtensionInterface::class); + } + } + + $this->extensions = $extensions; + $this->resolvedTypeFactory = $resolvedTypeFactory; + } + + public function getType(string $name): ResolvedFormTypeInterface + { + if (!isset($this->types[$name])) { + $type = null; + + foreach ($this->extensions as $extension) { + if ($extension->hasType($name)) { + $type = $extension->getType($name); + break; + } + } + + if (!$type) { + // Support fully-qualified class names + if (!class_exists($name)) { + throw new InvalidArgumentException(sprintf('Could not load type "%s": class does not exist.', $name)); + } + if (!is_subclass_of($name, FormTypeInterface::class)) { + throw new InvalidArgumentException(sprintf('Could not load type "%s": class does not implement "Symfony\Component\Form\FormTypeInterface".', $name)); + } + + $type = new $name(); + } + + $this->types[$name] = $this->resolveType($type); + } + + return $this->types[$name]; + } + + /** + * Wraps a type into a ResolvedFormTypeInterface implementation and connects it with its parent type. + */ + private function resolveType(FormTypeInterface $type): ResolvedFormTypeInterface + { + $parentType = $type->getParent(); + $fqcn = $type::class; + + if (isset($this->checkedTypes[$fqcn])) { + $types = implode(' > ', array_merge(array_keys($this->checkedTypes), [$fqcn])); + throw new LogicException(sprintf('Circular reference detected for form type "%s" (%s).', $fqcn, $types)); + } + + $this->checkedTypes[$fqcn] = true; + + $typeExtensions = []; + try { + foreach ($this->extensions as $extension) { + $typeExtensions[] = $extension->getTypeExtensions($fqcn); + } + + return $this->resolvedTypeFactory->createResolvedType( + $type, + array_merge([], ...$typeExtensions), + $parentType ? $this->getType($parentType) : null + ); + } finally { + unset($this->checkedTypes[$fqcn]); + } + } + + public function hasType(string $name): bool + { + if (isset($this->types[$name])) { + return true; + } + + try { + $this->getType($name); + } catch (ExceptionInterface) { + return false; + } + + return true; + } + + public function getTypeGuesser(): ?FormTypeGuesserInterface + { + if (false === $this->guesser) { + $guessers = []; + + foreach ($this->extensions as $extension) { + $guesser = $extension->getTypeGuesser(); + + if ($guesser) { + $guessers[] = $guesser; + } + } + + $this->guesser = $guessers ? new FormTypeGuesserChain($guessers) : null; + } + + return $this->guesser; + } + + public function getExtensions(): array + { + return $this->extensions; + } +} diff --git a/lib/symfony/form/FormRegistryInterface.php b/lib/symfony/form/FormRegistryInterface.php new file mode 100644 index 000000000..b1e77898e --- /dev/null +++ b/lib/symfony/form/FormRegistryInterface.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * The central registry of the Form component. + * + * @author Bernhard Schussek + */ +interface FormRegistryInterface +{ + /** + * Returns a form type by name. + * + * This methods registers the type extensions from the form extensions. + * + * @throws Exception\InvalidArgumentException if the type cannot be retrieved from any extension + */ + public function getType(string $name): ResolvedFormTypeInterface; + + /** + * Returns whether the given form type is supported. + */ + public function hasType(string $name): bool; + + /** + * Returns the guesser responsible for guessing types. + */ + public function getTypeGuesser(): ?FormTypeGuesserInterface; + + /** + * Returns the extensions loaded by the framework. + * + * @return FormExtensionInterface[] + */ + public function getExtensions(): array; +} diff --git a/lib/symfony/form/FormRenderer.php b/lib/symfony/form/FormRenderer.php new file mode 100644 index 000000000..9853dcf50 --- /dev/null +++ b/lib/symfony/form/FormRenderer.php @@ -0,0 +1,288 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Twig\Environment; + +/** + * Renders a form into HTML using a rendering engine. + * + * @author Bernhard Schussek + */ +class FormRenderer implements FormRendererInterface +{ + public const CACHE_KEY_VAR = 'unique_block_prefix'; + + private FormRendererEngineInterface $engine; + private ?CsrfTokenManagerInterface $csrfTokenManager; + private array $blockNameHierarchyMap = []; + private array $hierarchyLevelMap = []; + private array $variableStack = []; + + public function __construct(FormRendererEngineInterface $engine, ?CsrfTokenManagerInterface $csrfTokenManager = null) + { + $this->engine = $engine; + $this->csrfTokenManager = $csrfTokenManager; + } + + public function getEngine(): FormRendererEngineInterface + { + return $this->engine; + } + + /** + * @return void + */ + public function setTheme(FormView $view, mixed $themes, bool $useDefaultThemes = true) + { + $this->engine->setTheme($view, $themes, $useDefaultThemes); + } + + public function renderCsrfToken(string $tokenId): string + { + if (null === $this->csrfTokenManager) { + throw new BadMethodCallException('CSRF tokens can only be generated if a CsrfTokenManagerInterface is injected in FormRenderer::__construct(). Try running "composer require symfony/security-csrf".'); + } + + return $this->csrfTokenManager->getToken($tokenId)->getValue(); + } + + public function renderBlock(FormView $view, string $blockName, array $variables = []): string + { + $resource = $this->engine->getResourceForBlockName($view, $blockName); + + if (!$resource) { + throw new LogicException(sprintf('No block "%s" found while rendering the form.', $blockName)); + } + + $viewCacheKey = $view->vars[self::CACHE_KEY_VAR]; + + // The variables are cached globally for a view (instead of for the + // current suffix) + if (!isset($this->variableStack[$viewCacheKey])) { + $this->variableStack[$viewCacheKey] = []; + + // The default variable scope contains all view variables, merged with + // the variables passed explicitly to the helper + $scopeVariables = $view->vars; + + $varInit = true; + } else { + // Reuse the current scope and merge it with the explicitly passed variables + $scopeVariables = end($this->variableStack[$viewCacheKey]); + + $varInit = false; + } + + // Merge the passed with the existing attributes + if (isset($variables['attr']) && isset($scopeVariables['attr'])) { + $variables['attr'] = array_replace($scopeVariables['attr'], $variables['attr']); + } + + // Merge the passed with the exist *label* attributes + if (isset($variables['label_attr']) && isset($scopeVariables['label_attr'])) { + $variables['label_attr'] = array_replace($scopeVariables['label_attr'], $variables['label_attr']); + } + + // Do not use array_replace_recursive(), otherwise array variables + // cannot be overwritten + $variables = array_replace($scopeVariables, $variables); + + $this->variableStack[$viewCacheKey][] = $variables; + + // Do the rendering + $html = $this->engine->renderBlock($view, $resource, $blockName, $variables); + + // Clear the stack + array_pop($this->variableStack[$viewCacheKey]); + + if ($varInit) { + unset($this->variableStack[$viewCacheKey]); + } + + return $html; + } + + public function searchAndRenderBlock(FormView $view, string $blockNameSuffix, array $variables = []): string + { + $renderOnlyOnce = 'row' === $blockNameSuffix || 'widget' === $blockNameSuffix; + + if ($renderOnlyOnce && $view->isRendered()) { + // This is not allowed, because it would result in rendering same IDs multiple times, which is not valid. + throw new BadMethodCallException(sprintf('Field "%s" has already been rendered, save the result of previous render call to a variable and output that instead.', $view->vars['name'])); + } + + // The cache key for storing the variables and types + $viewCacheKey = $view->vars[self::CACHE_KEY_VAR]; + $viewAndSuffixCacheKey = $viewCacheKey.$blockNameSuffix; + + // In templates, we have to deal with two kinds of block hierarchies: + // + // +---------+ +---------+ + // | Theme B | -------> | Theme A | + // +---------+ +---------+ + // + // form_widget -------> form_widget + // ^ + // | + // choice_widget -----> choice_widget + // + // The first kind of hierarchy is the theme hierarchy. This allows to + // override the block "choice_widget" from Theme A in the extending + // Theme B. This kind of inheritance needs to be supported by the + // template engine and, for example, offers "parent()" or similar + // functions to fall back from the custom to the parent implementation. + // + // The second kind of hierarchy is the form type hierarchy. This allows + // to implement a custom "choice_widget" block (no matter in which theme), + // or to fallback to the block of the parent type, which would be + // "form_widget" in this example (again, no matter in which theme). + // If the designer wants to explicitly fallback to "form_widget" in their + // custom "choice_widget", for example because they only want to wrap + // a
around the original implementation, they can call the + // widget() function again to render the block for the parent type. + // + // The second kind is implemented in the following blocks. + if (!isset($this->blockNameHierarchyMap[$viewAndSuffixCacheKey])) { + // INITIAL CALL + // Calculate the hierarchy of template blocks and start on + // the bottom level of the hierarchy (= "__
" block) + $blockNameHierarchy = []; + foreach ($view->vars['block_prefixes'] as $blockNamePrefix) { + $blockNameHierarchy[] = $blockNamePrefix.'_'.$blockNameSuffix; + } + $hierarchyLevel = \count($blockNameHierarchy) - 1; + + $hierarchyInit = true; + } else { + // RECURSIVE CALL + // If a block recursively calls searchAndRenderBlock() again, resume rendering + // using the parent type in the hierarchy. + $blockNameHierarchy = $this->blockNameHierarchyMap[$viewAndSuffixCacheKey]; + $hierarchyLevel = $this->hierarchyLevelMap[$viewAndSuffixCacheKey] - 1; + + $hierarchyInit = false; + } + + // The variables are cached globally for a view (instead of for the + // current suffix) + if (!isset($this->variableStack[$viewCacheKey])) { + $this->variableStack[$viewCacheKey] = []; + + // The default variable scope contains all view variables, merged with + // the variables passed explicitly to the helper + $scopeVariables = $view->vars; + + $varInit = true; + } else { + // Reuse the current scope and merge it with the explicitly passed variables + $scopeVariables = end($this->variableStack[$viewCacheKey]); + + $varInit = false; + } + + // Load the resource where this block can be found + $resource = $this->engine->getResourceForBlockNameHierarchy($view, $blockNameHierarchy, $hierarchyLevel); + + // Update the current hierarchy level to the one at which the resource was + // found. For example, if looking for "choice_widget", but only a resource + // is found for its parent "form_widget", then the level is updated here + // to the parent level. + $hierarchyLevel = $this->engine->getResourceHierarchyLevel($view, $blockNameHierarchy, $hierarchyLevel); + + // The actually existing block name in $resource + $blockName = $blockNameHierarchy[$hierarchyLevel]; + + // Escape if no resource exists for this block + if (!$resource) { + if (\count($blockNameHierarchy) !== \count(array_unique($blockNameHierarchy))) { + throw new LogicException(sprintf('Unable to render the form because the block names array contains duplicates: "%s".', implode('", "', array_reverse($blockNameHierarchy)))); + } + + throw new LogicException(sprintf('Unable to render the form as none of the following blocks exist: "%s".', implode('", "', array_reverse($blockNameHierarchy)))); + } + + // Merge the passed with the existing attributes + if (isset($variables['attr']) && isset($scopeVariables['attr'])) { + $variables['attr'] = array_replace($scopeVariables['attr'], $variables['attr']); + } + + // Merge the passed with the exist *label* attributes + if (isset($variables['label_attr']) && isset($scopeVariables['label_attr'])) { + $variables['label_attr'] = array_replace($scopeVariables['label_attr'], $variables['label_attr']); + } + + // Do not use array_replace_recursive(), otherwise array variables + // cannot be overwritten + $variables = array_replace($scopeVariables, $variables); + + // In order to make recursive calls possible, we need to store the block hierarchy, + // the current level of the hierarchy and the variables so that this method can + // resume rendering one level higher of the hierarchy when it is called recursively. + // + // We need to store these values in maps (associative arrays) because within a + // call to widget() another call to widget() can be made, but for a different view + // object. These nested calls should not override each other. + $this->blockNameHierarchyMap[$viewAndSuffixCacheKey] = $blockNameHierarchy; + $this->hierarchyLevelMap[$viewAndSuffixCacheKey] = $hierarchyLevel; + + // We also need to store the variables for the view so that we can render other + // blocks for the same view using the same variables as in the outer block. + $this->variableStack[$viewCacheKey][] = $variables; + + // Do the rendering + $html = $this->engine->renderBlock($view, $resource, $blockName, $variables); + + // Clear the stack + array_pop($this->variableStack[$viewCacheKey]); + + // Clear the caches if they were filled for the first time within + // this function call + if ($hierarchyInit) { + unset($this->blockNameHierarchyMap[$viewAndSuffixCacheKey], $this->hierarchyLevelMap[$viewAndSuffixCacheKey]); + } + + if ($varInit) { + unset($this->variableStack[$viewCacheKey]); + } + + if ($renderOnlyOnce) { + $view->setRendered(); + } + + return $html; + } + + public function humanize(string $text): string + { + return ucfirst(strtolower(trim(preg_replace(['/([A-Z])/', '/[_\s]+/'], ['_$1', ' '], $text)))); + } + + /** + * @internal + */ + public function encodeCurrency(Environment $environment, string $text, string $widget = ''): string + { + if ('UTF-8' === $charset = $environment->getCharset()) { + $text = htmlspecialchars($text, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + } else { + $text = htmlentities($text, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + $text = iconv('UTF-8', $charset, $text); + $widget = iconv('UTF-8', $charset, $widget); + } + + return str_replace('{{ widget }}', $widget, $text); + } +} diff --git a/lib/symfony/form/FormRendererEngineInterface.php b/lib/symfony/form/FormRendererEngineInterface.php new file mode 100644 index 000000000..e7de3544a --- /dev/null +++ b/lib/symfony/form/FormRendererEngineInterface.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * Adapter for rendering form templates with a specific templating engine. + * + * @author Bernhard Schussek + */ +interface FormRendererEngineInterface +{ + /** + * Sets the theme(s) to be used for rendering a view and its children. + * + * @param FormView $view The view to assign the theme(s) to + * @param mixed $themes The theme(s). The type of these themes + * is open to the implementation. + * + * @return void + */ + public function setTheme(FormView $view, mixed $themes, bool $useDefaultThemes = true); + + /** + * Returns the resource for a block name. + * + * The resource is first searched in the themes attached to $view, then + * in the themes of its parent view and so on, until a resource was found. + * + * The type of the resource is decided by the implementation. The resource + * is later passed to {@link renderBlock()} by the rendering algorithm. + * + * @param FormView $view The view for determining the used themes. + * First the themes attached directly to the + * view with {@link setTheme()} are considered, + * then the ones of its parent etc. + * + * @return mixed the renderer resource or false, if none was found + */ + public function getResourceForBlockName(FormView $view, string $blockName): mixed; + + /** + * Returns the resource for a block hierarchy. + * + * A block hierarchy is an array which starts with the root of the hierarchy + * and continues with the child of that root, the child of that child etc. + * The following is an example for a block hierarchy: + * + * form_widget + * text_widget + * url_widget + * + * In this example, "url_widget" is the most specific block, while the other + * blocks are its ancestors in the hierarchy. + * + * The second parameter $hierarchyLevel determines the level of the hierarchy + * that should be rendered. For example, if $hierarchyLevel is 2 for the + * above hierarchy, the engine will first look for the block "url_widget", + * then, if that does not exist, for the block "text_widget" etc. + * + * The type of the resource is decided by the implementation. The resource + * is later passed to {@link renderBlock()} by the rendering algorithm. + * + * @param FormView $view The view for determining the used themes. + * First the themes attached directly to + * the view with {@link setTheme()} are + * considered, then the ones of its parent etc. + * @param string[] $blockNameHierarchy The block name hierarchy, with the root block + * at the beginning + * @param int $hierarchyLevel The level in the hierarchy at which to start + * looking. Level 0 indicates the root block, i.e. + * the first element of $blockNameHierarchy. + * + * @return mixed The renderer resource or false, if none was found + */ + public function getResourceForBlockNameHierarchy(FormView $view, array $blockNameHierarchy, int $hierarchyLevel): mixed; + + /** + * Returns the hierarchy level at which a resource can be found. + * + * A block hierarchy is an array which starts with the root of the hierarchy + * and continues with the child of that root, the child of that child etc. + * The following is an example for a block hierarchy: + * + * form_widget + * text_widget + * url_widget + * + * The second parameter $hierarchyLevel determines the level of the hierarchy + * that should be rendered. + * + * If we call this method with the hierarchy level 2, the engine will first + * look for a resource for block "url_widget". If such a resource exists, + * the method returns 2. Otherwise it tries to find a resource for block + * "text_widget" (at level 1) and, again, returns 1 if a resource was found. + * The method continues to look for resources until the root level was + * reached and nothing was found. In this case false is returned. + * + * The type of the resource is decided by the implementation. The resource + * is later passed to {@link renderBlock()} by the rendering algorithm. + * + * @param FormView $view The view for determining the used themes. + * First the themes attached directly to + * the view with {@link setTheme()} are + * considered, then the ones of its parent etc. + * @param string[] $blockNameHierarchy The block name hierarchy, with the root block + * at the beginning + * @param int $hierarchyLevel The level in the hierarchy at which to start + * looking. Level 0 indicates the root block, i.e. + * the first element of $blockNameHierarchy. + */ + public function getResourceHierarchyLevel(FormView $view, array $blockNameHierarchy, int $hierarchyLevel): int|false; + + /** + * Renders a block in the given renderer resource. + * + * The resource can be obtained by calling {@link getResourceForBlock()} + * or {@link getResourceForBlockHierarchy()}. The type of the resource is + * decided by the implementation. + * + * @param FormView $view The view to render + * @param mixed $resource The renderer resource + * @param array $variables The variables to pass to the template + * + * @return string + */ + public function renderBlock(FormView $view, mixed $resource, string $blockName, array $variables = []); +} diff --git a/lib/symfony/form/FormRendererInterface.php b/lib/symfony/form/FormRendererInterface.php new file mode 100644 index 000000000..8e805727c --- /dev/null +++ b/lib/symfony/form/FormRendererInterface.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * Renders a form into HTML. + * + * @author Bernhard Schussek + */ +interface FormRendererInterface +{ + /** + * Returns the engine used by this renderer. + */ + public function getEngine(): FormRendererEngineInterface; + + /** + * Sets the theme(s) to be used for rendering a view and its children. + * + * @param FormView $view The view to assign the theme(s) to + * @param mixed $themes The theme(s). The type of these themes + * is open to the implementation. + * @param bool $useDefaultThemes If true, will use default themes specified + * in the renderer + * + * @return void + */ + public function setTheme(FormView $view, mixed $themes, bool $useDefaultThemes = true); + + /** + * Renders a named block of the form theme. + * + * @param FormView $view The view for which to render the block + * @param array $variables The variables to pass to the template + */ + public function renderBlock(FormView $view, string $blockName, array $variables = []): string; + + /** + * Searches and renders a block for a given name suffix. + * + * The block is searched by combining the block names stored in the + * form view with the given suffix. If a block name is found, that + * block is rendered. + * + * If this method is called recursively, the block search is continued + * where a block was found before. + * + * @param FormView $view The view for which to render the block + * @param array $variables The variables to pass to the template + */ + public function searchAndRenderBlock(FormView $view, string $blockNameSuffix, array $variables = []): string; + + /** + * Renders a CSRF token. + * + * Use this helper for CSRF protection without the overhead of creating a + * form. + * + * + * + * Check the token in your action using the same token ID. + * + * // $csrfProvider being an instance of Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface + * if (!$csrfProvider->isCsrfTokenValid('rm_user_'.$user->getId(), $token)) { + * throw new \RuntimeException('CSRF attack detected.'); + * } + */ + public function renderCsrfToken(string $tokenId): string; + + /** + * Makes a technical name human readable. + * + * Sequences of underscores are replaced by single spaces. The first letter + * of the resulting string is capitalized, while all other letters are + * turned to lowercase. + */ + public function humanize(string $text): string; +} diff --git a/lib/symfony/form/FormTypeExtensionInterface.php b/lib/symfony/form/FormTypeExtensionInterface.php new file mode 100644 index 000000000..ae76457cd --- /dev/null +++ b/lib/symfony/form/FormTypeExtensionInterface.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Bernhard Schussek + */ +interface FormTypeExtensionInterface +{ + /** + * Gets the extended types. + * + * @return string[] + */ + public static function getExtendedTypes(): iterable; + + /** + * @return void + */ + public function configureOptions(OptionsResolver $resolver); + + /** + * Builds the form. + * + * This method is called after the extended type has built the form to + * further modify it. + * + * @param array $options + * + * @return void + * + * @see FormTypeInterface::buildForm() + */ + public function buildForm(FormBuilderInterface $builder, array $options); + + /** + * Builds the view. + * + * This method is called after the extended type has built the view to + * further modify it. + * + * @param array $options + * + * @return void + * + * @see FormTypeInterface::buildView() + */ + public function buildView(FormView $view, FormInterface $form, array $options); + + /** + * Finishes the view. + * + * This method is called after the extended type has finished the view to + * further modify it. + * + * @param array $options + * + * @return void + * + * @see FormTypeInterface::finishView() + */ + public function finishView(FormView $view, FormInterface $form, array $options); +} diff --git a/lib/symfony/form/FormTypeGuesserChain.php b/lib/symfony/form/FormTypeGuesserChain.php new file mode 100644 index 000000000..ed94ece6e --- /dev/null +++ b/lib/symfony/form/FormTypeGuesserChain.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\Guess\Guess; +use Symfony\Component\Form\Guess\TypeGuess; +use Symfony\Component\Form\Guess\ValueGuess; + +class FormTypeGuesserChain implements FormTypeGuesserInterface +{ + private array $guessers = []; + + /** + * @param FormTypeGuesserInterface[] $guessers + * + * @throws UnexpectedTypeException if any guesser does not implement FormTypeGuesserInterface + */ + public function __construct(iterable $guessers) + { + $tmpGuessers = []; + foreach ($guessers as $guesser) { + if (!$guesser instanceof FormTypeGuesserInterface) { + throw new UnexpectedTypeException($guesser, FormTypeGuesserInterface::class); + } + + if ($guesser instanceof self) { + $tmpGuessers[] = $guesser->guessers; + } else { + $tmpGuessers[] = [$guesser]; + } + } + + $this->guessers = array_merge([], ...$tmpGuessers); + } + + public function guessType(string $class, string $property): ?TypeGuess + { + return $this->guess(static fn ($guesser) => $guesser->guessType($class, $property)); + } + + public function guessRequired(string $class, string $property): ?ValueGuess + { + return $this->guess(static fn ($guesser) => $guesser->guessRequired($class, $property)); + } + + public function guessMaxLength(string $class, string $property): ?ValueGuess + { + return $this->guess(static fn ($guesser) => $guesser->guessMaxLength($class, $property)); + } + + public function guessPattern(string $class, string $property): ?ValueGuess + { + return $this->guess(static fn ($guesser) => $guesser->guessPattern($class, $property)); + } + + /** + * Executes a closure for each guesser and returns the best guess from the + * return values. + * + * @param \Closure $closure The closure to execute. Accepts a guesser + * as argument and should return a Guess instance + */ + private function guess(\Closure $closure): ?Guess + { + $guesses = []; + + foreach ($this->guessers as $guesser) { + if ($guess = $closure($guesser)) { + $guesses[] = $guess; + } + } + + return Guess::getBestGuess($guesses); + } +} diff --git a/lib/symfony/form/FormTypeGuesserInterface.php b/lib/symfony/form/FormTypeGuesserInterface.php new file mode 100644 index 000000000..54414b9f6 --- /dev/null +++ b/lib/symfony/form/FormTypeGuesserInterface.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * @author Bernhard Schussek + */ +interface FormTypeGuesserInterface +{ + /** + * Returns a field guess for a property name of a class. + * + * @return Guess\TypeGuess|null + */ + public function guessType(string $class, string $property); + + /** + * Returns a guess whether a property of a class is required. + * + * @return Guess\ValueGuess|null + */ + public function guessRequired(string $class, string $property); + + /** + * Returns a guess about the field's maximum length. + * + * @return Guess\ValueGuess|null + */ + public function guessMaxLength(string $class, string $property); + + /** + * Returns a guess about the field's pattern. + * + * @return Guess\ValueGuess|null + */ + public function guessPattern(string $class, string $property); +} diff --git a/lib/symfony/form/FormTypeInterface.php b/lib/symfony/form/FormTypeInterface.php new file mode 100644 index 000000000..2bc9f7711 --- /dev/null +++ b/lib/symfony/form/FormTypeInterface.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Bernhard Schussek + */ +interface FormTypeInterface +{ + /** + * Returns the name of the parent type. + * + * The parent type and its extensions will configure the form with the + * following methods before the current implementation. + * + * @return string|null + */ + public function getParent(); + + /** + * Configures the options for this type. + * + * @return void + */ + public function configureOptions(OptionsResolver $resolver); + + /** + * Builds the form. + * + * This method is called for each type in the hierarchy starting from the + * top most type. Type extensions can further modify the form. + * + * @param array $options + * + * @return void + * + * @see FormTypeExtensionInterface::buildForm() + */ + public function buildForm(FormBuilderInterface $builder, array $options); + + /** + * Builds the form view. + * + * This method is called for each type in the hierarchy starting from the + * top most type. Type extensions can further modify the view. + * + * A view of a form is built before the views of the child forms are built. + * This means that you cannot access child views in this method. If you need + * to do so, move your logic to {@link finishView()} instead. + * + * @param array $options + * + * @return void + * + * @see FormTypeExtensionInterface::buildView() + */ + public function buildView(FormView $view, FormInterface $form, array $options); + + /** + * Finishes the form view. + * + * This method gets called for each type in the hierarchy starting from the + * top most type. Type extensions can further modify the view. + * + * When this method is called, views of the form's children have already + * been built and finished and can be accessed. You should only implement + * such logic in this method that actually accesses child views. For everything + * else you are recommended to implement {@link buildView()} instead. + * + * @param array $options + * + * @return void + * + * @see FormTypeExtensionInterface::finishView() + */ + public function finishView(FormView $view, FormInterface $form, array $options); + + /** + * Returns the prefix of the template block name for this type. + * + * The block prefix defaults to the underscored short class name with + * the "Type" suffix removed (e.g. "UserProfileType" => "user_profile"). + * + * @return string + */ + public function getBlockPrefix(); +} diff --git a/lib/symfony/form/FormView.php b/lib/symfony/form/FormView.php new file mode 100644 index 000000000..a6fc1df62 --- /dev/null +++ b/lib/symfony/form/FormView.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\BadMethodCallException; + +/** + * @author Bernhard Schussek + * + * @implements \ArrayAccess + * @implements \IteratorAggregate + */ +class FormView implements \ArrayAccess, \IteratorAggregate, \Countable +{ + /** + * The variables assigned to this view. + */ + public $vars = [ + 'value' => null, + 'attr' => [], + ]; + + /** + * The parent view. + */ + public $parent; + + /** + * The child views. + * + * @var array + */ + public $children = []; + + /** + * Is the form attached to this renderer rendered? + * + * Rendering happens when either the widget or the row method was called. + * Row implicitly includes widget, however certain rendering mechanisms + * have to skip widget rendering when a row is rendered. + */ + private bool $rendered = false; + + private bool $methodRendered = false; + + public function __construct(?self $parent = null) + { + $this->parent = $parent; + } + + /** + * Returns whether the view was already rendered. + */ + public function isRendered(): bool + { + if (true === $this->rendered || 0 === \count($this->children)) { + return $this->rendered; + } + + foreach ($this->children as $child) { + if (!$child->isRendered()) { + return false; + } + } + + return $this->rendered = true; + } + + /** + * Marks the view as rendered. + * + * @return $this + */ + public function setRendered(): static + { + $this->rendered = true; + + return $this; + } + + public function isMethodRendered(): bool + { + return $this->methodRendered; + } + + /** + * @return void + */ + public function setMethodRendered() + { + $this->methodRendered = true; + } + + /** + * Returns a child by name (implements \ArrayAccess). + * + * @param int|string $name The child name + */ + public function offsetGet(mixed $name): self + { + return $this->children[$name]; + } + + /** + * Returns whether the given child exists (implements \ArrayAccess). + * + * @param int|string $name The child name + */ + public function offsetExists(mixed $name): bool + { + return isset($this->children[$name]); + } + + /** + * Implements \ArrayAccess. + * + * @throws BadMethodCallException always as setting a child by name is not allowed + */ + public function offsetSet(mixed $name, mixed $value): void + { + throw new BadMethodCallException('Not supported.'); + } + + /** + * Removes a child (implements \ArrayAccess). + * + * @param int|string $name The child name + */ + public function offsetUnset(mixed $name): void + { + unset($this->children[$name]); + } + + /** + * Returns an iterator to iterate over children (implements \IteratorAggregate). + * + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->children); + } + + public function count(): int + { + return \count($this->children); + } +} diff --git a/lib/symfony/form/Forms.php b/lib/symfony/form/Forms.php new file mode 100644 index 000000000..020e75eff --- /dev/null +++ b/lib/symfony/form/Forms.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * Entry point of the Form component. + * + * Use this class to conveniently create new form factories: + * + * use Symfony\Component\Form\Forms; + * + * $formFactory = Forms::createFormFactory(); + * + * $form = $formFactory->createBuilder() + * ->add('firstName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + * ->add('lastName', 'Symfony\Component\Form\Extension\Core\Type\TextType') + * ->add('age', 'Symfony\Component\Form\Extension\Core\Type\IntegerType') + * ->add('color', 'Symfony\Component\Form\Extension\Core\Type\ChoiceType', [ + * 'choices' => ['Red' => 'r', 'Blue' => 'b'], + * ]) + * ->getForm(); + * + * You can also add custom extensions to the form factory: + * + * $formFactory = Forms::createFormFactoryBuilder() + * ->addExtension(new AcmeExtension()) + * ->getFormFactory(); + * + * If you create custom form types or type extensions, it is + * generally recommended to create your own extensions that lazily + * load these types and type extensions. In projects where performance + * does not matter that much, you can also pass them directly to the + * form factory: + * + * $formFactory = Forms::createFormFactoryBuilder() + * ->addType(new PersonType()) + * ->addType(new PhoneNumberType()) + * ->addTypeExtension(new FormTypeHelpTextExtension()) + * ->getFormFactory(); + * + * Support for the Validator component is provided by ValidatorExtension. + * This extension needs a validator object to function properly: + * + * use Symfony\Component\Validator\Validation; + * use Symfony\Component\Form\Extension\Validator\ValidatorExtension; + * + * $validator = Validation::createValidator(); + * $formFactory = Forms::createFormFactoryBuilder() + * ->addExtension(new ValidatorExtension($validator)) + * ->getFormFactory(); + * + * @author Bernhard Schussek + */ +final class Forms +{ + /** + * Creates a form factory with the default configuration. + */ + public static function createFormFactory(): FormFactoryInterface + { + return self::createFormFactoryBuilder()->getFormFactory(); + } + + /** + * Creates a form factory builder with the default configuration. + */ + public static function createFormFactoryBuilder(): FormFactoryBuilderInterface + { + return new FormFactoryBuilder(true); + } + + /** + * This class cannot be instantiated. + */ + private function __construct() + { + } +} diff --git a/lib/symfony/form/Guess/Guess.php b/lib/symfony/form/Guess/Guess.php new file mode 100644 index 000000000..fc19ed9ce --- /dev/null +++ b/lib/symfony/form/Guess/Guess.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Guess; + +use Symfony\Component\Form\Exception\InvalidArgumentException; + +/** + * Base class for guesses made by TypeGuesserInterface implementation. + * + * Each instance contains a confidence value about the correctness of the guess. + * Thus an instance with confidence HIGH_CONFIDENCE is more likely to be + * correct than an instance with confidence LOW_CONFIDENCE. + * + * @author Bernhard Schussek + */ +abstract class Guess +{ + /** + * Marks an instance with a value that is extremely likely to be correct. + */ + public const VERY_HIGH_CONFIDENCE = 3; + + /** + * Marks an instance with a value that is very likely to be correct. + */ + public const HIGH_CONFIDENCE = 2; + + /** + * Marks an instance with a value that is likely to be correct. + */ + public const MEDIUM_CONFIDENCE = 1; + + /** + * Marks an instance with a value that may be correct. + */ + public const LOW_CONFIDENCE = 0; + + /** + * The confidence about the correctness of the value. + * + * One of VERY_HIGH_CONFIDENCE, HIGH_CONFIDENCE, MEDIUM_CONFIDENCE + * and LOW_CONFIDENCE. + */ + private int $confidence; + + /** + * Returns the guess most likely to be correct from a list of guesses. + * + * If there are multiple guesses with the same, highest confidence, the + * returned guess is any of them. + * + * @param static[] $guesses An array of guesses + */ + public static function getBestGuess(array $guesses): ?static + { + $result = null; + $maxConfidence = -1; + + foreach ($guesses as $guess) { + if ($maxConfidence < $confidence = $guess->getConfidence()) { + $maxConfidence = $confidence; + $result = $guess; + } + } + + return $result; + } + + /** + * @throws InvalidArgumentException if the given value of confidence is unknown + */ + public function __construct(int $confidence) + { + if (self::VERY_HIGH_CONFIDENCE !== $confidence && self::HIGH_CONFIDENCE !== $confidence + && self::MEDIUM_CONFIDENCE !== $confidence && self::LOW_CONFIDENCE !== $confidence) { + throw new InvalidArgumentException('The confidence should be one of the constants defined in Guess.'); + } + + $this->confidence = $confidence; + } + + /** + * Returns the confidence that the guessed value is correct. + * + * @return int One of the constants VERY_HIGH_CONFIDENCE, HIGH_CONFIDENCE, + * MEDIUM_CONFIDENCE and LOW_CONFIDENCE + */ + public function getConfidence(): int + { + return $this->confidence; + } +} diff --git a/lib/symfony/form/Guess/TypeGuess.php b/lib/symfony/form/Guess/TypeGuess.php new file mode 100644 index 000000000..8ede78eb8 --- /dev/null +++ b/lib/symfony/form/Guess/TypeGuess.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Guess; + +/** + * Contains a guessed class name and a list of options for creating an instance + * of that class. + * + * @author Bernhard Schussek + */ +class TypeGuess extends Guess +{ + private string $type; + private array $options; + + /** + * @param string $type The guessed field type + * @param array $options The options for creating instances of the + * guessed class + * @param int $confidence The confidence that the guessed class name + * is correct + */ + public function __construct(string $type, array $options, int $confidence) + { + parent::__construct($confidence); + + $this->type = $type; + $this->options = $options; + } + + /** + * Returns the guessed field type. + */ + public function getType(): string + { + return $this->type; + } + + /** + * Returns the guessed options for creating instances of the guessed type. + */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/lib/symfony/form/Guess/ValueGuess.php b/lib/symfony/form/Guess/ValueGuess.php new file mode 100644 index 000000000..36abe6602 --- /dev/null +++ b/lib/symfony/form/Guess/ValueGuess.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Guess; + +/** + * Contains a guessed value. + * + * @author Bernhard Schussek + */ +class ValueGuess extends Guess +{ + private string|int|bool|null $value; + + /** + * @param int $confidence The confidence that the guessed class name is correct + */ + public function __construct(string|int|bool|null $value, int $confidence) + { + parent::__construct($confidence); + + $this->value = $value; + } + + /** + * Returns the guessed value. + */ + public function getValue(): string|int|bool|null + { + return $this->value; + } +} diff --git a/lib/symfony/form/LICENSE b/lib/symfony/form/LICENSE new file mode 100644 index 000000000..0138f8f07 --- /dev/null +++ b/lib/symfony/form/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/symfony/form/NativeRequestHandler.php b/lib/symfony/form/NativeRequestHandler.php new file mode 100644 index 000000000..7c9964e5e --- /dev/null +++ b/lib/symfony/form/NativeRequestHandler.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\Util\FormUtil; +use Symfony\Component\Form\Util\ServerParams; + +/** + * A request handler using PHP super globals $_GET, $_POST and $_SERVER. + * + * @author Bernhard Schussek + */ +class NativeRequestHandler implements RequestHandlerInterface +{ + private ServerParams $serverParams; + + /** + * The allowed keys of the $_FILES array. + */ + private const FILE_KEYS = [ + 'error', + 'name', + 'size', + 'tmp_name', + 'type', + ]; + + public function __construct(?ServerParams $params = null) + { + $this->serverParams = $params ?? new ServerParams(); + } + + /** + * @return void + * + * @throws Exception\UnexpectedTypeException If the $request is not null + */ + public function handleRequest(FormInterface $form, mixed $request = null) + { + if (null !== $request) { + throw new UnexpectedTypeException($request, 'null'); + } + + $name = $form->getName(); + $method = $form->getConfig()->getMethod(); + + if ($method !== self::getRequestMethod()) { + return; + } + + // For request methods that must not have a request body we fetch data + // from the query string. Otherwise we look for data in the request body. + if ('GET' === $method || 'HEAD' === $method || 'TRACE' === $method) { + if ('' === $name) { + $data = $_GET; + } else { + // Don't submit GET requests if the form's name does not exist + // in the request + if (!isset($_GET[$name])) { + return; + } + + $data = $_GET[$name]; + } + } else { + // Mark the form with an error if the uploaded size was too large + // This is done here and not in FormValidator because $_POST is + // empty when that error occurs. Hence the form is never submitted. + if ($this->serverParams->hasPostMaxSizeBeenExceeded()) { + // Submit the form, but don't clear the default values + $form->submit(null, false); + + $form->addError(new FormError( + $form->getConfig()->getOption('upload_max_size_message')(), + null, + ['{{ max }}' => $this->serverParams->getNormalizedIniPostMaxSize()] + )); + + return; + } + + $fixedFiles = []; + foreach ($_FILES as $fileKey => $file) { + $fixedFiles[$fileKey] = self::stripEmptyFiles(self::fixPhpFilesArray($file)); + } + + if ('' === $name) { + $params = $_POST; + $files = $fixedFiles; + } elseif (\array_key_exists($name, $_POST) || \array_key_exists($name, $fixedFiles)) { + $default = $form->getConfig()->getCompound() ? [] : null; + $params = \array_key_exists($name, $_POST) ? $_POST[$name] : $default; + $files = \array_key_exists($name, $fixedFiles) ? $fixedFiles[$name] : $default; + } else { + // Don't submit the form if it is not present in the request + return; + } + + if (\is_array($params) && \is_array($files)) { + $data = FormUtil::mergeParamsAndFiles($params, $files); + } else { + $data = $params ?: $files; + } + } + + // Don't auto-submit the form unless at least one field is present. + if ('' === $name && \count(array_intersect_key($data, $form->all())) <= 0) { + return; + } + + if (\is_array($data) && \array_key_exists('_method', $data) && $method === $data['_method'] && !$form->has('_method')) { + unset($data['_method']); + } + + $form->submit($data, 'PATCH' !== $method); + } + + public function isFileUpload(mixed $data): bool + { + // POST data will always be strings or arrays of strings. Thus, we can be sure + // that the submitted data is a file upload if the "error" value is an integer + // (this value must have been injected by PHP itself). + return \is_array($data) && isset($data['error']) && \is_int($data['error']); + } + + public function getUploadFileError(mixed $data): ?int + { + if (!\is_array($data)) { + return null; + } + + if (!isset($data['error'])) { + return null; + } + + if (!\is_int($data['error'])) { + return null; + } + + if (\UPLOAD_ERR_OK === $data['error']) { + return null; + } + + return $data['error']; + } + + private static function getRequestMethod(): string + { + $method = isset($_SERVER['REQUEST_METHOD']) + ? strtoupper($_SERVER['REQUEST_METHOD']) + : 'GET'; + + if ('POST' === $method && isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) { + $method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']); + } + + return $method; + } + + /** + * Fixes a malformed PHP $_FILES array. + * + * PHP has a bug that the format of the $_FILES array differs, depending on + * whether the uploaded file fields had normal field names or array-like + * field names ("normal" vs. "parent[child]"). + * + * This method fixes the array to look like the "normal" $_FILES array. + * + * It's safe to pass an already converted array, in which case this method + * just returns the original array unmodified. + * + * This method is identical to {@link \Symfony\Component\HttpFoundation\FileBag::fixPhpFilesArray} + * and should be kept as such in order to port fixes quickly and easily. + */ + private static function fixPhpFilesArray(mixed $data): mixed + { + if (!\is_array($data)) { + return $data; + } + + // Remove extra key added by PHP 8.1. + unset($data['full_path']); + $keys = array_keys($data); + sort($keys); + + if (self::FILE_KEYS !== $keys || !isset($data['name']) || !\is_array($data['name'])) { + return $data; + } + + $files = $data; + foreach (self::FILE_KEYS as $k) { + unset($files[$k]); + } + + foreach ($data['name'] as $key => $name) { + $files[$key] = self::fixPhpFilesArray([ + 'error' => $data['error'][$key], + 'name' => $name, + 'type' => $data['type'][$key], + 'tmp_name' => $data['tmp_name'][$key], + 'size' => $data['size'][$key], + ]); + } + + return $files; + } + + private static function stripEmptyFiles(mixed $data): mixed + { + if (!\is_array($data)) { + return $data; + } + + $keys = array_keys($data); + sort($keys); + + if (self::FILE_KEYS === $keys) { + if (\UPLOAD_ERR_NO_FILE === $data['error']) { + return null; + } + + return $data; + } + + foreach ($data as $key => $value) { + $data[$key] = self::stripEmptyFiles($value); + } + + return $data; + } +} diff --git a/lib/symfony/form/PreloadedExtension.php b/lib/symfony/form/PreloadedExtension.php new file mode 100644 index 000000000..298186a75 --- /dev/null +++ b/lib/symfony/form/PreloadedExtension.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; + +use Symfony\Component\Form\Exception\InvalidArgumentException; + +/** + * A form extension with preloaded types, type extensions and type guessers. + * + * @author Bernhard Schussek + */ +class PreloadedExtension implements FormExtensionInterface +{ + private array $types = []; + private array $typeExtensions = []; + private ?FormTypeGuesserInterface $typeGuesser; + + /** + * Creates a new preloaded extension. + * + * @param FormTypeInterface[] $types The types that the extension should support + * @param FormTypeExtensionInterface[][] $typeExtensions The type extensions that the extension should support + */ + public function __construct(array $types, array $typeExtensions, ?FormTypeGuesserInterface $typeGuesser = null) + { + $this->typeExtensions = $typeExtensions; + $this->typeGuesser = $typeGuesser; + + foreach ($types as $type) { + $this->types[$type::class] = $type; + } + } + + public function getType(string $name): FormTypeInterface + { + if (!isset($this->types[$name])) { + throw new InvalidArgumentException(sprintf('The type "%s" cannot be loaded by this extension.', $name)); + } + + return $this->types[$name]; + } + + public function hasType(string $name): bool + { + return isset($this->types[$name]); + } + + public function getTypeExtensions(string $name): array + { + return $this->typeExtensions[$name] + ?? []; + } + + public function hasTypeExtensions(string $name): bool + { + return !empty($this->typeExtensions[$name]); + } + + public function getTypeGuesser(): ?FormTypeGuesserInterface + { + return $this->typeGuesser; + } +} diff --git a/lib/symfony/form/README.md b/lib/symfony/form/README.md new file mode 100644 index 000000000..0cda654d7 --- /dev/null +++ b/lib/symfony/form/README.md @@ -0,0 +1,13 @@ +Form Component +============== + +The Form component allows you to easily create, process and reuse HTML forms. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/form.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/lib/symfony/form/RequestHandlerInterface.php b/lib/symfony/form/RequestHandlerInterface.php new file mode 100644 index 000000000..39fd458ee --- /dev/null +++ b/lib/symfony/form/RequestHandlerInterface.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; + +/** + * Submits forms if they were submitted. + * + * @author Bernhard Schussek + */ +interface RequestHandlerInterface +{ + /** + * Submits a form if it was submitted. + * + * @return void + */ + public function handleRequest(FormInterface $form, mixed $request = null); + + /** + * Returns true if the given data is a file upload. + */ + public function isFileUpload(mixed $data): bool; +} diff --git a/lib/symfony/form/ResolvedFormType.php b/lib/symfony/form/ResolvedFormType.php new file mode 100644 index 000000000..e2b05e8e0 --- /dev/null +++ b/lib/symfony/form/ResolvedFormType.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\OptionsResolver\Exception\ExceptionInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * A wrapper for a form type and its extensions. + * + * @author Bernhard Schussek + */ +class ResolvedFormType implements ResolvedFormTypeInterface +{ + private FormTypeInterface $innerType; + + /** + * @var FormTypeExtensionInterface[] + */ + private array $typeExtensions; + + private ?ResolvedFormTypeInterface $parent; + + private OptionsResolver $optionsResolver; + + /** + * @param FormTypeExtensionInterface[] $typeExtensions + */ + public function __construct(FormTypeInterface $innerType, array $typeExtensions = [], ?ResolvedFormTypeInterface $parent = null) + { + foreach ($typeExtensions as $extension) { + if (!$extension instanceof FormTypeExtensionInterface) { + throw new UnexpectedTypeException($extension, FormTypeExtensionInterface::class); + } + } + + $this->innerType = $innerType; + $this->typeExtensions = $typeExtensions; + $this->parent = $parent; + } + + public function getBlockPrefix(): string + { + return $this->innerType->getBlockPrefix(); + } + + public function getParent(): ?ResolvedFormTypeInterface + { + return $this->parent; + } + + public function getInnerType(): FormTypeInterface + { + return $this->innerType; + } + + public function getTypeExtensions(): array + { + return $this->typeExtensions; + } + + public function createBuilder(FormFactoryInterface $factory, string $name, array $options = []): FormBuilderInterface + { + try { + $options = $this->getOptionsResolver()->resolve($options); + } catch (ExceptionInterface $e) { + throw new $e(sprintf('An error has occurred resolving the options of the form "%s": ', get_debug_type($this->getInnerType())).$e->getMessage(), $e->getCode(), $e); + } + + // Should be decoupled from the specific option at some point + $dataClass = $options['data_class'] ?? null; + + $builder = $this->newBuilder($name, $dataClass, $factory, $options); + $builder->setType($this); + + return $builder; + } + + public function createView(FormInterface $form, ?FormView $parent = null): FormView + { + return $this->newView($parent); + } + + /** + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $this->parent?->buildForm($builder, $options); + + $this->innerType->buildForm($builder, $options); + + foreach ($this->typeExtensions as $extension) { + $extension->buildForm($builder, $options); + } + } + + /** + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options) + { + $this->parent?->buildView($view, $form, $options); + + $this->innerType->buildView($view, $form, $options); + + foreach ($this->typeExtensions as $extension) { + $extension->buildView($view, $form, $options); + } + } + + /** + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options) + { + $this->parent?->finishView($view, $form, $options); + + $this->innerType->finishView($view, $form, $options); + + foreach ($this->typeExtensions as $extension) { + /* @var FormTypeExtensionInterface $extension */ + $extension->finishView($view, $form, $options); + } + } + + public function getOptionsResolver(): OptionsResolver + { + if (!isset($this->optionsResolver)) { + if (null !== $this->parent) { + $this->optionsResolver = clone $this->parent->getOptionsResolver(); + } else { + $this->optionsResolver = new OptionsResolver(); + } + + $this->innerType->configureOptions($this->optionsResolver); + + foreach ($this->typeExtensions as $extension) { + $extension->configureOptions($this->optionsResolver); + } + } + + return $this->optionsResolver; + } + + /** + * Creates a new builder instance. + * + * Override this method if you want to customize the builder class. + */ + protected function newBuilder(string $name, ?string $dataClass, FormFactoryInterface $factory, array $options): FormBuilderInterface + { + if ($this->innerType instanceof ButtonTypeInterface) { + return new ButtonBuilder($name, $options); + } + + if ($this->innerType instanceof SubmitButtonTypeInterface) { + return new SubmitButtonBuilder($name, $options); + } + + return new FormBuilder($name, $dataClass, new EventDispatcher(), $factory, $options); + } + + /** + * Creates a new view instance. + * + * Override this method if you want to customize the view class. + */ + protected function newView(?FormView $parent = null): FormView + { + return new FormView($parent); + } +} diff --git a/lib/symfony/form/ResolvedFormTypeFactory.php b/lib/symfony/form/ResolvedFormTypeFactory.php new file mode 100644 index 000000000..437f9c553 --- /dev/null +++ b/lib/symfony/form/ResolvedFormTypeFactory.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\Form; + +/** + * @author Bernhard Schussek + */ +class ResolvedFormTypeFactory implements ResolvedFormTypeFactoryInterface +{ + public function createResolvedType(FormTypeInterface $type, array $typeExtensions, ?ResolvedFormTypeInterface $parent = null): ResolvedFormTypeInterface + { + return new ResolvedFormType($type, $typeExtensions, $parent); + } +} diff --git a/lib/symfony/form/ResolvedFormTypeFactoryInterface.php b/lib/symfony/form/ResolvedFormTypeFactoryInterface.php new file mode 100644 index 000000000..9fd39e7fe --- /dev/null +++ b/lib/symfony/form/ResolvedFormTypeFactoryInterface.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; + +/** + * Creates ResolvedFormTypeInterface instances. + * + * This interface allows you to use your custom ResolvedFormTypeInterface + * implementation, within which you can customize the concrete FormBuilderInterface + * implementations or FormView subclasses that are used by the framework. + * + * @author Bernhard Schussek + */ +interface ResolvedFormTypeFactoryInterface +{ + /** + * Resolves a form type. + * + * @param FormTypeExtensionInterface[] $typeExtensions + * + * @throws Exception\UnexpectedTypeException if the types parent {@link FormTypeInterface::getParent()} is not a string + * @throws Exception\InvalidArgumentException if the types parent cannot be retrieved from any extension + */ + public function createResolvedType(FormTypeInterface $type, array $typeExtensions, ?ResolvedFormTypeInterface $parent = null): ResolvedFormTypeInterface; +} diff --git a/lib/symfony/form/ResolvedFormTypeInterface.php b/lib/symfony/form/ResolvedFormTypeInterface.php new file mode 100644 index 000000000..e6f67ed40 --- /dev/null +++ b/lib/symfony/form/ResolvedFormTypeInterface.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * A wrapper for a form type and its extensions. + * + * @author Bernhard Schussek + */ +interface ResolvedFormTypeInterface +{ + /** + * Returns the prefix of the template block name for this type. + */ + public function getBlockPrefix(): string; + + /** + * Returns the parent type. + */ + public function getParent(): ?self; + + /** + * Returns the wrapped form type. + */ + public function getInnerType(): FormTypeInterface; + + /** + * Returns the extensions of the wrapped form type. + * + * @return FormTypeExtensionInterface[] + */ + public function getTypeExtensions(): array; + + /** + * Creates a new form builder for this type. + * + * @param string $name The name for the builder + */ + public function createBuilder(FormFactoryInterface $factory, string $name, array $options = []): FormBuilderInterface; + + /** + * Creates a new form view for a form of this type. + */ + public function createView(FormInterface $form, ?FormView $parent = null): FormView; + + /** + * Configures a form builder for the type hierarchy. + * + * @return void + */ + public function buildForm(FormBuilderInterface $builder, array $options); + + /** + * Configures a form view for the type hierarchy. + * + * It is called before the children of the view are built. + * + * @return void + */ + public function buildView(FormView $view, FormInterface $form, array $options); + + /** + * Finishes a form view for the type hierarchy. + * + * It is called after the children of the view have been built. + * + * @return void + */ + public function finishView(FormView $view, FormInterface $form, array $options); + + /** + * Returns the configured options resolver used for this type. + */ + public function getOptionsResolver(): OptionsResolver; +} diff --git a/lib/symfony/form/Resources/config/validation.xml b/lib/symfony/form/Resources/config/validation.xml new file mode 100644 index 000000000..918f101f4 --- /dev/null +++ b/lib/symfony/form/Resources/config/validation.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/lib/symfony/form/Resources/translations/validators.af.xlf b/lib/symfony/form/Resources/translations/validators.af.xlf new file mode 100644 index 000000000..c726e93b9 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.af.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Hierdie vorm moet nie ekstra velde bevat nie. + + + The uploaded file was too large. Please try to upload a smaller file. + Die opgelaaide lêer was te groot. Probeer asseblief 'n kleiner lêer. + + + The CSRF token is invalid. Please try to resubmit the form. + Die CSRF-teken is ongeldig. Probeer asseblief om die vorm weer in te dien. + + + This value is not a valid HTML5 color. + Hierdie waarde is nie 'n geldige HTML5 kleur nie. + + + Please enter a valid birthdate. + Voer asseblief 'n geldige geboortedatum in. + + + The selected choice is invalid. + Die gekiesde opsie is nie geldig nie. + + + The collection is invalid. + Die versameling is nie geldig nie. + + + Please select a valid color. + Kies asseblief 'n geldige kleur. + + + Please select a valid country. + Kies asseblief 'n geldige land. + + + Please select a valid currency. + Kies asseblief 'n geldige geldeenheid. + + + Please choose a valid date interval. + Kies asseblief 'n geldige datum interval. + + + Please enter a valid date and time. + Voer asseblilef 'n geldige datum en tyd in. + + + Please enter a valid date. + Voer asseblief 'n geldige datum in. + + + Please select a valid file. + Kies asseblief 'n geldige lêer. + + + The hidden field is invalid. + Die versteekte veld is nie geldig nie. + + + Please enter an integer. + Voer asseblief 'n geldige heeltal in. + + + Please select a valid language. + Kies assblief 'n geldige taal. + + + Please select a valid locale. + Voer assebliefn 'n geldige locale in. + + + Please enter a valid money amount. + Voer asseblief 'n geldige bedrag in. + + + Please enter a number. + Voer asseblief 'n nommer in. + + + The password is invalid. + Die wagwoord is ongeldig. + + + Please enter a percentage value. + Voer asseblief 'n geldige persentasie waarde in. + + + The values do not match. + Die waardes is nie dieselfde nie. + + + Please enter a valid time. + Voer asseblief 'n geldige tyd in time. + + + Please select a valid timezone. + Kies asseblief 'n geldige tydsone. + + + Please enter a valid URL. + Voer asseblief 'n geldige URL in. + + + Please enter a valid search term. + Voer asseblief 'n geldige soek term in. + + + Please provide a valid phone number. + Verskaf asseblief 'n geldige telefoonnommer. + + + The checkbox has an invalid value. + Die blokkie het 'n ongeldige waarde. + + + Please enter a valid email address. + Voer asseblief 'n geldige e-pos adres in. + + + Please select a valid option. + Kies asseblief 'n geldige opsie. + + + Please select a valid range. + Kies asseblief 'n geldige reeks. + + + Please enter a valid week. + Voer assblief 'n geldige week in. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.ar.xlf b/lib/symfony/form/Resources/translations/validators.ar.xlf new file mode 100644 index 000000000..d18b4691e --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.ar.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + هذا النموذج يجب الا يحتوى على اى حقول اضافية. + + + The uploaded file was too large. Please try to upload a smaller file. + مساحة الملف المرسل كبيرة. من فضلك حاول ارسال ملف اصغر. + + + The CSRF token is invalid. Please try to resubmit the form. + قيمة رمز الموقع غير صحيحة. من فضلك اعد ارسال النموذج. + + + This value is not a valid HTML5 color. + هذه القيمة ليست لون HTML5 صالحًا. + + + Please enter a valid birthdate. + الرجاء ادخال تاريخ ميلاد صالح. + + + The selected choice is invalid. + الاختيار المحدد غير صالح. + + + The collection is invalid. + المجموعة غير صالحة. + + + Please select a valid color. + الرجاء اختيار لون صالح. + + + Please select a valid country. + الرجاء اختيار بلد صالح. + + + Please select a valid currency. + الرجاء اختيار عملة صالحة. + + + Please choose a valid date interval. + الرجاء اختيار فاصل زمني صالح. + + + Please enter a valid date and time. + الرجاء إدخال تاريخ ووقت صالحين. + + + Please enter a valid date. + الرجاء إدخال تاريخ صالح. + + + Please select a valid file. + الرجاء اختيار ملف صالح. + + + The hidden field is invalid. + الحقل المخفي غير صالح. + + + Please enter an integer. + الرجاء إدخال عدد صحيح. + + + Please select a valid language. + الرجاء اختيار لغة صالحة. + + + Please select a valid locale. + الرجاء اختيار لغة صالحة. + + + Please enter a valid money amount. + الرجاء إدخال مبلغ مالي صالح. + + + Please enter a number. + الرجاء إدخال رقم. + + + The password is invalid. + كلمة المرور غير صحيحة. + + + Please enter a percentage value. + الرجاء إدخال قيمة النسبة المئوية. + + + The values do not match. + القيم لا تتطابق. + + + Please enter a valid time. + الرجاء إدخال وقت صالح. + + + Please select a valid timezone. + الرجاء تحديد منطقة زمنية صالحة. + + + Please enter a valid URL. + أدخل عنوان الرابط صحيح من فضلك. + + + Please enter a valid search term. + الرجاء إدخال مصطلح البحث ساري المفعول. + + + Please provide a valid phone number. + يرجى تقديم رقم هاتف صالح. + + + The checkbox has an invalid value. + خانة الاختيار لها قيمة غير صالحة. + + + Please enter a valid email address. + رجاء قم بإدخال بريد الكتروني صحيح + + + Please select a valid option. + الرجاء تحديد خيار صالح. + + + Please select a valid range. + يرجى تحديد نطاق صالح. + + + Please enter a valid week. + الرجاء إدخال أسبوع صالح. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.az.xlf b/lib/symfony/form/Resources/translations/validators.az.xlf new file mode 100644 index 000000000..87791b6d4 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.az.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Bu formada əlavə sahə olmamalıdır. + + + The uploaded file was too large. Please try to upload a smaller file. + Yüklənən fayl çox böyükdür. Lütfən daha kiçik fayl yükləyin. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF nişanı yanlışdır. Lütfen formanı yenidən göndərin. + + + This value is not a valid HTML5 color. + Bu dəyər doğru bir HTML5 rəngi deyil. + + + Please enter a valid birthdate. + Zəhmət olmasa doğru bir doğum günü daxil edin. + + + The selected choice is invalid. + Seçilmiş seçim doğru deyil. + + + The collection is invalid. + Kolleksiya doğru deyil. + + + Please select a valid color. + Zəhmət olmasa doğru bir rəng seçin. + + + Please select a valid country. + Zəhmət olmasa doğru bir ölkə seçin. + + + Please select a valid currency. + Zəhmət olmasa doğru bir valyuta seçin. + + + Please choose a valid date interval. + Zəhmət olmasa doğru bir tarix aralığı seçin. + + + Please enter a valid date and time. + Zəhmət olmasa doğru bir tarix ve saat daxil edin. + + + Please enter a valid date. + Zəhmət olmasa doğru bir tarix daxil edin. + + + Please select a valid file. + Zəhmət olmasa doğru bir fayl seçin. + + + The hidden field is invalid. + Gizli sahə doğru deyil. + + + Please enter an integer. + Zəhmət olmasa bir tam ədəd daxil edin. + + + Please select a valid language. + Zəhmət olmasa doğru bir dil seçin. + + + Please select a valid locale. + Zəhmət olmasa doğru bir yer seçin. + + + Please enter a valid money amount. + Zəhmət olmasa doğru bir pul miqdarı daxil edin. + + + Please enter a number. + Zəhmət olmasa doğru bir rəqəm daxil edin. + + + The password is invalid. + Parol doğru deyil. + + + Please enter a percentage value. + Zəhmət olmasa doğru bir faiz dəyəri daxil edin. + + + The values do not match. + Dəyərlər örtüşmür. + + + Please enter a valid time. + Zəhmət olmasa doğru bir saat daxil edin. + + + Please select a valid timezone. + Zəhmət olmasa doğru bir saat qurşağı seçin. + + + Please enter a valid URL. + Zəhmət olmasa doğru bir URL daxil edin. + + + Please enter a valid search term. + Zəhmət olmasa doğru bir axtarış termini daxil edin. + + + Please provide a valid phone number. + Zəhmət olmasa doğru bir telefon nömrəsi seçin. + + + The checkbox has an invalid value. + Seçim qutusunda doğru olmayan dəyər var. + + + Please enter a valid email address. + Zəhmət olmasa doğru bir e-poçt seçin. + + + Please select a valid option. + Zəhmət olmasa doğru bir variant seçin. + + + Please select a valid range. + Zəhmət olmasa doğru bir aralıq seçin. + + + Please enter a valid week. + Zəhmət olmasa doğru bir həftə seçin. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.be.xlf b/lib/symfony/form/Resources/translations/validators.be.xlf new file mode 100644 index 000000000..b24976e13 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.be.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Гэта форма не павінна мець дадатковых палей. + + + The uploaded file was too large. Please try to upload a smaller file. + Запампаваны файл быў занадта вялікім. Калі ласка, паспрабуйце запампаваць файл меншага памеру. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-токен не сапраўдны. Калі ласка, паспрабуйце яшчэ раз адправіць форму. + + + This value is not a valid HTML5 color. + Значэнне не з'яўляецца карэктным HTML5 колерам. + + + Please enter a valid birthdate. + Калі ласка, увядзіце карэктную дату нараджэння. + + + The selected choice is invalid. + Выбраны варыянт некарэктны. + + + The collection is invalid. + Калекцыя некарэктна. + + + Please select a valid color. + Калі ласка, выберыце карэктны колер. + + + Please select a valid country. + Калі ласка, выберыце карэктную краіну. + + + Please select a valid currency. + Калі ласка, выберыце карэктную валюту. + + + Please choose a valid date interval. + Калі ласка, выберыце карэктны інтэрвал дат. + + + Please enter a valid date and time. + Калі ласка, увядзіце карэктныя дату і час. + + + Please enter a valid date. + Калі ласка, увядзіце карэктную дату. + + + Please select a valid file. + Калі ласка, выберыце карэктны файл. + + + The hidden field is invalid. + Значэнне схаванага поля некарэктна. + + + Please enter an integer. + Калі ласка, увядзіце цэлы лік. + + + Please select a valid language. + Калі ласка, выберыце карэктную мову. + + + Please select a valid locale. + Калі ласка, выберыце карэктную лакаль. + + + Please enter a valid money amount. + Калі ласка, увядзіце карэктную колькасць грошай. + + + Please enter a number. + Калі ласка, увядзіце нумар. + + + The password is invalid. + Няправільны пароль. + + + Please enter a percentage value. + Калі ласка, увядзіце працэнтнае значэнне. + + + The values do not match. + Значэнні не супадаюць. + + + Please enter a valid time. + Калі ласка, увядзіце карэктны час. + + + Please select a valid timezone. + Калі ласка, выберыце карэктны гадзінны пояс. + + + Please enter a valid URL. + Калі ласка, увядзіце карэктны URL. + + + Please enter a valid search term. + Калі ласка, увядзіце карэктны пошукавы запыт. + + + Please provide a valid phone number. + Калі ласка, увядзіце карэктны нумар тэлефона. + + + The checkbox has an invalid value. + Флажок мае некарэктнае значэнне. + + + Please enter a valid email address. + Калі ласка, увядзіце карэктны адрас электроннай пошты. + + + Please select a valid option. + Калі ласка, выберыце карэктны варыянт. + + + Please select a valid range. + Калі ласка, выберыце карэктны дыяпазон. + + + Please enter a valid week. + Калі ласка, увядзіце карэктны тыдзень. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.bg.xlf b/lib/symfony/form/Resources/translations/validators.bg.xlf new file mode 100644 index 000000000..19b80f5f8 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.bg.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Тази форма не трябва да съдържа допълнителни полета. + + + The uploaded file was too large. Please try to upload a smaller file. + Каченият файл е твърде голям. Моля, опитайте да качите по-малък файл. + + + The CSRF token is invalid. Please try to resubmit the form. + Невалиден CSRF токен. Моля, опитайте да изпратите формата отново. + + + This value is not a valid HTML5 color. + Стойността не е валиден HTML5 цвят. + + + Please enter a valid birthdate. + Моля въведете валидна дата на раждане. + + + The selected choice is invalid. + Избраните стойности не са валидни. + + + The collection is invalid. + Колекцията не е валидна. + + + Please select a valid color. + Моля изберете валиден цвят. + + + Please select a valid country. + Моля изберете валидна държава. + + + Please select a valid currency. + Моля изберете валидна валута. + + + Please choose a valid date interval. + Моля изберете валиден интервал от дати. + + + Please enter a valid date and time. + Моля въведете валидни дата и час. + + + Please enter a valid date. + Моля въведете валидна дата. + + + Please select a valid file. + Моля изберете валиден файл. + + + The hidden field is invalid. + Скритото поле е невалидно. + + + Please enter an integer. + Моля попълнете цяло число. + + + Please select a valid language. + Моля изберете валиден език. + + + Please select a valid locale. + Моля изберете валиден език. + + + Please enter a valid money amount. + Моля въведете валидна парична сума. + + + Please enter a number. + Моля въведете число. + + + The password is invalid. + Паролата е невалидна. + + + Please enter a percentage value. + Моля въведете процентна стойност. + + + The values do not match. + Стойностите не съвпадат. + + + Please enter a valid time. + Моля въведете валидно време. + + + Please select a valid timezone. + Моля изберете валидна часова зона. + + + Please enter a valid URL. + Моля въведете валиден URL. + + + Please enter a valid search term. + Моля въведете валидно търсене. + + + Please provide a valid phone number. + Моля осигурете валиден телефонен номер. + + + The checkbox has an invalid value. + Отметката има невалидна стойност. + + + Please enter a valid email address. + Моля въведете валидна ел. поща. + + + Please select a valid option. + Моля изберете валидна опция. + + + Please select a valid range. + Моля изберете валиден обхват. + + + Please enter a valid week. + Моля въведете валидна седмица. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.bs.xlf b/lib/symfony/form/Resources/translations/validators.bs.xlf new file mode 100644 index 000000000..d360635df --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.bs.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ovaj obrazac ne bi trebalo da sadrži dodatna polja. + + + The uploaded file was too large. Please try to upload a smaller file. + Prenijeta (uploaded) datoteka je prevelika. Molim pokušajte prenijeti manju datoteku. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF vrijednost nije ispravna. Molim pokušajte ponovo da pošaljete obrazac. + + + This value is not a valid HTML5 color. + Ova vrijednost nije važeća HTML5 boja. + + + Please enter a valid birthdate. + Molim upišite ispravan datum rođenja. + + + The selected choice is invalid. + Odabrani izbor nije ispravan. + + + The collection is invalid. + Ova kolekcija nije ispravna. + + + Please select a valid color. + Molim izaberite ispravnu boju. + + + Please select a valid country. + Molim izaberite ispravnu državu. + + + Please select a valid currency. + Molim izaberite ispravnu valutu. + + + Please choose a valid date interval. + Molim izaberite ispravan datumski interval. + + + Please enter a valid date and time. + Molim upišite ispravan datum i vrijeme. + + + Please enter a valid date. + Molim upišite ispravan datum. + + + Please select a valid file. + Molim izaberite ispravnu datoteku. + + + The hidden field is invalid. + Skriveno polje nije ispravno. + + + Please enter an integer. + Molim upišite cijeli broj (integer). + + + Please select a valid language. + Molim izaberite ispravan jezik. + + + Please select a valid locale. + Molim izaberite ispravnu lokalizaciju. + + + Please enter a valid money amount. + Molim upišite ispravnu količinu novca. + + + Please enter a number. + Molim upišite broj. + + + The password is invalid. + Ova lozinka nije ispravna. + + + Please enter a percentage value. + Molim upišite procentualnu vrijednost. + + + The values do not match. + Date vrijednosti se ne poklapaju. + + + Please enter a valid time. + Molim upišite ispravno vrijeme. + + + Please select a valid timezone. + Molim izaberite ispravnu vremensku zonu. + + + Please enter a valid URL. + Molim upišite ispravan URL. + + + Please enter a valid search term. + Molim upišite ispravan termin za pretragu. + + + Please provide a valid phone number. + Molim navedite ispravan broj telefona. + + + The checkbox has an invalid value. + Polje za potvrdu sadrži neispravnu vrijednost. + + + Please enter a valid email address. + Molim upišite ispravnu email adresu. + + + Please select a valid option. + Molim izaberite ispravnu opciju. + + + Please select a valid range. + Molim izaberite ispravan opseg. + + + Please enter a valid week. + Molim upišite ispravnu sedmicu. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.ca.xlf b/lib/symfony/form/Resources/translations/validators.ca.xlf new file mode 100644 index 000000000..76df58246 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.ca.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Aquest formulari no hauria de contenir camps addicionals. + + + The uploaded file was too large. Please try to upload a smaller file. + L'arxiu pujat és massa gran. Per favor, pugi un arxiu més petit. + + + The CSRF token is invalid. Please try to resubmit the form. + El token CSRF no és vàlid. Per favor, provi d'enviar novament el formulari. + + + This value is not a valid HTML5 color. + Aquest valor no és un color HTML5 valid. + + + Please enter a valid birthdate. + Per favor introdueix una data d'aniversari valida. + + + The selected choice is invalid. + L'opció escollida és invalida. + + + The collection is invalid. + La col·lecció és invalida. + + + Please select a valid color. + Per favor selecciona un color vàlid. + + + Please select a valid country. + Per favor selecciona una ciutat vàlida. + + + Please select a valid currency. + Per favor selecciona una moneda vàlida. + + + Please choose a valid date interval. + Per favor escull un interval de dates vàlides. + + + Please enter a valid date and time. + Per favor introdueix una data i temps vàlid. + + + Please enter a valid date. + Per favor introdueix una data vàlida. + + + Please select a valid file. + Per favor selecciona un arxiu vàlid. + + + The hidden field is invalid. + El camp ocult és invàlid. + + + Please enter an integer. + Per favor introdueix un enter. + + + Please select a valid language. + Per favor selecciona un idioma vàlid. + + + Please select a valid locale. + Per favor seleccioneu una configuració regional vàlida + + + Please enter a valid money amount. + Per favor introdueix una quantitat de diners vàlids. + + + Please enter a number. + Per favor introdueix un número. + + + The password is invalid. + La contrasenya es invàlida. + + + Please enter a percentage value. + Per favor introdueix un valor percentual. + + + The values do not match. + Els valors no coincideixen. + + + Please enter a valid time. + Per favor introdueix un temps vàlid. + + + Please select a valid timezone. + Per favor selecciona una zona horària vàlida. + + + Please enter a valid URL. + Per favor introdueix una URL vàlida. + + + Please enter a valid search term. + Per favor introdueix un concepte de cerca vàlid. + + + Please provide a valid phone number. + Per favor introdueix un número de telèfon vàlid. + + + The checkbox has an invalid value. + La casella de selecció te un valor invàlid. + + + Please enter a valid email address. + Per favor introdueix un correu electrònic vàlid. + + + Please select a valid option. + Per favor selecciona una opció vàlida. + + + Please select a valid range. + Per favor selecciona un rang vàlid. + + + Please enter a valid week. + Per favor introdueix una setmana vàlida. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.cs.xlf b/lib/symfony/form/Resources/translations/validators.cs.xlf new file mode 100644 index 000000000..829fea17b --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.cs.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Tato skupina polí nesmí obsahovat další pole. + + + The uploaded file was too large. Please try to upload a smaller file. + Nahraný soubor je příliš velký. Nahrajte prosím menší soubor. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF token je neplatný. Zkuste prosím znovu odeslat formulář. + + + This value is not a valid HTML5 color. + Tato hodnota není platná HTML5 barva. + + + Please enter a valid birthdate. + Prosím zadejte platný datum narození. + + + The selected choice is invalid. + Vybraná možnost není platná. + + + The collection is invalid. + Kolekce není platná. + + + Please select a valid color. + Prosím vyberte platnou barvu. + + + Please select a valid country. + Prosím vyberte platnou zemi. + + + Please select a valid currency. + Prosím vyberte platnou měnu. + + + Please choose a valid date interval. + Prosím vyberte platné rozpětí dat. + + + Please enter a valid date and time. + Prosím zadejte platný datum a čas. + + + Please enter a valid date. + Prosím zadejte platný datum. + + + Please select a valid file. + Prosím vyberte platný soubor. + + + The hidden field is invalid. + Skryté pole není platné. + + + Please enter an integer. + Prosím zadejte číslo. + + + Please select a valid language. + Prosím zadejte platný jazyk. + + + Please select a valid locale. + Prosím zadejte platný jazyk. + + + Please enter a valid money amount. + Prosím zadejte platnou částku. + + + Please enter a number. + Prosím zadejte číslo. + + + The password is invalid. + Heslo není platné. + + + Please enter a percentage value. + Prosím zadejte procentuální hodnotu. + + + The values do not match. + Hodnoty se neshodují. + + + Please enter a valid time. + Prosím zadejte platný čas. + + + Please select a valid timezone. + Prosím vyberte platné časové pásmo. + + + Please enter a valid URL. + Prosím zadejte platnou URL. + + + Please enter a valid search term. + Prosím zadejte platný výraz k vyhledání. + + + Please provide a valid phone number. + Prosím zadejte platné telefonní číslo. + + + The checkbox has an invalid value. + Zaškrtávací políčko má neplatnou hodnotu. + + + Please enter a valid email address. + Prosím zadejte platnou emailovou adresu. + + + Please select a valid option. + Prosím vyberte platnou možnost. + + + Please select a valid range. + Prosím vyberte platný rozsah. + + + Please enter a valid week. + Prosím zadejte platný týden. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.cy.xlf b/lib/symfony/form/Resources/translations/validators.cy.xlf new file mode 100644 index 000000000..48f18afe7 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.cy.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ni ddylai'r ffurflen gynnwys meysydd ychwanegol. + + + The uploaded file was too large. Please try to upload a smaller file. + Roedd y ffeil a uwchlwythwyd yn rhy fawr. Ceisiwch uwchlwytho ffeil llai. + + + The CSRF token is invalid. Please try to resubmit the form. + Mae'r tocyn CSRF yn annilys. Ceisiwch ailgyflwyno'r ffurflen. + + + This value is not a valid HTML5 color. + Nid yw'r gwerth hwn yn lliw HTML5 dilys. + + + Please enter a valid birthdate. + Nodwch ddyddiad geni dilys. + + + The selected choice is invalid. + Mae'r dewis a ddewiswyd yn annilys. + + + The collection is invalid. + Mae'r casgliad yn annilys. + + + Please select a valid color. + Dewiswch liw dilys. + + + Please select a valid country. + Dewiswch wlad ddilys. + + + Please select a valid currency. + Dewiswch arian cyfred dilys. + + + Please choose a valid date interval. + Dewiswch ystod dyddiadau dilys. + + + Please enter a valid date and time. + Nodwch ddyddiad ac amser dilys. + + + Please enter a valid date. + Nodwch ddyddiad dilys. + + + Please select a valid file. + Dewiswch ffeil ddilys. + + + The hidden field is invalid. + Mae'r maes cudd yn annilys. + + + Please enter an integer. + Nodwch rif cyfan. + + + Please select a valid language. + Dewiswch iaith ddilys. + + + Please select a valid locale. + Dewiswch leoliad dilys. + + + Please enter a valid money amount. + Nodwch swm arian dilys. + + + Please enter a number. + Nodwch rif. + + + The password is invalid. + Mae'r cyfrinair yn annilys. + + + Please enter a percentage value. + Nodwch werth canran. + + + The values do not match. + Nid yw'r gwerthoedd yn cyfateb. + + + Please enter a valid time. + Nodwch amser dilys. + + + Please select a valid timezone. + Dewiswch barth amser dilys. + + + Please enter a valid URL. + Nodwch URL dilys. + + + Please enter a valid search term. + Nodwch derm chwilio dilys. + + + Please provide a valid phone number. + Darparwch rif ffôn dilys. + + + The checkbox has an invalid value. + Mae gan y blwch ticio werth annilys. + + + Please enter a valid email address. + Nodwch gyfeiriad e-bost dilys. + + + Please select a valid option. + Dewiswch opsiwn dilys. + + + Please select a valid range. + Dewiswch ystod ddilys. + + + Please enter a valid week. + Nodwch wythnos ddilys. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.da.xlf b/lib/symfony/form/Resources/translations/validators.da.xlf new file mode 100644 index 000000000..36f49b2c8 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.da.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Feltgruppen må ikke indeholde ekstra felter. + + + The uploaded file was too large. Please try to upload a smaller file. + Den uploadede fil var for stor. Upload venligst en mindre fil. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-token er ugyldig. Prøv venligst at genindsende. + + + This value is not a valid HTML5 color. + Værdien er ikke en gyldig HTML5 farve. + + + Please enter a valid birthdate. + Indtast venligst en gyldig fødselsdato. + + + The selected choice is invalid. + Den valgte mulighed er ugyldig . + + + The collection is invalid. + Samlingen er ugyldig. + + + Please select a valid color. + Vælg venligst en gyldig farve. + + + Please select a valid country. + Vælg venligst et gyldigt land. + + + Please select a valid currency. + Vælg venligst en gyldig valuta. + + + Please choose a valid date interval. + Vælg venligst et gyldigt datointerval. + + + Please enter a valid date and time. + Vælg venligst en gyldig dato og tid. + + + Please enter a valid date. + Vælg venligst en gyldig dato. + + + Please select a valid file. + Vælg venligst en gyldig fil. + + + The hidden field is invalid. + Det skjulte felt er ugyldigt. + + + Please enter an integer. + Indsæt veligst et heltal. + + + Please select a valid language. + Vælg venligst et gyldigt sprog. + + + Please select a valid locale. + Vælg venligst en gyldigt sprogkode. + + + Please enter a valid money amount. + Vælg venligst et gyldigt beløb. + + + Please enter a number. + Indtast venligst et nummer. + + + The password is invalid. + Passwordet er ugyldigt. + + + Please enter a percentage value. + Indtast venligst en procentværdi. + + + The values do not match. + Værdierne er ikke ens. + + + Please enter a valid time. + Indtast venligst en gyldig tid. + + + Please select a valid timezone. + Vælg venligst en gyldig tidszone. + + + Please enter a valid URL. + Indtast venligst en gyldig URL. + + + Please enter a valid search term. + Indtast venligst et gyldigt søgeord. + + + Please provide a valid phone number. + Giv venligst et gyldigt telefonnummer. + + + The checkbox has an invalid value. + Checkboxen har en ugyldigt værdi. + + + Please enter a valid email address. + Indtast venligst en gyldig e-mailadresse. + + + Please select a valid option. + Vælg venligst en gyldig mulighed. + + + Please select a valid range. + Vælg venligst et gyldigt interval . + + + Please enter a valid week. + Indtast venligst en gyldig uge. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.de.xlf b/lib/symfony/form/Resources/translations/validators.de.xlf new file mode 100644 index 000000000..759fa2a19 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.de.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Dieses Formular sollte keine zusätzlichen Felder enthalten. + + + The uploaded file was too large. Please try to upload a smaller file. + Die hochgeladene Datei ist zu groß. Versuchen Sie bitte eine kleinere Datei hochzuladen. + + + The CSRF token is invalid. Please try to resubmit the form. + Der CSRF-Token ist ungültig. Versuchen Sie bitte, das Formular erneut zu senden. + + + This value is not a valid HTML5 color. + Dieser Wert ist keine gültige HTML5 Farbe. + + + Please enter a valid birthdate. + Bitte geben Sie ein gültiges Geburtsdatum ein. + + + The selected choice is invalid. + Die Auswahl ist ungültig. + + + The collection is invalid. + Diese Gruppe von Feldern ist ungültig. + + + Please select a valid color. + Bitte geben Sie eine gültige Farbe ein. + + + Please select a valid country. + Bitte wählen Sie ein gültiges Land aus. + + + Please select a valid currency. + Bitte wählen Sie eine gültige Währung aus. + + + Please choose a valid date interval. + Bitte wählen Sie ein gültiges Datumsintervall. + + + Please enter a valid date and time. + Bitte geben Sie ein gültiges Datum samt Uhrzeit ein. + + + Please enter a valid date. + Bitte geben Sie ein gültiges Datum ein. + + + Please select a valid file. + Bitte wählen Sie eine gültige Datei. + + + The hidden field is invalid. + Das versteckte Feld ist ungültig. + + + Please enter an integer. + Bitte geben Sie eine ganze Zahl ein. + + + Please select a valid language. + Bitte wählen Sie eine gültige Sprache. + + + Please select a valid locale. + Bitte wählen Sie eine gültige Locale-Einstellung aus. + + + Please enter a valid money amount. + Bitte geben Sie einen gültigen Geldbetrag ein. + + + Please enter a number. + Bitte geben Sie eine gültige Zahl ein. + + + The password is invalid. + Das Kennwort ist ungültig. + + + Please enter a percentage value. + Bitte geben Sie einen gültigen Prozentwert ein. + + + The values do not match. + Die Werte stimmen nicht überein. + + + Please enter a valid time. + Bitte geben Sie eine gültige Uhrzeit ein. + + + Please select a valid timezone. + Bitte wählen Sie eine gültige Zeitzone. + + + Please enter a valid URL. + Bitte geben Sie eine gültige URL ein. + + + Please enter a valid search term. + Bitte geben Sie einen gültigen Suchbegriff ein. + + + Please provide a valid phone number. + Bitte geben Sie eine gültige Telefonnummer ein. + + + The checkbox has an invalid value. + Das Kontrollkästchen hat einen ungültigen Wert. + + + Please enter a valid email address. + Bitte geben Sie eine gültige E-Mail-Adresse ein. + + + Please select a valid option. + Bitte wählen Sie eine gültige Option. + + + Please select a valid range. + Bitte wählen Sie einen gültigen Bereich. + + + Please enter a valid week. + Bitte geben Sie eine gültige Woche ein. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.el.xlf b/lib/symfony/form/Resources/translations/validators.el.xlf new file mode 100644 index 000000000..b544dcbc6 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.el.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Αυτή η φόρμα δεν πρέπει να περιέχει επιπλέον πεδία. + + + The uploaded file was too large. Please try to upload a smaller file. + Το αρχείο είναι πολύ μεγάλο. Παρακαλούμε προσπαθήστε να ανεβάσετε ένα μικρότερο αρχείο. + + + The CSRF token is invalid. Please try to resubmit the form. + Το CSRF token δεν είναι έγκυρο. Παρακαλούμε δοκιμάστε να υποβάλετε τη φόρμα ξανά. + + + This value is not a valid HTML5 color. + Αυτή η τιμή δέν έναι έγκυρο χρώμα HTML5. + + + Please enter a valid birthdate. + Παρακαλόυμε ειχάγεται μία έγκυρη ημερομηνία γέννησης. + + + The selected choice is invalid. + Η επιλεγμένη επιλογή δέν είναι έγκυρη. + + + The collection is invalid. + Η συλλογή δέν είναι έγκυρη. + + + Please select a valid color. + Παρακαλούμε επιλέξτε ένα έγκυρο χρώμα. + + + Please select a valid country. + Παρακαλούμε επιλέξτε μία έγκυρη χώρα. + + + Please select a valid currency. + Παρακαλούμε επιλέξτε ένα έγυρο νόμισμα. + + + Please choose a valid date interval. + Παρακαλούμε επιλέξτε ένα έγκυρο διάστημα ημερομηνίας. + + + Please enter a valid date and time. + Παρακαλούμε εισαγάγετε μια έγκυρη ημερομηνία και ώρα. + + + Please enter a valid date. + Παρακαλούμε εισάγετε μία έγκυρη ημερομηνία. + + + Please select a valid file. + Παρακαλούμε επιλέξτε ένα έγκυρο αρχείο. + + + The hidden field is invalid. + Το κρυφό πεδίο δέν είναι έγκυρο. + + + Please enter an integer. + Παρακαλούμε εισάγετε έναν ακέραιο αριθμό. + + + Please select a valid language. + Παρακαλούμε επιλέξτε μία έγκυρη γλώσσα. + + + Please select a valid locale. + Παρακαλούμε επιλέξτε μία έγκυρη τοπικοποίηση. + + + Please enter a valid money amount. + Παρακαλούμε εισάγετε ένα έγκυρο χρηματικό ποσό. + + + Please enter a number. + Παρακαλούμε εισάγετε έναν αριθμό. + + + The password is invalid. + Ο κωδικός δέν είναι έγκυρος. + + + Please enter a percentage value. + Παρακαλούμε εισάγετε μία ποσοστιαία τιμή. + + + The values do not match. + Οι τιμές δέν ταιριάζουν. + + + Please enter a valid time. + Παρακαλούμε εισάγετε μία έγκυρη ώρα. + + + Please select a valid timezone. + Παρακαλούμε επιλέξτε μία έγυρη ζώνη ώρας. + + + Please enter a valid URL. + Παρακαλούμε εισάγετε μια έγκυρη διεύθυνση URL. + + + Please enter a valid search term. + Παρακαλούμε εισάγετε έναν έγκυρο όρο αναζήτησης. + + + Please provide a valid phone number. + Παρακαλούμε καταχωρίστε έναν έγκυρο αριθμό τηλεφώνου. + + + The checkbox has an invalid value. + Το πλαίσιο ελέγχου έχει μή έγκυρη τιμή. + + + Please enter a valid email address. + Παρακαλούμε εισάγετε μία έγκυρη ηλεκτρονική διεύθυνση. + + + Please select a valid option. + Παρακαλούμε επιλέξτε μία έγκυρη επιλογή. + + + Please select a valid range. + Παρακαλούμε επιλέξτε ένα έγυρο εύρος. + + + Please enter a valid week. + Παρακαλούμε εισάγετε μία έγκυρη εβδομάδα. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.en.xlf b/lib/symfony/form/Resources/translations/validators.en.xlf new file mode 100644 index 000000000..57d3da969 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.en.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + This form should not contain extra fields. + + + The uploaded file was too large. Please try to upload a smaller file. + The uploaded file was too large. Please try to upload a smaller file. + + + The CSRF token is invalid. Please try to resubmit the form. + The CSRF token is invalid. Please try to resubmit the form. + + + This value is not a valid HTML5 color. + This value is not a valid HTML5 color. + + + Please enter a valid birthdate. + Please enter a valid birthdate. + + + The selected choice is invalid. + The selected choice is invalid. + + + The collection is invalid. + The collection is invalid. + + + Please select a valid color. + Please select a valid color. + + + Please select a valid country. + Please select a valid country. + + + Please select a valid currency. + Please select a valid currency. + + + Please choose a valid date interval. + Please choose a valid date interval. + + + Please enter a valid date and time. + Please enter a valid date and time. + + + Please enter a valid date. + Please enter a valid date. + + + Please select a valid file. + Please select a valid file. + + + The hidden field is invalid. + The hidden field is invalid. + + + Please enter an integer. + Please enter an integer. + + + Please select a valid language. + Please select a valid language. + + + Please select a valid locale. + Please select a valid locale. + + + Please enter a valid money amount. + Please enter a valid money amount. + + + Please enter a number. + Please enter a number. + + + The password is invalid. + The password is invalid. + + + Please enter a percentage value. + Please enter a percentage value. + + + The values do not match. + The values do not match. + + + Please enter a valid time. + Please enter a valid time. + + + Please select a valid timezone. + Please select a valid timezone. + + + Please enter a valid URL. + Please enter a valid URL. + + + Please enter a valid search term. + Please enter a valid search term. + + + Please provide a valid phone number. + Please provide a valid phone number. + + + The checkbox has an invalid value. + The checkbox has an invalid value. + + + Please enter a valid email address. + Please enter a valid email address. + + + Please select a valid option. + Please select a valid option. + + + Please select a valid range. + Please select a valid range. + + + Please enter a valid week. + Please enter a valid week. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.es.xlf b/lib/symfony/form/Resources/translations/validators.es.xlf new file mode 100644 index 000000000..a9989737c --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.es.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Este formulario no debería contener campos adicionales. + + + The uploaded file was too large. Please try to upload a smaller file. + El archivo subido es demasiado grande. Por favor, suba un archivo más pequeño. + + + The CSRF token is invalid. Please try to resubmit the form. + El token CSRF no es válido. Por favor, pruebe a enviar nuevamente el formulario. + + + This value is not a valid HTML5 color. + Este valor no es un color HTML5 válido. + + + Please enter a valid birthdate. + Por favor, ingrese una fecha de cumpleaños válida. + + + The selected choice is invalid. + La opción seleccionada no es válida. + + + The collection is invalid. + La colección no es válida. + + + Please select a valid color. + Por favor, seleccione un color válido. + + + Please select a valid country. + Por favor, seleccione un país válido. + + + Please select a valid currency. + Por favor, seleccione una moneda válida. + + + Please choose a valid date interval. + Por favor, elija un intervalo de fechas válido. + + + Please enter a valid date and time. + Por favor, ingrese una fecha y hora válidas. + + + Please enter a valid date. + Por favor, ingrese una fecha válida. + + + Please select a valid file. + Por favor, seleccione un archivo válido. + + + The hidden field is invalid. + El campo oculto no es válido. + + + Please enter an integer. + Por favor, ingrese un número entero. + + + Please select a valid language. + Por favor, seleccione un idioma válido. + + + Please select a valid locale. + Por favor, seleccione una configuración regional válida. + + + Please enter a valid money amount. + Por favor, ingrese una cantidad de dinero válida. + + + Please enter a number. + Por favor, ingrese un número. + + + The password is invalid. + La contraseña no es válida. + + + Please enter a percentage value. + Por favor, ingrese un valor porcentual. + + + The values do not match. + Los valores no coinciden. + + + Please enter a valid time. + Por favor, ingrese una hora válida. + + + Please select a valid timezone. + Por favor, seleccione una zona horaria válida. + + + Please enter a valid URL. + Por favor, ingrese una URL válida. + + + Please enter a valid search term. + Por favor, ingrese un término de búsqueda válido. + + + Please provide a valid phone number. + Por favor, proporcione un número de teléfono válido. + + + The checkbox has an invalid value. + La casilla de verificación tiene un valor inválido. + + + Please enter a valid email address. + Por favor, ingrese una dirección de correo electrónico válida. + + + Please select a valid option. + Por favor, seleccione una opción válida. + + + Please select a valid range. + Por favor, seleccione un rango válido. + + + Please enter a valid week. + Por favor, ingrese una semana válida. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.et.xlf b/lib/symfony/form/Resources/translations/validators.et.xlf new file mode 100644 index 000000000..0767220ef --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.et.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Väljade grupp ei tohiks sisalda lisaväljasid. + + + The uploaded file was too large. Please try to upload a smaller file. + Üleslaaditud fail oli liiga suur. Palun proovi uuesti väiksema failiga. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-märgis on vigane. Palun proovi vormi uuesti esitada. + + + This value is not a valid HTML5 color. + See väärtus ei ole korrektne HTML5 värv. + + + Please enter a valid birthdate. + Palun sisesta korrektne sünnikuupäev. + + + The selected choice is invalid. + Tehtud valik on vigane. + + + The collection is invalid. + Kogum on vigane. + + + Please select a valid color. + Palun vali korrektne värv. + + + Please select a valid country. + Palun vali korrektne riik. + + + Please select a valid currency. + Palun vali korrektne valuuta. + + + Please choose a valid date interval. + Palun vali korrektne kuupäevade vahemik. + + + Please enter a valid date and time. + Palun sisesta korrektne kuupäev ja kellaaeg. + + + Please enter a valid date. + Palun sisesta korrektne kuupäev. + + + Please select a valid file. + Palun vali korrektne fail. + + + The hidden field is invalid. + Peidetud väli on vigane. + + + Please enter an integer. + Palun sisesta täisarv. + + + Please select a valid language. + Palun vali korrektne keel. + + + Please select a valid locale. + Palun vali korrektne keelekood. + + + Please enter a valid money amount. + Palun sisesta korrektne rahaline väärtus. + + + Please enter a number. + Palun sisesta number. + + + The password is invalid. + Vigane parool. + + + Please enter a percentage value. + Palun sisesta protsendiline väärtus. + + + The values do not match. + Väärtused ei klapi. + + + Please enter a valid time. + Palun sisesta korrektne aeg. + + + Please select a valid timezone. + Palun vali korrektne ajavöönd. + + + Please enter a valid URL. + Palun sisesta korrektne URL. + + + Please enter a valid search term. + Palun sisesta korrektne otsingutermin. + + + Please provide a valid phone number. + Palun sisesta korrektne telefoninumber. + + + The checkbox has an invalid value. + Märkeruudu väärtus on vigane. + + + Please enter a valid email address. + Palun sisesta korrektne e-posti aadress. + + + Please select a valid option. + Palun tee korrektne valik. + + + Please select a valid range. + Palun vali korrektne vahemik. + + + Please enter a valid week. + Palun sisesta korrektne nädal. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.eu.xlf b/lib/symfony/form/Resources/translations/validators.eu.xlf new file mode 100644 index 000000000..a73c63abb --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.eu.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Formulario honek ez luke aparteko eremurik eduki behar. + + + The uploaded file was too large. Please try to upload a smaller file. + Igotako fitxategia handiegia da. Mesedez saiatu fitxategi txikiago bat igotzen. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF tokena baliogabea da. Mesedez, saiatu berriro formularioa bidaltzen. + + + This value is not a valid HTML5 color. + Balio hori ez da HTML5 kolore onargarria. + + + Please enter a valid birthdate. + Mesedez, sartu baliozko urtebetetze-eguna. + + + The selected choice is invalid. + Hautatutako aukera ez da egokia. + + + The collection is invalid. + Bilduma ez da baliozkoa. + + + Please select a valid color. + Mesedez, hautatu baliozko kolore bat. + + + Please select a valid country. + Mesedez, hautatu baliozko herrialde bat. + + + Please select a valid currency. + Mesedez, hautatu baliozko moneta bat. + + + Please choose a valid date interval. + Mesedez, hautatu baliozko data-tarte bat. + + + Please enter a valid date and time. + Mesedez, sartu baliozko data eta ordua. + + + Please enter a valid date. + Mesedez, sartu baliozko data bat. + + + Please select a valid file. + Mesedez, hautatu baliozko fitxategi bat. + + + The hidden field is invalid. + Eremu ezkutua ez da baliozkoa. + + + Please enter an integer. + Mesedez, sartu zenbaki oso bat. + + + Please select a valid language. + Mesedez, hautatu baliozko hizkuntza bat. + + + Please select a valid locale. + Mesedez, hautatu baliozko eskualde-konfigurazio bat. + + + Please enter a valid money amount. + Mesedez, sartu baliozko diru-kopuru bat. + + + Please enter a number. + Mesedez, sartu zenbaki bat. + + + The password is invalid. + Pasahitza ez da zuzena. + + + Please enter a percentage value. + Mesedez, sartu portzentajezko balio bat. + + + The values do not match. + Balioak ez datoz bat. + + + Please enter a valid time. + Mesedez, sartu baliozko ordu bat. + + + Please select a valid timezone. + Mesedez, hautatu baliozko ordu-eremua. + + + Please enter a valid URL. + Mesedez, sartu baliozko URL bat. + + + Please enter a valid search term. + Mesedez, sartu bilaketa-termino onargarri bat. + + + Please provide a valid phone number. + Mesedez, eman baliozko telefono-zenbaki bat. + + + The checkbox has an invalid value. + Egiaztatze-laukiak balio baliogabea du. + + + Please enter a valid email address. + Mesedez, sartu baliozko helbide elektroniko bat. + + + Please select a valid option. + Mesedez, hautatu baliozko aukera bat. + + + Please select a valid range. + Mesedez, hautatu baliozko tarte bat. + + + Please enter a valid week. + Mesedez, sartu baliozko aste bat. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.fa.xlf b/lib/symfony/form/Resources/translations/validators.fa.xlf new file mode 100644 index 000000000..2ebb1cc2b --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.fa.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + این فرم نباید شامل فیلدهای اضافی باشد. + + + The uploaded file was too large. Please try to upload a smaller file. + فایل بارگذاری‌شده بسیار بزرگ است. لطفاً فایل کوچک‌تری را بارگذاری نمایید. + + + The CSRF token is invalid. Please try to resubmit the form. + توکن CSRF نامعتبر است. لطفاً فرم را مجدداً ارسال نمایید. + + + This value is not a valid HTML5 color. + این مقدار یک رنگ معتبر HTML5 نیست. + + + Please enter a valid birthdate. + لطفاً یک تاریخ تولد معتبر وارد نمایید. + + + The selected choice is invalid. + گزینه‌ انتخاب‌ شده نامعتبر است. + + + The collection is invalid. + این مجموعه نامعتبر است. + + + Please select a valid color. + لطفاً یک رنگ معتبر انتخاب کنید. + + + Please select a valid country. + لطفاً یک کشور معتبر انتخاب کنید. + + + Please select a valid currency. + لطفاً یک واحد پول معتبر انتخاب کنید. + + + Please choose a valid date interval. + لطفاً یک بازه‌ زمانی معتبر انتخاب کنید. + + + Please enter a valid date and time. + لطفاً یک تاریخ و زمان معتبر وارد کنید. + + + Please enter a valid date. + لطفاً یک تاریخ معتبر وارد کنید. + + + Please select a valid file. + لطفاً یک فایل معتبر انتخاب کنید. + + + The hidden field is invalid. + فیلد مخفی نامعتبر است. + + + Please enter an integer. + لطفاً یک عدد صحیح وارد کنید. + + + Please select a valid language. + لطفاً یک زبان معتبر انتخاب کنید. + + + Please select a valid locale. + لطفاً یک منطقه‌جغرافیایی (locale) معتبر انتخاب کنید. + + + Please enter a valid money amount. + لطفاً یک مقدار پول معتبر وارد کنید. + + + Please enter a number. + لطفاً یک عدد وارد کنید. + + + The password is invalid. + رمزعبور نامعتبر است. + + + Please enter a percentage value. + لطفاً یک درصد معتبر وارد کنید. + + + The values do not match. + مقادیر تطابق ندارند. + + + Please enter a valid time. + لطفاً یک زمان معتبر وارد کنید. + + + Please select a valid timezone. + لطفاً یک منطقه‌زمانی معتبر وارد کنید. + + + Please enter a valid URL. + لطفاً یک URL معتبر وارد کنید. + + + Please enter a valid search term. + لطفاً یک عبارت جستجوی معتبر وارد کنید. + + + Please provide a valid phone number. + لطفاً یک شماره تلفن معتبر وارد کنید. + + + The checkbox has an invalid value. + کادر انتخاب (checkbox) دارای مقداری نامعتبر است. + + + Please enter a valid email address. + لطفاً یک آدرس رایانامه (ایمیل) معتبر وارد کنید. + + + Please select a valid option. + لطفاً یک گزینه‌ معتبر انتخاب کنید. + + + Please select a valid range. + لطفاً یک محدوده‌ معتبر انتخاب کنید. + + + Please enter a valid week. + لطفاً یک هفته‌ معتبر وارد کنید. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.fi.xlf b/lib/symfony/form/Resources/translations/validators.fi.xlf new file mode 100644 index 000000000..438365404 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.fi.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Tämä lomake ei voi sisältää ylimääräisiä kenttiä. + + + The uploaded file was too large. Please try to upload a smaller file. + Ladattu tiedosto on liian iso. Ole hyvä ja lataa pienempi tiedosto. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-tarkiste on virheellinen. Ole hyvä ja yritä lähettää lomake uudestaan. + + + This value is not a valid HTML5 color. + Tämä arvo ei ole kelvollinen HTML5-väri. + + + Please enter a valid birthdate. + Syötä kelvollinen syntymäaika. + + + The selected choice is invalid. + Valittu vaihtoehto ei kelpaa. + + + The collection is invalid. + Ryhmä ei kelpaa. + + + Please select a valid color. + Valitse kelvollinen väri. + + + Please select a valid country. + Valitse kelvollinen maa. + + + Please select a valid currency. + Valitse kelvollinen valuutta. + + + Please choose a valid date interval. + Valitse kelvollinen aikaväli. + + + Please enter a valid date and time. + Syötä kelvolliset päivä ja aika. + + + Please enter a valid date. + Syötä kelvollinen päivä. + + + Please select a valid file. + Valitse kelvollinen tiedosto. + + + The hidden field is invalid. + Piilotettu kenttä ei ole kelvollinen. + + + Please enter an integer. + Syötä kokonaisluku. + + + Please select a valid language. + Valitse kelvollinen kieli. + + + Please select a valid locale. + Valitse kelvollinen kielikoodi. + + + Please enter a valid money amount. + Syötä kelvollinen rahasumma. + + + Please enter a number. + Syötä numero. + + + The password is invalid. + Salasana ei kelpaa. + + + Please enter a percentage value. + Syötä prosenttiluku. + + + The values do not match. + Arvot eivät vastaa toisiaan. + + + Please enter a valid time. + Syötä kelvollinen kellonaika. + + + Please select a valid timezone. + Valitse kelvollinen aikavyöhyke. + + + Please enter a valid URL. + Syötä kelvollinen URL. + + + Please enter a valid search term. + Syötä kelvollinen hakusana. + + + Please provide a valid phone number. + Anna kelvollinen puhelinnumero. + + + The checkbox has an invalid value. + Valintaruudun arvo ei kelpaa. + + + Please enter a valid email address. + Syötä kelvollinen sähköpostiosoite. + + + Please select a valid option. + Valitse kelvollinen vaihtoehto. + + + Please select a valid range. + Valitse kelvollinen väli. + + + Please enter a valid week. + Syötä kelvollinen viikko. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.fr.xlf b/lib/symfony/form/Resources/translations/validators.fr.xlf new file mode 100644 index 000000000..cbfb4f83c --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.fr.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ce formulaire ne doit pas contenir de champs supplémentaires. + + + The uploaded file was too large. Please try to upload a smaller file. + Le fichier téléchargé est trop volumineux. Merci d'essayer d'envoyer un fichier plus petit. + + + The CSRF token is invalid. Please try to resubmit the form. + Le jeton CSRF est invalide. Veuillez renvoyer le formulaire. + + + This value is not a valid HTML5 color. + Cette valeur n'est pas une couleur HTML5 valide. + + + Please enter a valid birthdate. + Veuillez entrer une date de naissance valide. + + + The selected choice is invalid. + Le choix sélectionné est invalide. + + + The collection is invalid. + La collection est invalide. + + + Please select a valid color. + Veuillez sélectionner une couleur valide. + + + Please select a valid country. + Veuillez sélectionner un pays valide. + + + Please select a valid currency. + Veuillez sélectionner une devise valide. + + + Please choose a valid date interval. + Veuillez choisir un intervalle de dates valide. + + + Please enter a valid date and time. + Veuillez saisir une date et une heure valides. + + + Please enter a valid date. + Veuillez entrer une date valide. + + + Please select a valid file. + Veuillez sélectionner un fichier valide. + + + The hidden field is invalid. + Le champ masqué n'est pas valide. + + + Please enter an integer. + Veuillez saisir un entier. + + + Please select a valid language. + Veuillez sélectionner une langue valide. + + + Please select a valid locale. + Veuillez sélectionner une langue valide. + + + Please enter a valid money amount. + Veuillez saisir un montant valide. + + + Please enter a number. + Veuillez saisir un nombre. + + + The password is invalid. + Le mot de passe est invalide. + + + Please enter a percentage value. + Veuillez saisir un pourcentage valide. + + + The values do not match. + Les valeurs ne correspondent pas. + + + Please enter a valid time. + Veuillez saisir une heure valide. + + + Please select a valid timezone. + Veuillez sélectionner un fuseau horaire valide. + + + Please enter a valid URL. + Veuillez saisir une URL valide. + + + Please enter a valid search term. + Veuillez saisir un terme de recherche valide. + + + Please provide a valid phone number. + Veuillez fournir un numéro de téléphone valide. + + + The checkbox has an invalid value. + La case à cocher a une valeur non valide. + + + Please enter a valid email address. + Veuillez saisir une adresse email valide. + + + Please select a valid option. + Veuillez sélectionner une option valide. + + + Please select a valid range. + Veuillez sélectionner une plage valide. + + + Please enter a valid week. + Veuillez entrer une semaine valide. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.gl.xlf b/lib/symfony/form/Resources/translations/validators.gl.xlf new file mode 100644 index 000000000..e3427f8d2 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.gl.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Este formulario non debería conter campos adicionais. + + + The uploaded file was too large. Please try to upload a smaller file. + O arquivo subido é demasiado grande. Por favor, suba un arquivo máis pequeno. + + + The CSRF token is invalid. Please try to resubmit the form. + O token CSRF non é válido. Por favor, probe a enviar novamente o formulario. + + + This value is not a valid HTML5 color. + Este valor non é unha cor HTML5 válida. + + + Please enter a valid birthdate. + Insire unha data de aniversario válida. + + + The selected choice is invalid. + A opción seleccionada non é válida. + + + The collection is invalid. + A colección non é válida. + + + Please select a valid color. + Por favor, seleccione unha cor válida. + + + Please select a valid country. + Por favor, seleccione un país válido. + + + Please select a valid currency. + Por favor, seleccione unha moeda válida. + + + Please choose a valid date interval. + Por favor, escolla un intervalo de datas válido. + + + Please enter a valid date and time. + Por favor, introduza unha data e hora válidas. + + + Please enter a valid date. + Por favor, introduce unha data válida. + + + Please select a valid file. + Por favor, seleccione un ficheiro válido. + + + The hidden field is invalid. + O campo oculto non é válido. + + + Please enter an integer. + Por favor, introduza un número enteiro. + + + Please select a valid language. + Por favor, selecciona un idioma válido. + + + Please select a valid locale. + Por favor, seleccione unha configuración rexional válida. + + + Please enter a valid money amount. + Por favor, introduza unha cantidade de diñeiro válida. + + + Please enter a number. + Por favor, introduza un número. + + + The password is invalid. + O contrasinal non é válido. + + + Please enter a percentage value. + Por favor, introduza un valor porcentual. + + + The values do not match. + Os valores non coinciden. + + + Please enter a valid time. + Por favor, introduza unha hora válida. + + + Please select a valid timezone. + Por favor, selecciona unha zona horaria válida. + + + Please enter a valid URL. + Por favor, introduce un URL válido. + + + Please enter a valid search term. + Por favor, introduce un termo de busca válido. + + + Please provide a valid phone number. + Por favor, fornecer un número de teléfono válido. + + + The checkbox has an invalid value. + A caixa de verificación ten un valor non válido. + + + Please enter a valid email address. + Por favor, introduce un enderezo de correo electrónico válido. + + + Please select a valid option. + Por favor, seleccione unha opción válida. + + + Please select a valid range. + Por favor, seleccione un intervalo válido. + + + Please enter a valid week. + Por favor, introduce unha semana válida. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.he.xlf b/lib/symfony/form/Resources/translations/validators.he.xlf new file mode 100644 index 000000000..41428ac70 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.he.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + הטופס לא צריך להכיל שדות נוספים. + + + The uploaded file was too large. Please try to upload a smaller file. + הקובץ שהועלה גדול מדי. נסה להעלות קובץ קטן יותר. + + + The CSRF token is invalid. Please try to resubmit the form. + אסימון CSRF אינו חוקי. אנא נסה לשלוח שוב את הטופס. + + + This value is not a valid HTML5 color. + ערך זה אינו צבע HTML5 חוקי. + + + Please enter a valid birthdate. + נא להזין את תאריך לידה תקני. + + + The selected choice is invalid. + הבחירה שנבחרה אינה חוקית. + + + The collection is invalid. + האוסף אינו חוקי. + + + Please select a valid color. + אנא בחר צבע חוקי. + + + Please select a valid country. + אנא בחר מדינה חוקית. + + + Please select a valid currency. + אנא בחר מטבע חוקי. + + + Please choose a valid date interval. + אנא בחר מרווח תאריכים חוקי. + + + Please enter a valid date and time. + אנא הזן תאריך ושעה תקנים. + + + Please enter a valid date. + נא להזין תאריך חוקי. + + + Please select a valid file. + אנא בחר קובץ חוקי. + + + The hidden field is invalid. + השדה הנסתר אינו חוקי. + + + Please enter an integer. + אנא הזן מספר שלם. + + + Please select a valid language. + אנא בחר שפה חוקי. + + + Please select a valid locale. + אנא בחר שפה מקומית. + + + Please enter a valid money amount. + אנא הזן סכום כסף חוקי. + + + Please enter a number. + אנא הזן מספר. + + + The password is invalid. + הסיסמה אינה חוקית. + + + Please enter a percentage value. + אנא הזן ערך באחוזים. + + + The values do not match. + הערכים אינם תואמים. + + + Please enter a valid time. + אנא הזן שעה חוקי. + + + Please select a valid timezone. + אנא בחר אזור זמן חוקי. + + + Please enter a valid URL. + נא להזין את כתובת אתר חוקית. + + + Please enter a valid search term. + אנא הזן מונח חיפוש חוקי. + + + Please provide a valid phone number. + אנא ספק מספר טלפון חוקי. + + + The checkbox has an invalid value. + לתיבת הסימון יש ערך לא חוקי. + + + Please enter a valid email address. + אנא הזן כתובת דוא"ל תקנית. + + + Please select a valid option. + אנא בחר אפשרות חוקית. + + + Please select a valid range. + אנא בחר טווח חוקי. + + + Please enter a valid week. + אנא הזן שבוע תקף. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.hr.xlf b/lib/symfony/form/Resources/translations/validators.hr.xlf new file mode 100644 index 000000000..e3aa7b2b9 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.hr.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ovaj obrazac ne smije sadržavati dodatna polja. + + + The uploaded file was too large. Please try to upload a smaller file. + Prenesena datoteka je prevelika. Molim pokušajte prenijeti manju datoteku. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF vrijednost nije ispravna. Pokušajte ponovo poslati obrazac. + + + This value is not a valid HTML5 color. + Ova vrijednost nije važeća HTML5 boja. + + + Please enter a valid birthdate. + Molim upišite ispravan datum rođenja. + + + The selected choice is invalid. + Odabrani izbor nije ispravan. + + + The collection is invalid. + Kolekcija nije ispravna. + + + Please select a valid color. + Molim odaberite ispravnu boju. + + + Please select a valid country. + Molim odaberite ispravnu državu. + + + Please select a valid currency. + Molim odaberite ispravnu valutu. + + + Please choose a valid date interval. + Molim odaberite ispravni vremenski interval. + + + Please enter a valid date and time. + Molim unesite ispravni datum i vrijeme. + + + Please enter a valid date. + Molim odaberite ispravan datum. + + + Please select a valid file. + Molim odaberite ispravnu datoteku. + + + The hidden field is invalid. + Skriveno polje nije ispravno. + + + Please enter an integer. + Molim unesite cijeli broj. + + + Please select a valid language. + Molim odaberite ispravan jezik. + + + Please select a valid locale. + Molim odaberite ispravnu lokalizaciju. + + + Please enter a valid money amount. + Molim unesite ispravan iznos novca. + + + Please enter a number. + Molim unesite broj. + + + The password is invalid. + Ova lozinka nije ispravna. + + + Please enter a percentage value. + Molim unesite vrijednost postotka. + + + The values do not match. + Ove vrijednosti se ne poklapaju. + + + Please enter a valid time. + Molim unesite ispravno vrijeme. + + + Please select a valid timezone. + Molim odaberite ispravnu vremensku zonu. + + + Please enter a valid URL. + Molim unesite ispravan URL. + + + Please enter a valid search term. + Molim unesite ispravan pojam za pretraživanje. + + + Please provide a valid phone number. + Molim navedite ispravan telefonski broj. + + + The checkbox has an invalid value. + Polje za potvrdu sadrži neispravnu vrijednost. + + + Please enter a valid email address. + Molim unesite valjanu adresu elektronske pošte. + + + Please select a valid option. + Molim odaberite ispravnu opciju. + + + Please select a valid range. + Molim odaberite ispravan raspon. + + + Please enter a valid week. + Molim unesite ispravni tjedan. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.hu.xlf b/lib/symfony/form/Resources/translations/validators.hu.xlf new file mode 100644 index 000000000..0ea74fea9 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.hu.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ez a mezőcsoport nem tartalmazhat extra mezőket. + + + The uploaded file was too large. Please try to upload a smaller file. + A feltöltött fájl túl nagy. Kérem, próbáljon egy kisebb fájlt feltölteni. + + + The CSRF token is invalid. Please try to resubmit the form. + Érvénytelen CSRF token. Kérem, próbálja újra elküldeni az űrlapot. + + + This value is not a valid HTML5 color. + Ez az érték nem egy érvényes HTML5 szín. + + + Please enter a valid birthdate. + Kérjük, adjon meg egy valós születési dátumot. + + + The selected choice is invalid. + A kiválasztott opció érvénytelen. + + + The collection is invalid. + A gyűjtemény érvénytelen. + + + Please select a valid color. + Kérjük, válasszon egy érvényes színt. + + + Please select a valid country. + Kérjük, válasszon egy érvényes országot. + + + Please select a valid currency. + Kérjük, válasszon egy érvényes pénznemet. + + + Please choose a valid date interval. + Kérjük, válasszon egy érvényes dátumintervallumot. + + + Please enter a valid date and time. + Kérjük, adjon meg egy érvényes dátumot és időpontot. + + + Please enter a valid date. + Kérjük, adjon meg egy érvényes dátumot. + + + Please select a valid file. + Kérjük, válasszon egy érvényes fájlt. + + + The hidden field is invalid. + A rejtett mező érvénytelen. + + + Please enter an integer. + Kérjük, adjon meg egy egész számot. + + + Please select a valid language. + Kérjük, válasszon egy érvényes nyelvet. + + + Please select a valid locale. + Kérjük, válasszon egy érvényes területi beállítást. + + + Please enter a valid money amount. + Kérjük, adjon meg egy érvényes pénzösszeget. + + + Please enter a number. + Kérjük, adjon meg egy számot. + + + The password is invalid. + A jelszó érvénytelen. + + + Please enter a percentage value. + Kérjük, adjon meg egy százalékos értéket. + + + The values do not match. + Az értékek nem egyeznek. + + + Please enter a valid time. + Kérjük, adjon meg egy érvényes időpontot. + + + Please select a valid timezone. + Kérjük, válasszon érvényes időzónát. + + + Please enter a valid URL. + Kérjük, adjon meg egy érvényes URL-t. + + + Please enter a valid search term. + Kérjük, adjon meg egy érvényes keresési kifejezést. + + + Please provide a valid phone number. + Kérjük, adjon egy érvényes telefonszámot + + + The checkbox has an invalid value. + A jelölőnégyzet értéke érvénytelen. + + + Please enter a valid email address. + Kérjük valós e-mail címet adjon meg. + + + Please select a valid option. + Kérjük, válasszon egy érvényes beállítást. + + + Please select a valid range. + Kérjük, válasszon egy érvényes tartományt. + + + Please enter a valid week. + Kérjük, adjon meg egy érvényes hetet. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.hy.xlf b/lib/symfony/form/Resources/translations/validators.hy.xlf new file mode 100644 index 000000000..ccca24735 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.hy.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Այս ձևը չպետք է պարունակի լրացուցիչ տողեր։ + + + The uploaded file was too large. Please try to upload a smaller file. + Վերբեռնված ֆայլը չափազանց մեծ է. Խնդրվում է վերբեռնել ավելի փոքր չափսի ֆայլ։ + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF արժեքը անթույլատրելի է. Փորձեք նորից ուղարկել ձևը։ + + + This value is not a valid HTML5 color. + Այս արժեքը վավեր HTML5 գույն չէ։ + + + Please enter a valid birthdate. + Խնդրում ենք մուտքագրել վավեր ծննդյան ամսաթիվ։ + + + The selected choice is invalid. + Ընտրված ընտրությունն անվավեր է։ + + + The collection is invalid. + Համախումբն անվավեր է։ + + + Please select a valid color. + Խնդրում ենք ընտրել վավեր գույն։ + + + Please select a valid country. + Խնդրում ենք ընտրել վավեր երկիր։ + + + Please select a valid currency. + Խնդրում ենք ընտրել վավեր արժույթ։ + + + Please choose a valid date interval. + Խնդրում ենք ընտրել ճիշտ ամսաթվերի միջակայք։ + + + Please enter a valid date and time. + Խնդրում ենք մուտքագրել վավեր ամսաթիվ և ժամ։ + + + Please enter a valid date. + Խնդրում ենք մուտքագրել վավեր ամսաթիվ։ + + + Please select a valid file. + Խնդրում ենք ընտրել վավեր ֆայլ։ + + + The hidden field is invalid. + Թաքնված դաշտը անվավեր է։ + + + Please enter an integer. + Խնդրում ենք մուտքագրել ամբողջ թիվ։ + + + Please select a valid language. + Խնդրում ենք ընտրել վավեր լեզու։ + + + Please select a valid locale. + Խնդրում ենք ընտրել վավեր տեղայնացում։ + + + Please enter a valid money amount. + Խնդրում ենք մուտքագրել վավեր գումար։ + + + Please enter a number. + Խնդրում ենք մուտքագրել համար։ + + + The password is invalid. + Գաղտնաբառն անվավեր է։ + + + Please enter a percentage value. + Խնդրում ենք մուտքագրել տոկոսային արժեք։ + + + The values do not match. + Արժեքները չեն համընկնում։ + + + Please enter a valid time. + Մուտքագրեք վավեր ժամանակ։ + + + Please select a valid timezone. + Խնդրում ենք ընտրել վավեր ժամային գոտի։ + + + Please enter a valid URL. + Խնդրում ենք մուտքագրել վավեր URL։ + + + Please enter a valid search term. + Խնդրում ենք մուտքագրել վավեր որոնման տերմին։ + + + Please provide a valid phone number. + Խնդրում ենք տրամադրել վավեր հեռախոսահամար։ + + + The checkbox has an invalid value. + Նշման վանդակը անվավեր արժեք ունի։ + + + Please enter a valid email address. + Խնդրում ենք մուտքագրել վավեր էլ-հասցե։ + + + Please select a valid option. + Խնդրում ենք ընտրել ճիշտ տարբերակ։ + + + Please select a valid range. + Խնդրում ենք ընտրել վավեր տիրույթ։ + + + Please enter a valid week. + Մուտքագրեք վավեր շաբաթ։ + + + + diff --git a/lib/symfony/form/Resources/translations/validators.id.xlf b/lib/symfony/form/Resources/translations/validators.id.xlf new file mode 100644 index 000000000..e4b43f7e3 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.id.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Gabungan kolom tidak boleh mengandung kolom tambahan. + + + The uploaded file was too large. Please try to upload a smaller file. + Berkas yang di unggah terlalu besar. Silahkan coba unggah berkas yang lebih kecil. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-Token tidak sah. Silahkan coba kirim ulang formulir. + + + This value is not a valid HTML5 color. + Nilai ini bukan merupakan HTML5 color yang sah. + + + Please enter a valid birthdate. + Silahkan masukkan tanggal lahir yang sah. + + + The selected choice is invalid. + Pilihan yang dipilih tidak sah. + + + The collection is invalid. + Koleksi tidak sah. + + + Please select a valid color. + Silahkan pilih warna yang sah. + + + Please select a valid country. + Silahkan pilih negara yang sah. + + + Please select a valid currency. + Silahkan pilih mata uang yang sah. + + + Please choose a valid date interval. + Silahkan pilih interval tanggal yang sah. + + + Please enter a valid date and time. + Silahkan masukkan tanggal dan waktu yang sah. + + + Please enter a valid date. + Silahkan masukkan tanggal yang sah. + + + Please select a valid file. + Silahkan pilih berkas yang sah. + + + The hidden field is invalid. + Ruas yang tersembunyi tidak sah. + + + Please enter an integer. + Silahkan masukkan angka. + + + Please select a valid language. + Silahlan pilih bahasa yang sah. + + + Please select a valid locale. + Silahkan pilih local yang sah. + + + Please enter a valid money amount. + Silahkan masukkan nilai uang yang sah. + + + Please enter a number. + Silahkan masukkan sebuah angka + + + The password is invalid. + Kata sandi tidak sah. + + + Please enter a percentage value. + Silahkan masukkan sebuah nilai persentase. + + + The values do not match. + Nilainya tidak cocok. + + + Please enter a valid time. + Silahkan masukkan waktu yang sah. + + + Please select a valid timezone. + Silahkan pilih zona waktu yang sah. + + + Please enter a valid URL. + Silahkan masukkan URL yang sah. + + + Please enter a valid search term. + Silahkan masukkan kata pencarian yang sah. + + + Please provide a valid phone number. + Silahkan sediakan nomor telepon yang sah. + + + The checkbox has an invalid value. + Nilai dari checkbox tidak sah. + + + Please enter a valid email address. + Silahkan masukkan alamat surel yang sah. + + + Please select a valid option. + Silahkan pilih opsi yang sah. + + + Please select a valid range. + Silahkan pilih rentang yang sah. + + + Please enter a valid week. + Silahkan masukkan minggu yang sah. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.it.xlf b/lib/symfony/form/Resources/translations/validators.it.xlf new file mode 100644 index 000000000..bdea7132f --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.it.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Questo form non dovrebbe contenere nessun campo extra. + + + The uploaded file was too large. Please try to upload a smaller file. + Il file caricato è troppo grande. Per favore, carica un file più piccolo. + + + The CSRF token is invalid. Please try to resubmit the form. + Il token CSRF non è valido. Prova a reinviare il form. + + + This value is not a valid HTML5 color. + Il valore non è un colore HTML5 valido. + + + Please enter a valid birthdate. + Per favore, inserisci una data di compleanno valida. + + + The selected choice is invalid. + La scelta selezionata non è valida. + + + The collection is invalid. + La collezione non è valida. + + + Please select a valid color. + Per favore, seleziona un colore valido. + + + Please select a valid country. + Per favore, seleziona un paese valido. + + + Please select a valid currency. + Per favore, seleziona una valuta valida. + + + Please choose a valid date interval. + Per favore, scegli un intervallo di date valido. + + + Please enter a valid date and time. + Per favore, inserisci una data e ora valida. + + + Please enter a valid date. + Per favore, inserisci una data valida. + + + Please select a valid file. + Per favore, seleziona un file valido. + + + The hidden field is invalid. + Il campo nascosto non è valido. + + + Please enter an integer. + Per favore, inserisci un numero intero. + + + Please select a valid language. + Per favore, seleziona una lingua valida. + + + Please select a valid locale. + Per favore, seleziona una lingua valida. + + + Please enter a valid money amount. + Per favore, inserisci un importo valido. + + + Please enter a number. + Per favore, inserisci un numero. + + + The password is invalid. + La password non è valida. + + + Please enter a percentage value. + Per favore, inserisci un valore percentuale. + + + The values do not match. + I valori non corrispondono. + + + Please enter a valid time. + Per favore, inserisci un orario valido. + + + Please select a valid timezone. + Per favore, seleziona un fuso orario valido. + + + Please enter a valid URL. + Per favore, inserisci un URL valido. + + + Please enter a valid search term. + Per favore, inserisci un termine di ricerca valido. + + + Please provide a valid phone number. + Per favore, indica un numero di telefono valido. + + + The checkbox has an invalid value. + La casella di selezione non ha un valore valido. + + + Please enter a valid email address. + Per favore, indica un indirizzo email valido. + + + Please select a valid option. + Per favore, seleziona un'opzione valida. + + + Please select a valid range. + Per favore, seleziona un intervallo valido. + + + Please enter a valid week. + Per favore, inserisci una settimana valida. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.ja.xlf b/lib/symfony/form/Resources/translations/validators.ja.xlf new file mode 100644 index 000000000..5728d9b1d --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.ja.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + フィールドグループに追加のフィールドを含んではなりません。 + + + The uploaded file was too large. Please try to upload a smaller file. + アップロードされたファイルが大きすぎます。小さなファイルで再度アップロードしてください。 + + + The CSRF token is invalid. Please try to resubmit the form. + CSRFトークンが無効です、再送信してください。 + + + This value is not a valid HTML5 color. + 有効なHTML5の色ではありません。 + + + Please enter a valid birthdate. + 有効な生年月日を入力してください。 + + + The selected choice is invalid. + 選択した値は無効です。 + + + The collection is invalid. + コレクションは無効です。 + + + Please select a valid color. + 有効な色を選択してください。 + + + Please select a valid country. + 有効な国を選択してください。 + + + Please select a valid currency. + 有効な通貨を選択してください。 + + + Please choose a valid date interval. + 有効な日付間隔を選択してください。 + + + Please enter a valid date and time. + 有効な日時を入力してください。 + + + Please enter a valid date. + 有効な日付を入力してください。 + + + Please select a valid file. + 有効なファイルを選択してください。 + + + The hidden field is invalid. + 隠しフィールドが無効です。 + + + Please enter an integer. + 整数で入力してください。 + + + Please select a valid language. + 有効な言語を選択してください。 + + + Please select a valid locale. + 有効なロケールを選択してください。 + + + Please enter a valid money amount. + 有効な金額を入力してください。 + + + Please enter a number. + 数値で入力してください。 + + + The password is invalid. + パスワードが無効です。 + + + Please enter a percentage value. + パーセント値で入力してください。 + + + The values do not match. + 値が一致しません。 + + + Please enter a valid time. + 有効な時間を入力してください。 + + + Please select a valid timezone. + 有効なタイムゾーンを選択してください。 + + + Please enter a valid URL. + 有効なURLを入力してください。 + + + Please enter a valid search term. + 有効な検索語を入力してください。 + + + Please provide a valid phone number. + 有効な電話番号を入力してください。 + + + The checkbox has an invalid value. + チェックボックスの値が無効です。 + + + Please enter a valid email address. + 有効なメールアドレスを入力してください。 + + + Please select a valid option. + 有効な値を選択してください。 + + + Please select a valid range. + 有効な範囲を選択してください。 + + + Please enter a valid week. + 有効な週を入力してください。 + + + + diff --git a/lib/symfony/form/Resources/translations/validators.lb.xlf b/lib/symfony/form/Resources/translations/validators.lb.xlf new file mode 100644 index 000000000..1f4ee820b --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.lb.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Dës Feldergrupp sollt keng zousätzlech Felder enthalen. + + + The uploaded file was too large. Please try to upload a smaller file. + De geschécktene Fichier ass ze grouss. Versicht wann ech gelift ee méi klenge Fichier eropzelueden. + + + The CSRF token is invalid. Please try to resubmit the form. + Den CSRF-Token ass ongëlteg. Versicht wann ech gelift de Formulaire nach eng Kéier ze schécken. + + + This value is not a valid HTML5 color. + Dëse Wäert ass keng gëlteg HTML5-Faarf. + + + Please enter a valid birthdate. + W.e.g. e gëltege Gebuertsdatum aginn. + + + The selected choice is invalid. + Den ausgewielte Choix ass ongëlteg. + + + The collection is invalid. + D'Kollektioun ass ongëlteg. + + + Please select a valid color. + W.e.g. eng gëlteg Faarf auswielen. + + + Please select a valid country. + W.e.g. e gëltegt Land auswielen. + + + Please select a valid currency. + W.e.g. eng gëlteg Wärung auswielen. + + + Please choose a valid date interval. + W.e.g. e gëltegen Datumsinterval aginn. + + + Please enter a valid date and time. + W.e.g. eng gëlteg Datum an Zäit aginn. + + + Please enter a valid date. + W.e.g. eng gëltegen Datum aginn. + + + Please select a valid file. + W.e.g. e gëltege Fichier auswielen. + + + The hidden field is invalid. + Dat verstoppte Feld ass ongëlteg. + + + Please enter an integer. + W.e.g. eng ganz Zuel aginn. + + + Please select a valid language. + W.e.g. e gëltegt Sprooch auswielen. + + + Please select a valid locale. + W.e.g. e gëltegt Regionalschema auswielen. + + + Please enter a valid money amount. + W.e.g. eng gëlteg Geldzomm aginn. + + + Please enter a number. + W.e.g. eng Zuel aginn. + + + The password is invalid. + D'Passwuert ass ongëlteg. + + + Please enter a percentage value. + W.e.g. e Prozentwäert aginn. + + + The values do not match. + D'Wäerter stëmmen net iwwereneen. + + + Please enter a valid time. + W.e.g. eng gëlteg Zäit aginn. + + + Please select a valid timezone. + W.e.g. eng gëlteg Zäitzon auswielen. + + + Please enter a valid URL. + W.e.g. eng gëlteg URL aginn. + + + Please enter a valid search term. + W.e.g. e gëltege Sichbegrëff aginn. + + + Please provide a valid phone number. + W.e.g. eng gëlteg Telefonsnummer uginn. + + + The checkbox has an invalid value. + D'Ukräizfeld huet en ongëltege Wäert. + + + Please enter a valid email address. + W.e.g. eng gëlteg E-Mail-Adress aginn. + + + Please select a valid option. + W.e.g. eng gëlteg Optioun auswielen. + + + Please select a valid range. + W.e.g. eng gëlteg Spannbreet auswielen. + + + Please enter a valid week. + W.e.g. eng gëlteg Woch aginn. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.lt.xlf b/lib/symfony/form/Resources/translations/validators.lt.xlf new file mode 100644 index 000000000..aba1120e3 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.lt.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Forma negali turėti papildomų laukų. + + + The uploaded file was too large. Please try to upload a smaller file. + Įkelta byla yra per didelė. bandykite įkelti mažesnę. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF kodas nepriimtinas. Bandykite siųsti formos užklausą dar kartą. + + + This value is not a valid HTML5 color. + Ši reikšmė nėra HTML5 spalva. + + + Please enter a valid birthdate. + Prašome įvesti tinkamą gimimo datą. + + + The selected choice is invalid. + Pasirinktas pasirinkimas yra neteisingas. + + + The collection is invalid. + Neteisingas sąrašas. + + + Please select a valid color. + Prašome pasirinkti tinkamą spalvą. + + + Please select a valid country. + Prašome pasirinkti tinkamą šalį. + + + Please select a valid currency. + Prašome pasirinkti tinkamą valiutą. + + + Please choose a valid date interval. + Prašome pasirinkti tinkamą datos intervalą. + + + Please enter a valid date and time. + Prašome įvesti tinkamą datą ir laiką. + + + Please enter a valid date. + Prašome įvesti tinkamą datą. + + + Please select a valid file. + Prašome pasirinkti tinkamą bylą. + + + The hidden field is invalid. + Klaidingas paslėptasis laukas. + + + Please enter an integer. + Prašome įvesti sveiką skaičių. + + + Please select a valid language. + Prašome pasirinkti tinkamą kalbą. + + + Please select a valid locale. + Prašome pasirinkti tinkamą lokalę. + + + Please enter a valid money amount. + Prašome įvesti tinkamą pinigų sumą. + + + Please enter a number. + Prašome įvesti numerį. + + + The password is invalid. + Klaidingas slaptažodis. + + + Please enter a percentage value. + Prašome įvesti procentinę reikšmę. + + + The values do not match. + Reikšmės nesutampa. + + + Please enter a valid time. + Prašome įvesti tinkamą laiką. + + + Please select a valid timezone. + Prašome pasirinkti tinkamą laiko zoną. + + + Please enter a valid URL. + Prašome įvesti tinkamą URL. + + + Please enter a valid search term. + Prašome įvesti tinkamą paieškos terminą. + + + Please provide a valid phone number. + Prašome pateikti tinkamą telefono numerį. + + + The checkbox has an invalid value. + Klaidinga žymimajo langelio reikšmė. + + + Please enter a valid email address. + Prašome įvesti tinkamą el. pašto adresą. + + + Please select a valid option. + Prašome pasirinkti tinkamą parinktį. + + + Please select a valid range. + Prašome pasirinkti tinkamą diapozoną. + + + Please enter a valid week. + Prašome įvesti tinkamą savaitę. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.lv.xlf b/lib/symfony/form/Resources/translations/validators.lv.xlf new file mode 100644 index 000000000..fb358dccf --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.lv.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Šajā veidlapā nevajadzētu būt papildus ievades laukiem. + + + The uploaded file was too large. Please try to upload a smaller file. + Augšupielādētā faila izmērs bija par lielu. Lūdzu mēģiniet augšupielādēt mazāka izmēra failu. + + + The CSRF token is invalid. Please try to resubmit the form. + Dotais CSRF talons nav derīgs. Lūdzu mēģiniet vēlreiz iesniegt veidlapu. + + + This value is not a valid HTML5 color. + Šī vertība nav derīga HTML5 krāsa. + + + Please enter a valid birthdate. + Lūdzu, ievadiet derīgu dzimšanas datumu. + + + The selected choice is invalid. + Iezīmētā izvēle nav derīga. + + + The collection is invalid. + Kolekcija nav derīga. + + + Please select a valid color. + Lūdzu, izvēlieties derīgu krāsu. + + + Please select a valid country. + Lūdzu, izvēlieties derīgu valsti. + + + Please select a valid currency. + Lūdzu, izvēlieties derīgu valūtu. + + + Please choose a valid date interval. + Lūdzu, izvēlieties derīgu datumu intervālu. + + + Please enter a valid date and time. + Lūdzu, ievadiet derīgu datumu un laiku. + + + Please enter a valid date. + Lūdzu, ievadiet derīgu datumu. + + + Please select a valid file. + Lūdzu, izvēlieties derīgu failu. + + + The hidden field is invalid. + Slēptā lauka vērtība ir nederīga. + + + Please enter an integer. + Lūdzu, ievadiet veselu skaitli. + + + Please select a valid language. + Lūdzu, izvēlieties derīgu valodu. + + + Please select a valid locale. + Lūdzu, izvēlieties derīgu lokalizāciju. + + + Please enter a valid money amount. + Lūdzu, ievadiet derīgu naudas lielumu. + + + Please enter a number. + Lūdzu, ievadiet skaitli. + + + The password is invalid. + Parole ir nederīga. + + + Please enter a percentage value. + Lūdzu, ievadiet procentuālo lielumu. + + + The values do not match. + Vērtības nesakrīt. + + + Please enter a valid time. + Lūdzu, ievadiet derīgu laiku. + + + Please select a valid timezone. + Lūdzu, izvēlieties derīgu laika zonu. + + + Please enter a valid URL. + Lūdzu, ievadiet derīgu URL. + + + Please enter a valid search term. + Lūdzu, ievadiet derīgu meklēšanas nosacījumu. + + + Please provide a valid phone number. + Lūdzu, ievadiet derīgu tālruņa numuru. + + + The checkbox has an invalid value. + Izvēles rūtiņai ir nederīga vērtība. + + + Please enter a valid email address. + Lūdzu, ievadiet derīgu e-pasta adresi. + + + Please select a valid option. + Lūdzu, izvēlieties derīgu opciju. + + + Please select a valid range. + Lūdzu, izvēlieties derīgu diapazonu. + + + Please enter a valid week. + Lūdzu, ievadiet derīgu nedēļu. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.mk.xlf b/lib/symfony/form/Resources/translations/validators.mk.xlf new file mode 100644 index 000000000..5f2af85eb --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.mk.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Оваа форма не треба да содржи дополнителни полиња. + + + The uploaded file was too large. Please try to upload a smaller file. + Датотеката што се обидовте да ја подигнете е преголема. Ве молиме обидете се со помала датотека. + + + The CSRF token is invalid. Please try to resubmit the form. + Вашиот CSRF токен е невалиден. Ве молиме испратете ја формата одново. + + + This value is not a valid HTML5 color. + Оваа вредност не е валидна HTML5 боја. + + + Please enter a valid birthdate. + Ве молиме внесете валидна дата на раѓање. + + + The selected choice is invalid. + Избраната опција е невалидна. + + + The collection is invalid. + Колекцијата е невалидна. + + + Please select a valid color. + Ве молиме одберете валидна боја. + + + Please select a valid country. + Ве молиме одберете валидна земја. + + + Please select a valid currency. + Ве молиме одберете валидна валута. + + + Please choose a valid date interval. + Ве молиме одберете валиден интервал помеѓу два датума. + + + Please enter a valid date and time. + Ве молиме внесете валиден датум и време. + + + Please enter a valid date. + Ве молиме внесете валиден датум. + + + Please select a valid file. + Ве молиме одберете валидна датотека. + + + The hidden field is invalid. + Скриеното поле е невалидно. + + + Please enter an integer. + Ве молиме внесете цел број. + + + Please select a valid language. + Ве молиме одберете валиден јазик. + + + Please select a valid locale. + Ве молиме одберете валидна локализација. + + + Please enter a valid money amount. + Ве молиме внесете валидна сума на пари. + + + Please enter a number. + Ве молиме внесете број. + + + The password is invalid. + Лозинката е погрешна. + + + Please enter a percentage value. + Ве молиме внесете процентуална вредност. + + + The values do not match. + Вредностите не се совпаѓаат. + + + Please enter a valid time. + Ве молиме внесете валидно време. + + + Please select a valid timezone. + Ве молиме одберете валидна временска зона. + + + Please enter a valid URL. + Ве молиме внесете валиден униформен локатор на ресурси (URL). + + + Please enter a valid search term. + Ве молиме внесете валиден термин за пребарување. + + + Please provide a valid phone number. + Ве молиме внесете валиден телефонски број. + + + The checkbox has an invalid value. + Полето за штиклирање има неважечка вредност. + + + Please enter a valid email address. + Ве молиме внесете валидна адреса за е-пошта. + + + Please select a valid option. + Ве молиме одберете валидна опција. + + + Please select a valid range. + Ве молиме одберете важечки опсег. + + + Please enter a valid week. + Ве молиме внесете валидна недела. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.mn.xlf b/lib/symfony/form/Resources/translations/validators.mn.xlf new file mode 100644 index 000000000..2e6d09bc6 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.mn.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Форм нэмэлт талбар багтаах боломжгүй. + + + The uploaded file was too large. Please try to upload a smaller file. + Upload хийсэн файл хэтэрхий том байна. Бага хэмжээтэй файл оруулна уу. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF token буруу байна. Формоо дахин илгээнэ үү. + + + This value is not a valid HTML5 color. + Энэ утга зөв HTML5 өнгө биш байна. + + + Please enter a valid birthdate. + Зөв төрсөн он сар оруулна уу. + + + The selected choice is invalid. + Сонгосон утга буруу байна. + + + The collection is invalid. + Цуглуулга буруу байна. + + + Please select a valid color. + Үнэн зөв өнгө сонгоно уу. + + + Please select a valid country. + Үнэн зөв улс сонгоно уу. + + + Please select a valid currency. + Үнэн зөв мөнгөн тэмдэгт сонгоно уу. + + + Please choose a valid date interval. + Үнэн зөв цагын зай сонгоно уу. + + + Please enter a valid date and time. + Үнэн зөв он цаг оруулна уу. + + + Please enter a valid date. + Үнэн зөв он цаг өдөр оруулна уу. + + + Please select a valid file. + Үнэн зөв файл сонгоно уу. + + + The hidden field is invalid. + Нууц талбарын утга буруу байна. + + + Please enter an integer. + Бүхэл тоо оруулна уу. + + + Please select a valid language. + Үнэн зөв хэл сонгоно уу. + + + Please select a valid locale. + Үнэн зөв бүс сонгоно уу. + + + Please enter a valid money amount. + Үнэн зөв мөнгөний хэмжээ сонгоно уу. + + + Please enter a number. + Тоо оруулна уу. + + + The password is invalid. + Нууц үг буруу байна. + + + Please enter a percentage value. + Хувь утга оруулна уу. + + + The values do not match. + Утга хоорондоо таарахгүй байна. + + + Please enter a valid time. + Үнэн зөв цаг оруулна уу. + + + Please select a valid timezone. + Үнэн зөв цагын бүс оруулна уу. + + + Please enter a valid URL. + Үнэн зөв URL оруулна уу. + + + Please enter a valid search term. + Үнэн зөв хайх утга оруулна уу. + + + Please provide a valid phone number. + Үнэн зөв утасны дугаар оруулна уу. + + + The checkbox has an invalid value. + Сонгох хайрцаг буруу утгатай байна. + + + Please enter a valid email address. + Үнэн зөв и-мэйл хаяг оруулна уу. + + + Please select a valid option. + Үнэн зөв сонголт сонгоно уу. + + + Please select a valid range. + Үнэн зөв хязгаарын утга сонгоно уу. + + + Please enter a valid week. + Үнэн зөв долоо хоног сонгоно уу. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.my.xlf b/lib/symfony/form/Resources/translations/validators.my.xlf new file mode 100644 index 000000000..9ecb9d368 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.my.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + ဤ ဖောင်သည် field အပိုများ မပါ၀င်သင့်ပါ။ + + + The uploaded file was too large. Please try to upload a smaller file. + Upload တင်သောဖိုင်သည်အလွန်ကြီးလွန်းသည်။ ကျေးဇူးပြု၍ သေးငယ်သည့်ဖိုင်ကိုတင်ရန်ကြိုးစားပါ။ + + + The CSRF token is invalid. Please try to resubmit the form. + သင့်လျှော်သော် CSRF တိုကင် မဟုတ်ပါ။ ကျေးဇူးပြု၍ဖောင်ကိုပြန်တင်ပါ။ + + + This value is not a valid HTML5 color. + ဤတန်ဖိုးသည် သင့်လျှော်သော် HTML5 အရောင်မဟုတ်ပါ။ + + + Please enter a valid birthdate. + ကျေးဇူးပြု၍ မှန်ကန်သောမွေးနေ့ကိုထည့်ပါ။ + + + The selected choice is invalid. + သင့် ရွေးချယ်မှုသည်မမှန်ကန်ပါ။ + + + The collection is invalid. + ဤ collection သည်သင့်လျှော်သော် collection မဟုတ်ပါ။ + + + Please select a valid color. + ကျေးဇူးပြု၍ မှန်ကန်သောအရောင်ကိုရွေးပါ။ + + + Please select a valid country. + ကျေးဇူးပြု၍ မှန်ကန်သောနိုင်ငံကိုရွေးပါ။ + + + Please select a valid currency. + ကျေးဇူးပြု၍ မှန်ကန်သောငွေကြေးကိုရွေးပါ။ + + + Please choose a valid date interval. + ကျေးဇူးပြု၍ မှန်ကန်သောနေ ရက်စွဲကိုရွေးပါ။ + + + Please enter a valid date and time. + ကျေးဇူးပြု၍ မှန်ကန်သောနေ ရက်စွဲနှင့်အချိန် ကိုထည့်ပါ။ + + + Please enter a valid date. + ကျေးဇူးပြု၍ မှန်ကန်သောနေ ရက်စွဲကိုထည့်ပါ။ + + + Please select a valid file. + ကျေးဇူးပြု၍ မှန်ကန်သောနေ ဖိုင်ကိုရွေးချယ်ပါ။ + + + The hidden field is invalid. + မသင့် လျှော်သော် hidden field ဖြစ်နေသည်။ + + + Please enter an integer. + ကျေးဇူးပြု၍ Integer တန်ဖိုးသာထည့်ပါ။ + + + Please select a valid language. + ကျေးဇူးပြု၍ မှန်ကန်သော ဘာသာစကားကိုရွေးချယ်ပါ။ + + + Please select a valid locale. + ကျေးဇူးပြု၍ မှန်ကန်သော locale ကိုရွေးချယ်ပါ။ + + + Please enter a valid money amount. + ကျေးဇူးပြု၍ မှန်ကန်သော ပိုက်ဆံပမာဏ ကိုထည့်ပါ။ + + + Please enter a number. + ကျေးဇူးပြု၍ မှန်ကန်သော နံပါတ် ကိုရွေးချယ်ပါ။ + + + The password is invalid. + မှန်ကန်သောစကား၀ှက်မဟုတ်ပါ။ + + + Please enter a percentage value. + ကျေးဇူးပြု၍ ရာခိုင်နှုန်းတန်ဖိုးထည့်ပါ။ + + + The values do not match. + တန်ဖိုးများကိုက်ညီမှုမရှိပါ။ + + + Please enter a valid time. + ကျေးဇူးပြု၍ မှန်ကန်သောအချိန်ကိုထည့်ပါ။ + + + Please select a valid timezone. + ကျေးဇူးပြု၍ မှန်ကန်သောအချိန်ဇုန်ကိုရွေးပါ။ + + + Please enter a valid URL. + ကျေးဇူးပြု၍ သင့်လျှော်သော် URL ကိုရွေးပါ။ + + + Please enter a valid search term. + ကျေးဇူးပြု၍ သင့် လျှော်သော်ရှာဖွေမှု term များထည့်ပါ။ + + + Please provide a valid phone number. + ကျေးဇူးပြု၍ သင့် လျှော်သော်ရှာဖွေမှု ဖုန်းနံပါတ်ထည့်ပါ။ + + + The checkbox has an invalid value. + Checkbox တန်ဖိုးသည် မှန်ကန်မှုမရှိပါ။ + + + Please enter a valid email address. + ကျေးဇူးပြု၍ မှန်ကန်သော် email လိပ်စာထည့်ပါ။ + + + Please select a valid option. + ကျေးဇူးပြု၍ မှန်ကန်သော် ရွေးချယ်မှု ကိုရွေးပါ။ + + + Please select a valid range. + ကျေးဇူးပြု၍ မှန်ကန်သော အပိုင်းအခြား ကိုရွေးပါ။ + + + Please enter a valid week. + ကျေးဇူးပြု၍ မှန်ကန်သောရက်သတ္တပတ်ကိုထည့်ပါ။ + + + + diff --git a/lib/symfony/form/Resources/translations/validators.nb.xlf b/lib/symfony/form/Resources/translations/validators.nb.xlf new file mode 100644 index 000000000..193306b71 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.nb.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Feltgruppen må ikke inneholde ekstra felter. + + + The uploaded file was too large. Please try to upload a smaller file. + Den opplastede filen var for stor. Vennligst last opp en mindre fil. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-tokenen er ugyldig. Vennligst prøv å sende inn skjemaet på nytt. + + + This value is not a valid HTML5 color. + Denne verdien er ikke en gyldig HTML5-farge. + + + Please enter a valid birthdate. + Vennligst oppgi gyldig fødselsdato. + + + The selected choice is invalid. + Det valgte valget er ugyldig. + + + The collection is invalid. + Samlingen er ugyldig. + + + Please select a valid color. + Velg en gyldig farge. + + + Please select a valid country. + Vennligst velg et gyldig land. + + + Please select a valid currency. + Vennligst velg en gyldig valuta. + + + Please choose a valid date interval. + Vennligst velg et gyldig datointervall. + + + Please enter a valid date and time. + Vennligst angi en gyldig dato og tid. + + + Please enter a valid date. + Vennligst oppgi en gyldig dato. + + + Please select a valid file. + Vennligst velg en gyldig fil. + + + The hidden field is invalid. + Det skjulte feltet er ugyldig. + + + Please enter an integer. + Vennligst skriv inn et heltall. + + + Please select a valid language. + Vennligst velg et gyldig språk. + + + Please select a valid locale. + Vennligst velg et gyldig sted. + + + Please enter a valid money amount. + Vennligst angi et gyldig pengebeløp. + + + Please enter a number. + Vennligst skriv inn et nummer. + + + The password is invalid. + Passordet er ugyldig. + + + Please enter a percentage value. + Vennligst angi en prosentverdi. + + + The values do not match. + Verdiene stemmer ikke overens. + + + Please enter a valid time. + Vennligst angi et gyldig tidspunkt. + + + Please select a valid timezone. + Vennligst velg en gyldig tidssone. + + + Please enter a valid URL. + Vennligst skriv inn en gyldig URL. + + + Please enter a valid search term. + Vennligst angi et gyldig søketerm. + + + Please provide a valid phone number. + Vennligst oppgi et gyldig telefonnummer. + + + The checkbox has an invalid value. + Avkrysningsboksen har en ugyldig verdi. + + + Please enter a valid email address. + Vennligst skriv inn en gyldig e-post adresse. + + + Please select a valid option. + Vennligst velg et gyldig alternativ. + + + Please select a valid range. + Vennligst velg et gyldig område. + + + Please enter a valid week. + Vennligst skriv inn en gyldig uke. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.nl.xlf b/lib/symfony/form/Resources/translations/validators.nl.xlf new file mode 100644 index 000000000..6330ecf8a --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.nl.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Dit formulier mag geen extra velden bevatten. + + + The uploaded file was too large. Please try to upload a smaller file. + Het geüploade bestand is te groot. Probeer een kleiner bestand te uploaden. + + + The CSRF token is invalid. Please try to resubmit the form. + De CSRF-token is ongeldig. Probeer het formulier opnieuw te versturen. + + + This value is not a valid HTML5 color. + Dit is geen geldige HTML5 kleur. + + + Please enter a valid birthdate. + Vul een geldige geboortedatum in. + + + The selected choice is invalid. + Deze keuze is ongeldig. + + + The collection is invalid. + Deze collectie is ongeldig. + + + Please select a valid color. + Kies een geldige kleur. + + + Please select a valid country. + Kies een geldige landnaam. + + + Please select a valid currency. + Kies een geldige valuta. + + + Please choose a valid date interval. + Kies een geldig tijdinterval. + + + Please enter a valid date and time. + Vul een geldige datum en tijd in. + + + Please enter a valid date. + Vul een geldige datum in. + + + Please select a valid file. + Kies een geldig bestand. + + + The hidden field is invalid. + Het verborgen veld is incorrect. + + + Please enter an integer. + Vul een geldig getal in. + + + Please select a valid language. + Kies een geldige taal. + + + Please select a valid locale. + Kies een geldige locale. + + + Please enter a valid money amount. + Vul een geldig bedrag in. + + + Please enter a number. + Vul een geldig getal in. + + + The password is invalid. + Het wachtwoord is incorrect. + + + Please enter a percentage value. + Vul een geldig percentage in. + + + The values do not match. + De waardes komen niet overeen. + + + Please enter a valid time. + Vul een geldige tijd in. + + + Please select a valid timezone. + Vul een geldige tijdzone in. + + + Please enter a valid URL. + Vul een geldige URL in. + + + Please enter a valid search term. + Vul een geldige zoekterm in. + + + Please provide a valid phone number. + Vul een geldig telefoonnummer in. + + + The checkbox has an invalid value. + De checkbox heeft een incorrecte waarde. + + + Please enter a valid email address. + Vul een geldig e-mailadres in. + + + Please select a valid option. + Kies een geldige optie. + + + Please select a valid range. + Kies een geldig bereik. + + + Please enter a valid week. + Vul een geldige week in. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.nn.xlf b/lib/symfony/form/Resources/translations/validators.nn.xlf new file mode 100644 index 000000000..0722b4568 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.nn.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Feltgruppa kan ikkje innehalde ekstra felt. + + + The uploaded file was too large. Please try to upload a smaller file. + Fila du lasta opp var for stor. Last opp ei mindre fil. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-teiknet er ugyldig. Ver venleg og prøv å sende inn skjemaet på nytt. + + + This value is not a valid HTML5 color. + Verdien er ikkje ein gyldig HTML5-farge. + + + Please enter a valid birthdate. + Gje opp ein gyldig fødselsdato. + + + The selected choice is invalid. + Valget du gjorde er ikkje gyldig. + + + The collection is invalid. + Samlinga er ikkje gyldig. + + + Please select a valid color. + Gje opp ein gyldig farge. + + + Please select a valid country. + Gje opp eit gyldig land. + + + Please select a valid currency. + Gje opp ein gyldig valuta. + + + Please choose a valid date interval. + Gje opp eit gyldig datointervall. + + + Please enter a valid date and time. + Gje opp ein gyldig dato og tid. + + + Please enter a valid date. + Gje opp ein gyldig dato. + + + Please select a valid file. + Velg ei gyldig fil. + + + The hidden field is invalid. + Det skjulte feltet er ikkje gyldig. + + + Please enter an integer. + Gje opp eit heiltal. + + + Please select a valid language. + Gje opp eit gyldig språk. + + + Please select a valid locale. + Gje opp eit gyldig locale. + + + Please enter a valid money amount. + Gje opp ein gyldig sum pengar. + + + Please enter a number. + Gje opp eit nummer. + + + The password is invalid. + Passordet er ikkje gyldig. + + + Please enter a percentage value. + Gje opp ein prosentverdi. + + + The values do not match. + Verdiane er ikkje eins. + + + Please enter a valid time. + Gje opp ei gyldig tid. + + + Please select a valid timezone. + Gje opp ei gyldig tidssone. + + + Please enter a valid URL. + Gje opp ein gyldig URL. + + + Please enter a valid search term. + Gje opp gyldige søkjeord. + + + Please provide a valid phone number. + Gje opp eit gyldig telefonnummer. + + + The checkbox has an invalid value. + Sjekkboksen har ein ugyldig verdi. + + + Please enter a valid email address. + Gje opp ei gyldig e-postadresse. + + + Please select a valid option. + Velg eit gyldig vilkår. + + + Please select a valid range. + Velg eit gyldig spenn. + + + Please enter a valid week. + Gje opp ei gyldig veke. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.no.xlf b/lib/symfony/form/Resources/translations/validators.no.xlf new file mode 100644 index 000000000..193306b71 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.no.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Feltgruppen må ikke inneholde ekstra felter. + + + The uploaded file was too large. Please try to upload a smaller file. + Den opplastede filen var for stor. Vennligst last opp en mindre fil. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-tokenen er ugyldig. Vennligst prøv å sende inn skjemaet på nytt. + + + This value is not a valid HTML5 color. + Denne verdien er ikke en gyldig HTML5-farge. + + + Please enter a valid birthdate. + Vennligst oppgi gyldig fødselsdato. + + + The selected choice is invalid. + Det valgte valget er ugyldig. + + + The collection is invalid. + Samlingen er ugyldig. + + + Please select a valid color. + Velg en gyldig farge. + + + Please select a valid country. + Vennligst velg et gyldig land. + + + Please select a valid currency. + Vennligst velg en gyldig valuta. + + + Please choose a valid date interval. + Vennligst velg et gyldig datointervall. + + + Please enter a valid date and time. + Vennligst angi en gyldig dato og tid. + + + Please enter a valid date. + Vennligst oppgi en gyldig dato. + + + Please select a valid file. + Vennligst velg en gyldig fil. + + + The hidden field is invalid. + Det skjulte feltet er ugyldig. + + + Please enter an integer. + Vennligst skriv inn et heltall. + + + Please select a valid language. + Vennligst velg et gyldig språk. + + + Please select a valid locale. + Vennligst velg et gyldig sted. + + + Please enter a valid money amount. + Vennligst angi et gyldig pengebeløp. + + + Please enter a number. + Vennligst skriv inn et nummer. + + + The password is invalid. + Passordet er ugyldig. + + + Please enter a percentage value. + Vennligst angi en prosentverdi. + + + The values do not match. + Verdiene stemmer ikke overens. + + + Please enter a valid time. + Vennligst angi et gyldig tidspunkt. + + + Please select a valid timezone. + Vennligst velg en gyldig tidssone. + + + Please enter a valid URL. + Vennligst skriv inn en gyldig URL. + + + Please enter a valid search term. + Vennligst angi et gyldig søketerm. + + + Please provide a valid phone number. + Vennligst oppgi et gyldig telefonnummer. + + + The checkbox has an invalid value. + Avkrysningsboksen har en ugyldig verdi. + + + Please enter a valid email address. + Vennligst skriv inn en gyldig e-post adresse. + + + Please select a valid option. + Vennligst velg et gyldig alternativ. + + + Please select a valid range. + Vennligst velg et gyldig område. + + + Please enter a valid week. + Vennligst skriv inn en gyldig uke. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.pl.xlf b/lib/symfony/form/Resources/translations/validators.pl.xlf new file mode 100644 index 000000000..767f05d29 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.pl.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ten formularz nie powinien zawierać dodatkowych pól. + + + The uploaded file was too large. Please try to upload a smaller file. + Wgrany plik był za duży. Proszę spróbować wgrać mniejszy plik. + + + The CSRF token is invalid. Please try to resubmit the form. + Token CSRF jest nieprawidłowy. Proszę spróbować wysłać formularz ponownie. + + + This value is not a valid HTML5 color. + Ta wartość nie jest prawidłowym kolorem HTML5. + + + Please enter a valid birthdate. + Proszę wprowadzić prawidłową datę urodzenia. + + + The selected choice is invalid. + Wybrana wartość jest nieprawidłowa. + + + The collection is invalid. + Zbiór jest nieprawidłowy. + + + Please select a valid color. + Proszę wybrać prawidłowy kolor. + + + Please select a valid country. + Proszę wybrać prawidłowy kraj. + + + Please select a valid currency. + Proszę wybrać prawidłową walutę. + + + Please choose a valid date interval. + Proszę wybrać prawidłowy przedział czasowy. + + + Please enter a valid date and time. + Proszę wprowadzić prawidłową datę i czas. + + + Please enter a valid date. + Proszę wprowadzić prawidłową datę. + + + Please select a valid file. + Proszę wybrać prawidłowy plik. + + + The hidden field is invalid. + Ukryte pole jest nieprawidłowe. + + + Please enter an integer. + Proszę wprowadzić liczbę całkowitą. + + + Please select a valid language. + Proszę wybrać prawidłowy język. + + + Please select a valid locale. + Proszę wybrać prawidłową lokalizację. + + + Please enter a valid money amount. + Proszę wybrać prawidłową ilość pieniędzy. + + + Please enter a number. + Proszę wprowadzić liczbę. + + + The password is invalid. + Hasło jest nieprawidłowe. + + + Please enter a percentage value. + Proszę wprowadzić wartość procentową. + + + The values do not match. + Wartości się nie zgadzają. + + + Please enter a valid time. + Proszę wprowadzić prawidłowy czas. + + + Please select a valid timezone. + Proszę wybrać prawidłową strefę czasową. + + + Please enter a valid URL. + Proszę wprowadzić prawidłowy adres URL. + + + Please enter a valid search term. + Proszę wprowadzić prawidłowy termin wyszukiwania. + + + Please provide a valid phone number. + Proszę wprowadzić prawidłowy numer telefonu. + + + The checkbox has an invalid value. + Pole wyboru posiada nieprawidłową wartość. + + + Please enter a valid email address. + Proszę wprowadzić prawidłowy adres email. + + + Please select a valid option. + Proszę wybrać prawidłową opcję. + + + Please select a valid range. + Proszę wybrać prawidłowy zakres. + + + Please enter a valid week. + Proszę wybrać prawidłowy tydzień. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.pt.xlf b/lib/symfony/form/Resources/translations/validators.pt.xlf new file mode 100644 index 000000000..755108f35 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.pt.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Este formulário não deveria possuir mais campos. + + + The uploaded file was too large. Please try to upload a smaller file. + O ficheiro enviado é muito grande. Por favor, tente enviar um ficheiro menor. + + + The CSRF token is invalid. Please try to resubmit the form. + O token CSRF está inválido. Por favor, tente enviar o formulário novamente. + + + This value is not a valid HTML5 color. + Este valor não é uma cor HTML5 válida. + + + Please enter a valid birthdate. + Por favor, informe uma data de nascimento válida. + + + The selected choice is invalid. + A escolha seleccionada é inválida. + + + The collection is invalid. + A coleção é inválida. + + + Please select a valid color. + Por favor, selecione uma cor válida. + + + Please select a valid country. + Por favor, selecione um país válido. + + + Please select a valid currency. + Por favor, selecione uma moeda válida. + + + Please choose a valid date interval. + Por favor, escolha um intervalo de datas válido. + + + Please enter a valid date and time. + Por favor, informe uma data e horário válidos. + + + Please enter a valid date. + Por favor, informe uma data válida. + + + Please select a valid file. + Por favor, selecione um ficheiro válido. + + + The hidden field is invalid. + O campo oculto é inválido. + + + Please enter an integer. + Por favor, informe um inteiro. + + + Please select a valid language. + Por favor selecione um idioma válido. + + + Please select a valid locale. + Por favor, selecione um locale válido. + + + Please enter a valid money amount. + Por favor, informe um valor monetário válido. + + + Please enter a number. + Por favor, informe um número. + + + The password is invalid. + A palavra-passe é inválida. + + + Please enter a percentage value. + Por favor, informe um valor percentual. + + + The values do not match. + Os valores não correspondem. + + + Please enter a valid time. + Por favor, informe uma hora válida. + + + Please select a valid timezone. + Por favor, selecione um fuso horário válido. + + + Please enter a valid URL. + Por favor, informe uma URL válida. + + + Please enter a valid search term. + Por favor, informe um termo de busca válido. + + + Please provide a valid phone number. + Por favor, infome um número de telefone válido. + + + The checkbox has an invalid value. + O checkbox possui um valor inválido. + + + Please enter a valid email address. + Por favor, informe um endereço de email válido. + + + Please select a valid option. + Por favor, selecione uma opção válida. + + + Please select a valid range. + Por favor, selecione um intervalo válido. + + + Please enter a valid week. + Por favor, selecione uma semana válida. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.pt_BR.xlf b/lib/symfony/form/Resources/translations/validators.pt_BR.xlf new file mode 100644 index 000000000..c386ab304 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.pt_BR.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Este formulário não deve conter campos adicionais. + + + The uploaded file was too large. Please try to upload a smaller file. + O arquivo enviado é muito grande. Por favor, tente enviar um arquivo menor. + + + The CSRF token is invalid. Please try to resubmit the form. + O token CSRF é inválido. Por favor, tente reenviar o formulário. + + + This value is not a valid HTML5 color. + Este valor não é uma cor HTML5 válida. + + + Please enter a valid birthdate. + Por favor, informe uma data de nascimento válida. + + + The selected choice is invalid. + A escolha selecionada é inválida. + + + The collection is invalid. + A coleção é inválida. + + + Please select a valid color. + Por favor, selecione uma cor válida. + + + Please select a valid country. + Por favor, selecione um país válido. + + + Please select a valid currency. + Por favor, selecione uma moeda válida. + + + Please choose a valid date interval. + Por favor, escolha um intervalo de datas válido. + + + Please enter a valid date and time. + Por favor, informe uma data e horário válidos. + + + Please enter a valid date. + Por favor, informe uma data válida. + + + Please select a valid file. + Por favor, selecione um arquivo válido. + + + The hidden field is invalid. + O campo oculto é inválido. + + + Please enter an integer. + Por favor, informe um número inteiro. + + + Please select a valid language. + Por favor, selecione um idioma válido. + + + Please select a valid locale. + Por favor, selecione uma configuração de local válida. + + + Please enter a valid money amount. + Por favor, informe um valor monetário válido. + + + Please enter a number. + Por favor, informe um número. + + + The password is invalid. + A senha é inválida. + + + Please enter a percentage value. + Por favor, informe um valor percentual. + + + The values do not match. + Os valores não conferem. + + + Please enter a valid time. + Por favor, informe um horário válido. + + + Please select a valid timezone. + Por favor, selecione um fuso horário válido. + + + Please enter a valid URL. + Por favor, informe uma URL válida. + + + Please enter a valid search term. + Por favor, informe um termo de busca válido. + + + Please provide a valid phone number. + Por favor, informe um telefone válido. + + + The checkbox has an invalid value. + A caixa de seleção possui um valor inválido. + + + Please enter a valid email address. + Por favor, informe um endereço de e-mail válido. + + + Please select a valid option. + Por favor, selecione uma opção válida. + + + Please select a valid range. + Por favor, selecione um intervalo válido. + + + Please enter a valid week. + Por favor, informe uma semana válida. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.ro.xlf b/lib/symfony/form/Resources/translations/validators.ro.xlf new file mode 100644 index 000000000..63b4c551f --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.ro.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Acest formular nu ar trebui să conțină câmpuri suplimentare. + + + The uploaded file was too large. Please try to upload a smaller file. + Fișierul încărcat a fost prea mare. Vă rugăm sa încărcați un fișier mai mic. + + + The CSRF token is invalid. Please try to resubmit the form. + Token-ul CSRF este invalid. Vă rugăm să retrimiteți formularul. + + + This value is not a valid HTML5 color. + Această valoare nu este un cod de culoare HTML5 valid. + + + Please enter a valid birthdate. + Vă rugăm să introduceți o dată de naștere validă. + + + The selected choice is invalid. + Valoarea selectată este invalidă. + + + The collection is invalid. + Colecția nu este validă. + + + Please select a valid color. + Vă rugăm să selectați o culoare validă. + + + Please select a valid country. + Vă rugăm să selectați o țară validă. + + + Please select a valid currency. + Vă rugăm să selectați o monedă validă. + + + Please choose a valid date interval. + Vă rugăm să selectați un interval de zile valid. + + + Please enter a valid date and time. + Vă rugăm să introduceți o dată și o oră validă. + + + Please enter a valid date. + Vă rugăm să introduceți o dată validă. + + + Please select a valid file. + Vă rugăm să selectați un fișier valid. + + + The hidden field is invalid. + Câmpul ascuns este invalid. + + + Please enter an integer. + Vă rugăm să introduceți un număr întreg. + + + Please select a valid language. + Vă rugăm să selectați o limbă validă. + + + Please select a valid locale. + Vă rugăm să selectați o setare locală validă. + + + Please enter a valid money amount. + Vă rugăm să introduceți o valoare monetară corectă. + + + Please enter a number. + Vă rugăm să introduceți un număr. + + + The password is invalid. + Parola nu este validă. + + + Please enter a percentage value. + Vă rugăm să introduceți o valoare procentuală. + + + The values do not match. + Valorile nu coincid. + + + Please enter a valid time. + Vă rugăm să introduceți o oră validă. + + + Please select a valid timezone. + Vă rugăm să selectați un fus orar valid. + + + Please enter a valid URL. + Vă rugăm să introduceți un URL valid. + + + Please enter a valid search term. + Vă rugăm să introduceți un termen de căutare valid. + + + Please provide a valid phone number. + Vă rugăm să introduceți un număr de telefon valid. + + + The checkbox has an invalid value. + Bifa nu are o valoare validă. + + + Please enter a valid email address. + Vă rugăm să introduceți o adresă de email validă. + + + Please select a valid option. + Vă rugăm să selectați o opțiune validă. + + + Please select a valid range. + Vă rugăm să selectați un interval valid. + + + Please enter a valid week. + Vă rugăm să introduceți o săptămână validă. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.ru.xlf b/lib/symfony/form/Resources/translations/validators.ru.xlf new file mode 100644 index 000000000..26535d26d --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.ru.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Эта форма не должна содержать дополнительных полей. + + + The uploaded file was too large. Please try to upload a smaller file. + Загруженный файл слишком большой. Пожалуйста, попробуйте загрузить файл меньшего размера. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF значение недопустимо. Пожалуйста, попробуйте повторить отправку формы. + + + This value is not a valid HTML5 color. + Значение не является допустимым HTML5 цветом. + + + Please enter a valid birthdate. + Пожалуйста, введите действительную дату рождения. + + + The selected choice is invalid. + Выбранный вариант недопустим. + + + The collection is invalid. + Коллекция недопустима. + + + Please select a valid color. + Пожалуйста, выберите допустимый цвет. + + + Please select a valid country. + Пожалуйста, выберите действительную страну. + + + Please select a valid currency. + Пожалуйста, выберите действительную валюту. + + + Please choose a valid date interval. + Пожалуйста, выберите действительный период. + + + Please enter a valid date and time. + Пожалуйста, введите действительные дату и время. + + + Please enter a valid date. + Пожалуйста, введите действительную дату. + + + Please select a valid file. + Пожалуйста, выберите допустимый файл. + + + The hidden field is invalid. + Значение скрытого поля недопустимо. + + + Please enter an integer. + Пожалуйста, введите целое число. + + + Please select a valid language. + Пожалуйста, выберите допустимый язык. + + + Please select a valid locale. + Пожалуйста, выберите допустимую локаль. + + + Please enter a valid money amount. + Пожалуйста, введите допустимое количество денег. + + + Please enter a number. + Пожалуйста, введите номер. + + + The password is invalid. + Пароль недействителен. + + + Please enter a percentage value. + Пожалуйста, введите процентное значение. + + + The values do not match. + Значения не совпадают. + + + Please enter a valid time. + Пожалуйста, введите действительное время. + + + Please select a valid timezone. + Пожалуйста, выберите действительный часовой пояс. + + + Please enter a valid URL. + Пожалуйста, введите действительный URL. + + + Please enter a valid search term. + Пожалуйста, введите действительный поисковый запрос. + + + Please provide a valid phone number. + Пожалуйста, введите действительный номер телефона. + + + The checkbox has an invalid value. + Флажок имеет недопустимое значение. + + + Please enter a valid email address. + Пожалуйста, введите допустимый email адрес. + + + Please select a valid option. + Пожалуйста, выберите допустимый вариант. + + + Please select a valid range. + Пожалуйста, выберите допустимый диапазон. + + + Please enter a valid week. + Пожалуйста, введите действительную неделю. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.sk.xlf b/lib/symfony/form/Resources/translations/validators.sk.xlf new file mode 100644 index 000000000..72ecd13e1 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.sk.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Polia by nemali obsahovať ďalšie prvky. + + + The uploaded file was too large. Please try to upload a smaller file. + Odoslaný súbor je príliš veľký. Prosím odošlite súbor s menšou veľkosťou. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF token je neplatný. Prosím skúste znovu odoslať formulár. + + + This value is not a valid HTML5 color. + Táto hodnota nie je platná HTML5 farba. + + + Please enter a valid birthdate. + Prosím zadajte platný dátum narodenia. + + + The selected choice is invalid. + Vybraná možnosť je neplatná. + + + The collection is invalid. + Kolekcia je neplatná. + + + Please select a valid color. + Prosím vyberte platnú farbu. + + + Please select a valid country. + Prosím vyberte platnú krajinu. + + + Please select a valid currency. + Prosím vyberte platnú menu. + + + Please choose a valid date interval. + Prosím vyberte platný rozsah dát. + + + Please enter a valid date and time. + Prosím zadajte platný dátum a čas. + + + Please enter a valid date. + Prosím zadajte platný dátum. + + + Please select a valid file. + Prosím vyberte platný súbor. + + + The hidden field is invalid. + Skryté pole je neplatné. + + + Please enter an integer. + Prosím zadajte celé číslo. + + + Please select a valid language. + Prosím vyberte platný jazyk. + + + Please select a valid locale. + Prosím vyberte platné miestne nastavenia. + + + Please enter a valid money amount. + Prosím zadajte platnú čiastku. + + + Please enter a number. + Prosím zadajte číslo. + + + The password is invalid. + Heslo je neprávne. + + + Please enter a percentage value. + Prosím zadajte percentuálnu hodnotu. + + + The values do not match. + Hodnoty nie sú zhodné. + + + Please enter a valid time. + Prosím zadajte platný čas. + + + Please select a valid timezone. + Prosím vyberte platné časové pásmo. + + + Please enter a valid URL. + Prosím zadajte platnú URL. + + + Please enter a valid search term. + Prosím zadajte platný vyhľadávací výraz. + + + Please provide a valid phone number. + Prosím zadajte platné telefónne číslo. + + + The checkbox has an invalid value. + Zaškrtávacie políčko má neplatnú hodnotu. + + + Please enter a valid email address. + Prosím zadajte platnú emailovú adresu. + + + Please select a valid option. + Prosím vyberte platnú možnosť. + + + Please select a valid range. + Prosím vyberte platný rozsah. + + + Please enter a valid week. + Prosím zadajte platný týždeň. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.sl.xlf b/lib/symfony/form/Resources/translations/validators.sl.xlf new file mode 100644 index 000000000..c19949d71 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.sl.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ta obrazec ne sme vsebovati dodatnih polj. + + + The uploaded file was too large. Please try to upload a smaller file. + Naložena datoteka je prevelika. Prosimo, poizkusite naložiti manjšo. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF vrednost je napačna. Prosimo, ponovno pošljite obrazec. + + + This value is not a valid HTML5 color. + Ta vrednost ni veljavna barva HTML5. + + + Please enter a valid birthdate. + Prosimo, vnesite veljaven rojstni datum. + + + The selected choice is invalid. + Izbira ni veljavna. + + + The collection is invalid. + Zbirka ni veljavna. + + + Please select a valid color. + Prosimo, izberite veljavno barvo. + + + Please select a valid country. + Prosimo, izberite veljavno državo. + + + Please select a valid currency. + Prosimo, izberite veljavno valuto. + + + Please choose a valid date interval. + Prosimo, izberite veljaven datumski interval. + + + Please enter a valid date and time. + Prosimo, vnesite veljaven datum in čas. + + + Please enter a valid date. + Prosimo, izberite veljaven datum. + + + Please select a valid file. + Prosimo, izberite veljavno datoteko. + + + The hidden field is invalid. + Skrito polje ni veljavno. + + + Please enter an integer. + Prosimo, vnesite celo število. + + + Please select a valid language. + Prosimo, izberite veljaven jezik. + + + Please select a valid locale. + Prosimo, izberite veljavne področne nastavitve. + + + Please enter a valid money amount. + Prosimo, vnesite veljaven denarni znesek. + + + Please enter a number. + Prosimo, vnesite številko. + + + The password is invalid. + Geslo ni veljavno. + + + Please enter a percentage value. + Prosimo, vnesite odstotno vrednost. + + + The values do not match. + Vrednosti se ne ujemajo. + + + Please enter a valid time. + Prosimo, vnesite veljaven čas. + + + Please select a valid timezone. + Prosimo, izberite veljaven časovni pas. + + + Please enter a valid URL. + Prosimo, vnesite veljaven URL. + + + Please enter a valid search term. + Prosimo, vnesite veljaven iskalni izraz. + + + Please provide a valid phone number. + Prosimo, podajte veljavno telefonsko številko. + + + The checkbox has an invalid value. + Potrditveno polje vsebuje neveljavno vrednost. + + + Please enter a valid email address. + Prosimo, vnesite veljaven e-poštni naslov. + + + Please select a valid option. + Prosimo, izberite veljavno možnost. + + + Please select a valid range. + Prosimo, izberite veljaven obseg. + + + Please enter a valid week. + Prosimo, vnesite veljaven teden. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.sq.xlf b/lib/symfony/form/Resources/translations/validators.sq.xlf new file mode 100644 index 000000000..0feb137f8 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.sq.xlf @@ -0,0 +1,148 @@ + + + +
+ + Për fjalët e huaja, të cilat nuk kanë përkthim të drejtpërdrejtë, ju lutemi të ndiqni rregullat e mëposhtme: + a) në rast se emri është akronim i përdorur gjerësisht si i përveçëm, atëherë, emri lakohet pa thonjëza dhe mbaresa shkruhet me vizë ndarëse. Gjinia gjykohet sipas rastit. Shembull: JSON-i (mashkullore) + b) në rast se emri është akronim i papërdorur gjerësisht si i përveçëm, atëherë, emri lakohet pa thonjëza dhe mbaresa shkruhet me vizë ndarëse. Gjinia është femërore. Shembull: URL-ja (femërore) + c) në rast se emri duhet lakuar për shkak të rasës në fjali, atëherë, emri lakohet pa thonjëza dhe mbaresa shkruhet me vizë ndarëse. Shembull: host-i, prej host-it + d) në rast se emri nuk duhet lakuar për shkak të trajtës në fjali, atëherë, emri rrethohet me thonjëzat “”. Shembull: “locale” + +
+ + + This form should not contain extra fields. + Ky formular nuk duhet të përmbajë fusha shtesë. + + + The uploaded file was too large. Please try to upload a smaller file. + Skeda e ngarkuar ishte shumë e madhe. Ju lutemi provoni të ngarkoni një skedë më të vogël. + + + The CSRF token is invalid. Please try to resubmit the form. + Vlera CSRF është e pavlefshme. Ju lutemi provoni të ridërgoni formularin. + + + This value is not a valid HTML5 color. + Kjo vlerë nuk është një ngjyrë e vlefshme HTML5. + + + Please enter a valid birthdate. + Ju lutemi shkruani një datëlindje të vlefshme. + + + The selected choice is invalid. + Alternativa e zgjedhur është e pavlefshme. + + + The collection is invalid. + Koleksioni është i pavlefshëm. + + + Please select a valid color. + Ju lutemi zgjidhni një ngjyrë të vlefshme. + + + Please select a valid country. + Ju lutemi zgjidhni një shtet të vlefshëm. + + + Please select a valid currency. + Ju lutemi zgjidhni një valutë të vlefshme. + + + Please choose a valid date interval. + Ju lutemi zgjidhni një interval të vlefshëm. + + + Please enter a valid date and time. + Ju lutemi shkruani një datë dhe orë të vlefshme. + + + Please enter a valid date. + Ju lutemi shkruani një datë të vlefshme. + + + Please select a valid file. + Ju lutemi zgjidhni një skedë të vlefshme. + + + The hidden field is invalid. + Fusha e fshehur është e pavlefshme. + + + Please enter an integer. + Ju lutemi shkruani një numër të plotë. + + + Please select a valid language. + Ju lutemi zgjidhni një gjuhë të vlefshme. + + + Please select a valid locale. + Ju lutemi zgjidhni një “locale” të vlefshme. + + + Please enter a valid money amount. + Ju lutemi shkruani një shumë të vlefshme parash. + + + Please enter a number. + Ju lutemi shkruani një numër. + + + The password is invalid. + Fjalëkalimi është i pavlefshëm. + + + Please enter a percentage value. + Ju lutemi shkruani një vlerë përqindjeje. + + + The values do not match. + Vlerat nuk përputhen. + + + Please enter a valid time. + Ju lutemi shkruani një orë të vlefshme. + + + Please select a valid timezone. + Ju lutemi zgjidhni një zonë kohore të vlefshme. + + + Please enter a valid URL. + Ju lutemi shkruani një URL të vlefshme. + + + Please enter a valid search term. + Ju lutemi shkruani një term të vlefshëm kërkimi. + + + Please provide a valid phone number. + Ju lutemi jepni një numër telefoni të vlefshëm. + + + The checkbox has an invalid value. + Kutia e zgjedhjes ka një vlerë të pavlefshme. + + + Please enter a valid email address. + Ju lutemi shkruani një adresë të vlefshme email-i. + + + Please select a valid option. + Ju lutemi zgjidhni një alternativë të vlefshme. + + + Please select a valid range. + Ju lutemi zgjidhni një seri të vlefshme. + + + Please enter a valid week. + Ju lutemi shkruani një javë të vlefshme. + + +
+
diff --git a/lib/symfony/form/Resources/translations/validators.sr_Cyrl.xlf b/lib/symfony/form/Resources/translations/validators.sr_Cyrl.xlf new file mode 100644 index 000000000..4b3e5b9b8 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.sr_Cyrl.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Овај формулар не треба да садржи додатна поља. + + + The uploaded file was too large. Please try to upload a smaller file. + Отпремљена датотека је била превелика. Молим покушајте отпремање мање датотеке. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF вредност није исправна. Покушајте поново. + + + This value is not a valid HTML5 color. + Ова вредност није исправна HTML5 боја. + + + Please enter a valid birthdate. + Молим упишите исправан датум рођења. + + + The selected choice is invalid. + Одабрани избор није исправан. + + + The collection is invalid. + Ова колекција није исправна. + + + Please select a valid color. + Молим изаберите исправну боју. + + + Please select a valid country. + Молим изаберите исправну државу. + + + Please select a valid currency. + Молим изаберите исправну валуту. + + + Please choose a valid date interval. + Молим изаберите исправан датумски интервал. + + + Please enter a valid date and time. + Молим упишите исправан датум и време. + + + Please enter a valid date. + Молим упишите исправан датум. + + + Please select a valid file. + Молим изаберите исправну датотеку. + + + The hidden field is invalid. + Скривено поље није исправно. + + + Please enter an integer. + Молим упишите цео број (integer). + + + Please select a valid language. + Молим изаберите исправан језик. + + + Please select a valid locale. + Молим изаберите исправну локализацију. + + + Please enter a valid money amount. + Молим упишите исправну количину новца. + + + Please enter a number. + Молим упишите број. + + + The password is invalid. + Ова лозинка није исправна. + + + Please enter a percentage value. + Молим упишите процентуалну вредност. + + + The values do not match. + Дате вредности се не поклапају. + + + Please enter a valid time. + Молим упишите исправно време. + + + Please select a valid timezone. + Молим изаберите исправну временску зону. + + + Please enter a valid URL. + Молим упишите исправан URL. + + + Please enter a valid search term. + Молим упишите исправан термин за претрагу. + + + Please provide a valid phone number. + Молим наведите исправан број телефона. + + + The checkbox has an invalid value. + Поље за потврду садржи неисправну вредност. + + + Please enter a valid email address. + Молим упишите исправну email адресу. + + + Please select a valid option. + Молим изаберите исправну опцију. + + + Please select a valid range. + Молим изаберите исправан опсег. + + + Please enter a valid week. + Молим упишите исправну седмицу. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.sr_Latn.xlf b/lib/symfony/form/Resources/translations/validators.sr_Latn.xlf new file mode 100644 index 000000000..6f64f5634 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.sr_Latn.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ovaj formular ne treba da sadrži dodatna polja. + + + The uploaded file was too large. Please try to upload a smaller file. + Otpremljena datoteka je bila prevelika. Molim pokušajte otpremanje manje datoteke. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF vrednost nije ispravna. Pokušajte ponovo. + + + This value is not a valid HTML5 color. + Ova vrednost nije ispravna HTML5 boja. + + + Please enter a valid birthdate. + Molim upišite ispravan datum rođenja. + + + The selected choice is invalid. + Odabrani izbor nije ispravan. + + + The collection is invalid. + Ova kolekcija nije ispravna. + + + Please select a valid color. + Molim izaberite ispravnu boju. + + + Please select a valid country. + Molim izaberite ispravnu državu. + + + Please select a valid currency. + Molim izaberite ispravnu valutu. + + + Please choose a valid date interval. + Molim izaberite ispravan datumski interval. + + + Please enter a valid date and time. + Molim upišite ispravan datum i vreme. + + + Please enter a valid date. + Molim upišite ispravan datum. + + + Please select a valid file. + Molim izaberite ispravnu datoteku. + + + The hidden field is invalid. + Skriveno polje nije ispravno. + + + Please enter an integer. + Molim upišite ceo broj (integer). + + + Please select a valid language. + Molim izaberite ispravan jezik. + + + Please select a valid locale. + Molim izaberite ispravnu lokalizaciju. + + + Please enter a valid money amount. + Molim upišite ispravnu količinu novca. + + + Please enter a number. + Molim upišite broj. + + + The password is invalid. + Ova lozinka nije ispravna. + + + Please enter a percentage value. + Molim upišite procentualnu vrednost. + + + The values do not match. + Date vrednosti se ne poklapaju. + + + Please enter a valid time. + Molim upišite ispravno vreme. + + + Please select a valid timezone. + Molim izaberite ispravnu vremensku zonu. + + + Please enter a valid URL. + Molim upišite ispravan URL. + + + Please enter a valid search term. + Molim upišite ispravan termin za pretragu. + + + Please provide a valid phone number. + Molim navedite ispravan broj telefona. + + + The checkbox has an invalid value. + Polje za potvrdu sadrži neispravnu vrednost. + + + Please enter a valid email address. + Molim upišite ispravnu email adresu. + + + Please select a valid option. + Molim izaberite ispravnu opciju. + + + Please select a valid range. + Molim izaberite ispravan opseg. + + + Please enter a valid week. + Molim upišite ispravnu sedmicu. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.sv.xlf b/lib/symfony/form/Resources/translations/validators.sv.xlf new file mode 100644 index 000000000..052a56960 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.sv.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Formuläret kan inte innehålla extra fält. + + + The uploaded file was too large. Please try to upload a smaller file. + Den uppladdade filen var för stor. Försök ladda upp en mindre fil. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF-elementet är inte giltigt. Försök att skicka formuläret igen. + + + This value is not a valid HTML5 color. + Värdet är inte en giltig HTML5-färg. + + + Please enter a valid birthdate. + Ange ett giltigt födelsedatum. + + + The selected choice is invalid. + Det valda alternativet är ogiltigt. + + + The collection is invalid. + Den här samlingen är ogiltig. + + + Please select a valid color. + Välj en giltig färg. + + + Please select a valid country. + Välj ett land. + + + Please select a valid currency. + Välj en valuta. + + + Please choose a valid date interval. + Välj ett giltigt datumintervall. + + + Please enter a valid date and time. + Ange ett giltigt datum och tid. + + + Please enter a valid date. + Ange ett giltigt datum. + + + Please select a valid file. + Välj en fil. + + + The hidden field is invalid. + Det dolda fältet är ogiltigt. + + + Please enter an integer. + Ange ett heltal. + + + Please select a valid language. + Välj språk. + + + Please select a valid locale. + Välj plats. + + + Please enter a valid money amount. + Ange en giltig summa pengar. + + + Please enter a number. + Ange en siffra. + + + The password is invalid. + Lösenordet är ogiltigt. + + + Please enter a percentage value. + Ange ett procentuellt värde. + + + The values do not match. + De angivna värdena stämmer inte överens. + + + Please enter a valid time. + Ange en giltig tid. + + + Please select a valid timezone. + Välj en tidszon. + + + Please enter a valid URL. + Ange en giltig URL. + + + Please enter a valid search term. + Ange ett giltigt sökbegrepp. + + + Please provide a valid phone number. + Ange ett giltigt telefonnummer. + + + The checkbox has an invalid value. + Kryssrutan har ett ogiltigt värde. + + + Please enter a valid email address. + Ange en giltig e-postadress. + + + Please select a valid option. + Välj ett alternativ. + + + Please select a valid range. + Välj ett intervall. + + + Please enter a valid week. + Ange en giltig vecka. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.th.xlf b/lib/symfony/form/Resources/translations/validators.th.xlf new file mode 100644 index 000000000..82d417d95 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.th.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + ฟอร์มนี้ไม่ควรมี extra fields + + + The uploaded file was too large. Please try to upload a smaller file. + ไฟล์ที่อัพโหลดมีขนาดใหญ่เกินไป กรุณาลองอัพโหลดใหม่อีกครั้งด้วยไฟล์ที่มีขนาดเล็กลง + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF token ไม่ถูกต้อง กรุณาลองส่งแบบฟอร์มใหม่ + + + This value is not a valid HTML5 color. + ค่านี้ไม่ใช่ค่าที่ถูกต้องของค่าสี HTML5 + + + Please enter a valid birthdate. + กรุณากรอกวันเดือนปีเกิดที่ถูกต้อง + + + The selected choice is invalid. + ตัวเลือกที่เลิอกไม่ถูกต้อง + + + The collection is invalid. + คอเล็กชั่นไม่ถูกต้อง + + + Please select a valid color. + กรุณาเลือกค่าสีที่ถูกต้อง + + + Please select a valid country. + กรุณาเลือกประเทศที่ถูกต้อง + + + Please select a valid currency. + กรุุณาเลิอกค่าสกุลเงินที่ถูกต้อง + + + Please choose a valid date interval. + กรุณณากรอกช่วงวันที่ที่ถูกต้อง + + + Please enter a valid date and time. + กรุณณากรอกค่าเวลาและวันที่ที่ถูกต้อง + + + Please enter a valid date. + กรุณณากรอกค่าวันที่ที่ถูกต้อง + + + Please select a valid file. + กรุณาเลือกไฟล์ที่ถูกต้อง + + + The hidden field is invalid. + ค่า Hidden field ไม่ถูกต้อง + + + Please enter an integer. + กรุณากรอกตัวเลขจำนวนเต็ม + + + Please select a valid language. + กรุณาเลือกภาษาที่ถูกต้อง + + + Please select a valid locale. + กรุณาเลือกท้องถิ่นที่ถูกต้อง + + + Please enter a valid money amount. + กรุณากรอกจำนวนเงินที่ถูกต้อง + + + Please enter a number. + กรุณากรอกตัวเลข + + + The password is invalid. + รหัสผ่านไม่ถูกต้อง + + + Please enter a percentage value. + กรุณากรอกค่าเปอร์เซ็นต์ + + + The values do not match. + ค่าทั้งสองไม่ตรงกัน + + + Please enter a valid time. + กรุณากรอกค่าเวลาที่ถูกต้อง + + + Please select a valid timezone. + กรุณาเลือกค่าเขตเวลาที่ถูกต้อง + + + Please enter a valid URL. + กรุณากรอก URL ที่ถูกต้อง + + + Please enter a valid search term. + กรุณากรอกคำค้นหาที่ถูกต้อง + + + Please provide a valid phone number. + กรุณากรอกเบอร์โทรศัพท์ที่ถูกต้อง + + + The checkbox has an invalid value. + Checkbox มีค่าที่ไม่ถูกต้อง + + + Please enter a valid email address. + กรุณากรอกที่อยู่อีเมล์ที่ถูกต้อง + + + Please select a valid option. + กรุณาเลือกตัวเลือกที่ถูกต้อง + + + Please select a valid range. + กรุณาเลือกค่าช่วงที่ถูกต้อง + + + Please enter a valid week. + กรุณากรอกค่าสัปดาห์ที่ถูกต้อง + + + + diff --git a/lib/symfony/form/Resources/translations/validators.tl.xlf b/lib/symfony/form/Resources/translations/validators.tl.xlf new file mode 100644 index 000000000..6aeef41e1 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.tl.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ang pormang itong ay hindi dapat magkarron ng dagdag na mga patlang. + + + The uploaded file was too large. Please try to upload a smaller file. + Ang ini-upload na file ay masyadong malaki. Pakiulit muling mag-upload ng mas maliit na file. + + + The CSRF token is invalid. Please try to resubmit the form. + Hindi balido ang CSRF token. Maagpasa muli ng isang pang porma. + + + This value is not a valid HTML5 color. + Ang halagang ito ay hindi wastong HTML5 color. + + + Please enter a valid birthdate. + Pakilagay ang tamang petsa ng kapanganakan. + + + The selected choice is invalid. + Ang pinagpiliang sagot ay hindi tama. + + + The collection is invalid. + Hindi balido ang koleksyon. + + + Please select a valid color. + Pakipiliin ang nararapat na kulay. + + + Please select a valid country. + Pakipiliin ang nararapat na bansa. + + + Please select a valid currency. + Pakipiliin ang tamang pananalapi. + + + Please choose a valid date interval. + Piliin ang wastong agwat ng petsa. + + + Please enter a valid date and time. + Piliin ang wastong petsa at oras. + + + Please enter a valid date. + Ilagay ang wastong petsa. + + + Please select a valid file. + Piliin ang balidong file. + + + The hidden field is invalid. + Hindi balido ang field na nakatago. + + + Please enter an integer. + Pakilagay ang integer. + + + Please select a valid language. + Piliin ang nararapat na lengguwahe. + + + Please select a valid locale. + Pakipili ang nararapat na locale. + + + Please enter a valid money amount. + Pakilagay ang tamang halaga ng pera. + + + Please enter a number. + Ilagay ang numero. + + + The password is invalid. + Hindi balido ang password. + + + Please enter a percentage value. + Pakilagay ang tamang porsyento ng halaga. + + + The values do not match. + Hindi tugma ang mga halaga. + + + Please enter a valid time. + Pakilagay ang tamang oras. + + + Please select a valid timezone. + Pakilagay ang tamang sona ng oras. + + + Please enter a valid URL. + Pakilagay ang balidong URL. + + + Please enter a valid search term. + Pakilagay ang balidong katagang sinasaliksik. + + + Please provide a valid phone number. + Pakilagay ang balidong numero ng telepono. + + + The checkbox has an invalid value. + Ang checkbox ay mayroon hindi balidong halaga. + + + Please enter a valid email address. + Pakilagay ang balidong email address. + + + Please select a valid option. + Pakipiliin ang balidong pagpipilian. + + + Please select a valid range. + Pakipilian ang balidong layo. + + + Please enter a valid week. + Pakilagay ang balidong linggo. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.tr.xlf b/lib/symfony/form/Resources/translations/validators.tr.xlf new file mode 100644 index 000000000..71a469619 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.tr.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Form ekstra alanlar içeremez. + + + The uploaded file was too large. Please try to upload a smaller file. + Yüklenen dosya boyutu çok yüksek. Lütfen daha küçük bir dosya yüklemeyi deneyin. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF fişi geçersiz. Formu tekrar göndermeyi deneyin. + + + This value is not a valid HTML5 color. + Bu değer, geçerli bir HTML5 rengi değil. + + + Please enter a valid birthdate. + Lütfen geçerli bir doğum tarihi girin. + + + The selected choice is invalid. + Seçilen seçim geçersiz. + + + The collection is invalid. + Koleksiyon geçersiz. + + + Please select a valid color. + Lütfen geçerli bir renk seçin. + + + Please select a valid country. + Lütfen geçerli bir ülke seçin. + + + Please select a valid currency. + Lütfen geçerli bir para birimi seçin. + + + Please choose a valid date interval. + Lütfen geçerli bir tarih aralığı seçin. + + + Please enter a valid date and time. + Lütfen geçerli bir tarih ve saat girin. + + + Please enter a valid date. + Lütfen geçerli bir tarih giriniz. + + + Please select a valid file. + Lütfen geçerli bir dosya seçin. + + + The hidden field is invalid. + Gizli alan geçersiz. + + + Please enter an integer. + Lütfen bir tam sayı girin. + + + Please select a valid language. + Lütfen geçerli bir dil seçin. + + + Please select a valid locale. + Lütfen geçerli bir yerel ayar seçin. + + + Please enter a valid money amount. + Lütfen geçerli bir para tutarı girin. + + + Please enter a number. + Lütfen bir numara giriniz. + + + The password is invalid. + Şifre geçersiz. + + + Please enter a percentage value. + Lütfen bir yüzde değeri girin. + + + The values do not match. + Değerler eşleşmiyor. + + + Please enter a valid time. + Lütfen geçerli bir zaman girin. + + + Please select a valid timezone. + Lütfen geçerli bir saat dilimi seçin. + + + Please enter a valid URL. + Lütfen geçerli bir giriniz URL. + + + Please enter a valid search term. + Lütfen geçerli bir arama terimi girin. + + + Please provide a valid phone number. + lütfen geçerli bir telefon numarası sağlayın. + + + The checkbox has an invalid value. + Onay kutusunda geçersiz bir değer var. + + + Please enter a valid email address. + Lütfen geçerli bir e-posta adresi girin. + + + Please select a valid option. + Lütfen geçerli bir seçenek seçin. + + + Please select a valid range. + Lütfen geçerli bir aralık seçin. + + + Please enter a valid week. + Lütfen geçerli bir hafta girin. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.uk.xlf b/lib/symfony/form/Resources/translations/validators.uk.xlf new file mode 100644 index 000000000..c6bbca185 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.uk.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ця форма не повинна містити додаткових полів. + + + The uploaded file was too large. Please try to upload a smaller file. + Завантажений файл занадто великий. Будь ласка, спробуйте завантажити файл меншого розміру. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF значення недопустиме. Будь ласка, спробуйте відправити форму знову. + + + This value is not a valid HTML5 color. + Це значення не є допустимим кольором HTML5. + + + Please enter a valid birthdate. + Будь ласка, введіть дійсну дату народження. + + + The selected choice is invalid. + Обраний варіант недійсний. + + + The collection is invalid. + Колекція недійсна. + + + Please select a valid color. + Будь ласка, оберіть дійсний колір. + + + Please select a valid country. + Будь ласка, оберіть дійсну країну. + + + Please select a valid currency. + Будь ласка, оберіть дійсну валюту. + + + Please choose a valid date interval. + Будь ласка, оберіть дійсний інтервал дати. + + + Please enter a valid date and time. + Будь ласка, введіть дійсну дату та час. + + + Please enter a valid date. + Будь ласка, введіть дійсну дату. + + + Please select a valid file. + Будь ласка, оберіть дійсний файл. + + + The hidden field is invalid. + Приховане поле недійсне. + + + Please enter an integer. + Будь ласка, введіть ціле число. + + + Please select a valid language. + Будь ласка, оберіть дійсну мову. + + + Please select a valid locale. + Будь ласка, оберіть дійсну локаль. + + + Please enter a valid money amount. + Будь ласка, введіть дійсну суму грошей. + + + Please enter a number. + Будь ласка, введіть число. + + + The password is invalid. + Пароль недійсний. + + + Please enter a percentage value. + Будь ласка, введіть процентне значення. + + + The values do not match. + Значення не збігаються. + + + Please enter a valid time. + Будь ласка, введіть дійсний час. + + + Please select a valid timezone. + Будь ласка, оберіть дійсний часовий пояс. + + + Please enter a valid URL. + Будь ласка, введіть дійсну URL-адресу. + + + Please enter a valid search term. + Будь ласка, введіть дійсний пошуковий термін. + + + Please provide a valid phone number. + Будь ласка, введіть дійсний номер телефону. + + + The checkbox has an invalid value. + Прапорець має недійсне значення. + + + Please enter a valid email address. + Будь ласка, введіть дійсну адресу електронної пошти. + + + Please select a valid option. + Будь ласка, оберіть дійсний варіант. + + + Please select a valid range. + Будь ласка, оберіть дійсний діапазон. + + + Please enter a valid week. + Будь ласка, введіть дійсний тиждень. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.ur.xlf b/lib/symfony/form/Resources/translations/validators.ur.xlf new file mode 100644 index 000000000..42b891bbf --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.ur.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + اس فارم میں اضافی فیلڈز نہیں ہونی چاہئیں + + + The uploaded file was too large. Please try to upload a smaller file. + اپ لوڈ کردھ فائل بہت بڑی تھی۔ براہ کرم ایک چھوٹی فائل اپ لوڈ کرنے کی کوشش کریں + + + The CSRF token is invalid. Please try to resubmit the form. + ٹوکن غلط ہے۔ براۓ کرم فارم کو دوبارہ جمع کرانے کی کوشش کریں CSRF + + + This value is not a valid HTML5 color. + ر نگ نھیں ھےHTML یھ ولیو در ست + + + Please enter a valid birthdate. + براۓ کرم درست تاریخ پیدائش درج کریں + + + The selected choice is invalid. + منتخب کردہ انتخاب غلط ہے + + + The collection is invalid. + یھ مجموعہ غلط ہے + + + Please select a valid color. + براۓ کرم ایک درست رنگ منتخب کریں + + + Please select a valid country. + براۓ کرم ایک درست ملک منتخب کریں + + + Please select a valid currency. + براۓ کرم ایک درست کرنسی منتخب کریں + + + Please choose a valid date interval. + براۓ کرم ایک درست تاریخی وقفھہ منتخب کریں + + + Please enter a valid date and time. + براۓ کرم ایک درست تاریخ اور وقت درج کریں + + + Please enter a valid date. + براۓ کرم ایک درست تاریخ درج کریں + + + Please select a valid file. + براۓ کرم ایک درست فائل منتخب کریں + + + The hidden field is invalid. + پوشیدھہ فیلڈ غلط ہے + + + Please enter an integer. + براۓ کرم ایک عدد درج کریں + + + Please select a valid language. + براۓ کرم ایک درست زبان منتخب کریں + + + Please select a valid locale. + براۓ کرم ایک درست مقام منتخب کریں + + + Please enter a valid money amount. + براۓ کرم ایک درست رقم درج کریں + + + Please enter a number. + براۓ کرم ایک نمبر درج کریں + + + The password is invalid. + پاس ورڈ غلط ہے + + + Please enter a percentage value. + براہ کرم فیصد کی ويلو درج کریں + + + The values do not match. + ويليوذ ٹھيک نہیں ہیں + + + Please enter a valid time. + براۓ کرم ایک درست وقت درج کریں + + + Please select a valid timezone. + براۓ کرم ایک درست ٹائم زون منتخب کریں + + + Please enter a valid URL. + براۓ کرم ایک درست ادريس درج کریں + + + Please enter a valid search term. + براۓ کرم ایک درست ويلو تلاش کيلۓ درج کریں + + + Please provide a valid phone number. + براۓ کرم ایک درست فون نمبر فراہم کریں + + + The checkbox has an invalid value. + چیک باکس میں ایک غلط ويلو ہے + + + Please enter a valid email address. + براۓ مہربانی قابل قبول ای میل ایڈریس لکھیں + + + Please select a valid option. + براۓ کرم ایک درست آپشن منتخب کریں + + + Please select a valid range. + براۓ کرم ایک درست رینج منتخب کریں + + + Please enter a valid week. + براۓ کرم ایک درست ہفتہ درج کریں + + + + diff --git a/lib/symfony/form/Resources/translations/validators.uz.xlf b/lib/symfony/form/Resources/translations/validators.uz.xlf new file mode 100644 index 000000000..86be2379c --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.uz.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Ushbu fo'rmada qo'shimcha maydonlar bo'lmasligi kerak. + + + The uploaded file was too large. Please try to upload a smaller file. + Yuklab olingan fayl juda katta. Iltimos, kichikroq faylni yuklashga harakat qiling. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF qiymati yaroqsiz. Fo'rmani qayta yuborishga harakat qiling. + + + This value is not a valid HTML5 color. + Qiymat noto'g'ri, HTML5 rangi emas. + + + Please enter a valid birthdate. + Iltimos, tug'ilgan kuningizni to'g'ri kiriting. + + + The selected choice is invalid. + Tanlangan parametr noto'g'ri. + + + The collection is invalid. + Kolleksiya noto'g'ri + + + Please select a valid color. + Iltimos, to'g'ri rang tanlang. + + + Please select a valid country. + Iltimos, to'g'ri mamlakatni tanlang. + + + Please select a valid currency. + Iltimos, to'g'ri valyutani tanlang. + + + Please choose a valid date interval. + Iltimos, to'g'ri sana oralig'ini tanlang. + + + Please enter a valid date and time. + Iltimos, to'g'ri sana va vaqtni kiriting. + + + Please enter a valid date. + Iltimos, to'g'ri sanani kiriting. + + + Please select a valid file. + Iltimos, to'g'ri faylni tanlang. + + + The hidden field is invalid. + Yashirin maydon qiymati yaroqsiz. + + + Please enter an integer. + Iltimos, butun son kiriting. + + + Please select a valid language. + Iltimos, to'g'ri tilni tanlang. + + + Please select a valid locale. + Iltimos, to'g'ri localni tanlang. + + + Please enter a valid money amount. + Iltimos, tegishli miqdordagi pulni kiriting. + + + Please enter a number. + Iltimos, raqam kiriting. + + + The password is invalid. + Parol noto'g'ri. + + + Please enter a percentage value. + Iltimos, foyizli qiymat kiriting. + + + The values do not match. + Qiymatlar mos kelmaydi. + + + Please enter a valid time. + Iltimos, to'g'ri vaqtni tanlang. + + + Please select a valid timezone. + Iltimos, to'g'ri vaqt zonasini tanlang. + + + Please enter a valid URL. + Iltimos, to'g'ri URL kiriting. + + + Please enter a valid search term. + Iltimos, to'g'ri qidiruv so'zini kiriting. + + + Please provide a valid phone number. + Iltimos, to'g'ri telefon raqamini kiriting. + + + The checkbox has an invalid value. + Belgilash katagida yaroqsiz qiymat mavjud. + + + Please enter a valid email address. + Iltimos, to'g'ri email kiriting. + + + Please select a valid option. + Iltimos, yaroqli variantni tanlang. + + + Please select a valid range. + Iltimos, yaroqli oraliqni tanlang. + + + Please enter a valid week. + Iltimos, haqiqiy haftani kiriting. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.vi.xlf b/lib/symfony/form/Resources/translations/validators.vi.xlf new file mode 100644 index 000000000..92171c055 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.vi.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + Mẫu này không nên chứa trường mở rộng. + + + The uploaded file was too large. Please try to upload a smaller file. + Tập tin tải lên quá lớn. Vui lòng thử lại với tập tin nhỏ hơn. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF token không hợp lệ. Vui lòng thử lại. + + + This value is not a valid HTML5 color. + Giá trị này không phải là màu HTML5 hợp lệ. + + + Please enter a valid birthdate. + Vui lòng nhập ngày sinh hợp lệ. + + + The selected choice is invalid. + Lựa chọn không hợp lệ. + + + The collection is invalid. + Danh sách không hợp lệ. + + + Please select a valid color. + Vui lòng chọn một màu hợp lệ. + + + Please select a valid country. + Vui lòng chọn đất nước hợp lệ. + + + Please select a valid currency. + Vui lòng chọn tiền tệ hợp lệ. + + + Please choose a valid date interval. + Vui lòng chọn một khoảng thời gian hợp lệ. + + + Please enter a valid date and time. + Vui lòng nhập ngày và thời gian hợp lệ. + + + Please enter a valid date. + Vui lòng nhập ngày hợp lệ. + + + Please select a valid file. + Vui lòng chọn tệp hợp lệ. + + + The hidden field is invalid. + Phạm vi ẩn không hợp lệ. + + + Please enter an integer. + Vui lòng nhập một số nguyên. + + + Please select a valid language. + Vui lòng chọn ngôn ngữ hợp lệ. + + + Please select a valid locale. + Vui lòng chọn miền hợp lệ. + + + Please enter a valid money amount. + Vui lòng nhập một khoảng tiền hợp lệ. + + + Please enter a number. + Vui lòng nhập một con số. + + + The password is invalid. + Mật khẩu không hợp lệ. + + + Please enter a percentage value. + Vui lòng nhập một giá trị phần trăm. + + + The values do not match. + Các giá trị không phù hợp. + + + Please enter a valid time. + Vui lòng nhập thời gian hợp lệ. + + + Please select a valid timezone. + Vui lòng chọn múi giờ hợp lệ. + + + Please enter a valid URL. + Vui lòng nhập một URL hợp lệ. + + + Please enter a valid search term. + Vui lòng nhập chuỗi tìm kiếm hợp lệ. + + + Please provide a valid phone number. + Vui lòng cung cấp số điện thoại hợp lệ. + + + The checkbox has an invalid value. + Hộp kiểm có một giá trị không hợp lệ. + + + Please enter a valid email address. + Vui lòng nhập địa chỉ email hợp lệ. + + + Please select a valid option. + Vui lòng chọn một phương án hợp lệ. + + + Please select a valid range. + Vui lòng nhập một phạm vi hợp lệ. + + + Please enter a valid week. + Vui lòng nhập một tuần hợp lệ. + + + + diff --git a/lib/symfony/form/Resources/translations/validators.zh_CN.xlf b/lib/symfony/form/Resources/translations/validators.zh_CN.xlf new file mode 100644 index 000000000..a1469b798 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.zh_CN.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + 该表单中不可有额外字段. + + + The uploaded file was too large. Please try to upload a smaller file. + 上传文件太大, 请重新尝试上传一个较小的文件. + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF 验证符无效, 请重新提交. + + + This value is not a valid HTML5 color. + 该数值不是个有效的 HTML5 颜色。 + + + Please enter a valid birthdate. + 请输入有效的生日日期。 + + + The selected choice is invalid. + 所选的选项无效。 + + + The collection is invalid. + 集合无效。 + + + Please select a valid color. + 请选择有效的颜色。 + + + Please select a valid country. + 请选择有效的国家。 + + + Please select a valid currency. + 请选择有效的货币。 + + + Please choose a valid date interval. + 请选择有效的日期间隔。 + + + Please enter a valid date and time. + 请输入有效的日期与时间。 + + + Please enter a valid date. + 请输入有效的日期。 + + + Please select a valid file. + 请选择有效的文件。 + + + The hidden field is invalid. + 隐藏字段无效。 + + + Please enter an integer. + 请输入整数。 + + + Please select a valid language. + 请选择有效的语言。 + + + Please select a valid locale. + 请选择有效的语言环境。 + + + Please enter a valid money amount. + 请输入正确的金额。 + + + Please enter a number. + 请输入数字。 + + + The password is invalid. + 密码无效。 + + + Please enter a percentage value. + 请输入百分比值。 + + + The values do not match. + 数值不匹配。 + + + Please enter a valid time. + 请输入有效的时间。 + + + Please select a valid timezone. + 请选择有效的时区。 + + + Please enter a valid URL. + 请输入有效的网址。 + + + Please enter a valid search term. + 请输入有效的搜索词。 + + + Please provide a valid phone number. + 请提供有效的手机号码。 + + + The checkbox has an invalid value. + 无效的选框值。 + + + Please enter a valid email address. + 请输入有效的电子邮件地址。 + + + Please select a valid option. + 请选择有效的选项。 + + + Please select a valid range. + 请选择有效的范围。 + + + Please enter a valid week. + 请输入有效的星期。 + + + + diff --git a/lib/symfony/form/Resources/translations/validators.zh_TW.xlf b/lib/symfony/form/Resources/translations/validators.zh_TW.xlf new file mode 100644 index 000000000..0a76ab7a7 --- /dev/null +++ b/lib/symfony/form/Resources/translations/validators.zh_TW.xlf @@ -0,0 +1,139 @@ + + + + + + This form should not contain extra fields. + 此表單不應包含其他欄位。 + + + The uploaded file was too large. Please try to upload a smaller file. + 上傳的檔案過大。請嘗試上傳較小的檔案。 + + + The CSRF token is invalid. Please try to resubmit the form. + CSRF token 無效。請重新提交表單。 + + + This value is not a valid HTML5 color. + 這個數值不是有效的 HTML5 顏色。 + + + Please enter a valid birthdate. + 請輸入有效的出生日期。 + + + The selected choice is invalid. + 選取的選項無效。 + + + The collection is invalid. + 這個集合無效。 + + + Please select a valid color. + 請選擇有效的顏色。 + + + Please select a valid country. + 請選擇有效的國家。 + + + Please select a valid currency. + 請選擇有效的貨幣。 + + + Please choose a valid date interval. + 請選擇有效的日期區間。 + + + Please enter a valid date and time. + 請輸入有效的日期和時間。 + + + Please enter a valid date. + 請輸入有效的日期。 + + + Please select a valid file. + 請選擇有效的檔案。 + + + The hidden field is invalid. + 隱藏欄位無效。 + + + Please enter an integer. + 請輸入整數。 + + + Please select a valid language. + 請選擇有效的語言。 + + + Please select a valid locale. + 請選擇有效的語系。 + + + Please enter a valid money amount. + 請輸入有效的金額。 + + + Please enter a number. + 請輸入數字。 + + + The password is invalid. + 密碼無效。 + + + Please enter a percentage value. + 請輸入百分比數值。 + + + The values do not match. + 數值不相符。 + + + Please enter a valid time. + 請輸入有效的時間。 + + + Please select a valid timezone. + 請選擇有效的時區。 + + + Please enter a valid URL. + 請輸入有效的 URL。 + + + Please enter a valid search term. + 請輸入有效的搜尋關鍵字。 + + + Please provide a valid phone number. + 請提供有效的電話號碼。 + + + The checkbox has an invalid value. + 核取方塊上有無效的值。 + + + Please enter a valid email address. + 請輸入有效的電子郵件地址。 + + + Please select a valid option. + 請選擇有效的選項。 + + + Please select a valid range. + 請選擇有效的範圍。 + + + Please enter a valid week. + 請輸入有效的星期。 + + + + diff --git a/lib/symfony/form/ReversedTransformer.php b/lib/symfony/form/ReversedTransformer.php new file mode 100644 index 000000000..857267236 --- /dev/null +++ b/lib/symfony/form/ReversedTransformer.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * Reverses a transformer. + * + * When the transform() method is called, the reversed transformer's + * reverseTransform() method is called and vice versa. + * + * @author Bernhard Schussek + */ +class ReversedTransformer implements DataTransformerInterface +{ + protected $reversedTransformer; + + public function __construct(DataTransformerInterface $reversedTransformer) + { + $this->reversedTransformer = $reversedTransformer; + } + + public function transform(mixed $value): mixed + { + return $this->reversedTransformer->reverseTransform($value); + } + + public function reverseTransform(mixed $value): mixed + { + return $this->reversedTransformer->transform($value); + } +} diff --git a/lib/symfony/form/SubmitButton.php b/lib/symfony/form/SubmitButton.php new file mode 100644 index 000000000..37ce141d2 --- /dev/null +++ b/lib/symfony/form/SubmitButton.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * A button that submits the form. + * + * @author Bernhard Schussek + */ +class SubmitButton extends Button implements ClickableInterface +{ + private bool $clicked = false; + + public function isClicked(): bool + { + return $this->clicked; + } + + /** + * Submits data to the button. + * + * @return $this + * + * @throws Exception\AlreadySubmittedException if the form has already been submitted + */ + public function submit(array|string|null $submittedData, bool $clearMissing = true): static + { + if ($this->getConfig()->getDisabled()) { + $this->clicked = false; + + return $this; + } + + parent::submit($submittedData, $clearMissing); + + $this->clicked = null !== $submittedData; + + return $this; + } +} diff --git a/lib/symfony/form/SubmitButtonBuilder.php b/lib/symfony/form/SubmitButtonBuilder.php new file mode 100644 index 000000000..b98398f90 --- /dev/null +++ b/lib/symfony/form/SubmitButtonBuilder.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * A builder for {@link SubmitButton} instances. + * + * @author Bernhard Schussek + */ +class SubmitButtonBuilder extends ButtonBuilder +{ + /** + * Creates the button. + */ + public function getForm(): SubmitButton + { + return new SubmitButton($this->getFormConfig()); + } +} diff --git a/lib/symfony/form/SubmitButtonTypeInterface.php b/lib/symfony/form/SubmitButtonTypeInterface.php new file mode 100644 index 000000000..f7ac13f7e --- /dev/null +++ b/lib/symfony/form/SubmitButtonTypeInterface.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; + +/** + * A type that should be converted into a {@link SubmitButton} instance. + * + * @author Bernhard Schussek + */ +interface SubmitButtonTypeInterface extends FormTypeInterface +{ +} diff --git a/lib/symfony/form/Test/FormBuilderInterface.php b/lib/symfony/form/Test/FormBuilderInterface.php new file mode 100644 index 000000000..185a8a12d --- /dev/null +++ b/lib/symfony/form/Test/FormBuilderInterface.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Test; + +use Symfony\Component\Form\FormBuilderInterface as BaseFormBuilderInterface; + +interface FormBuilderInterface extends \Iterator, BaseFormBuilderInterface +{ +} diff --git a/lib/symfony/form/Test/FormIntegrationTestCase.php b/lib/symfony/form/Test/FormIntegrationTestCase.php new file mode 100644 index 000000000..5bf37fd48 --- /dev/null +++ b/lib/symfony/form/Test/FormIntegrationTestCase.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\Test; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\Forms; + +/** + * @author Bernhard Schussek + */ +abstract class FormIntegrationTestCase extends TestCase +{ + protected FormFactoryInterface $factory; + + protected function setUp(): void + { + $this->factory = Forms::createFormFactoryBuilder() + ->addExtensions($this->getExtensions()) + ->addTypeExtensions($this->getTypeExtensions()) + ->addTypes($this->getTypes()) + ->addTypeGuessers($this->getTypeGuessers()) + ->getFormFactory(); + } + + protected function getExtensions() + { + return []; + } + + protected function getTypeExtensions() + { + return []; + } + + protected function getTypes() + { + return []; + } + + protected function getTypeGuessers() + { + return []; + } +} diff --git a/lib/symfony/form/Test/FormInterface.php b/lib/symfony/form/Test/FormInterface.php new file mode 100644 index 000000000..4af460308 --- /dev/null +++ b/lib/symfony/form/Test/FormInterface.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Test; + +use Symfony\Component\Form\FormInterface as BaseFormInterface; + +interface FormInterface extends \Iterator, BaseFormInterface +{ +} diff --git a/lib/symfony/form/Test/FormPerformanceTestCase.php b/lib/symfony/form/Test/FormPerformanceTestCase.php new file mode 100644 index 000000000..7774d9b9b --- /dev/null +++ b/lib/symfony/form/Test/FormPerformanceTestCase.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Test; + +use Symfony\Component\Form\Test\Traits\RunTestTrait; + +/** + * Base class for performance tests. + * + * Copied from Doctrine 2's OrmPerformanceTestCase. + * + * @author robo + * @author Bernhard Schussek + */ +abstract class FormPerformanceTestCase extends FormIntegrationTestCase +{ + use RunTestTrait; + + /** + * @var int + */ + protected $maxRunningTime = 0; + + private function doRunTest(): mixed + { + $s = microtime(true); + $result = parent::runTest(); + $time = microtime(true) - $s; + + if (0 != $this->maxRunningTime && $time > $this->maxRunningTime) { + $this->fail(sprintf('expected running time: <= %s but was: %s', $this->maxRunningTime, $time)); + } + + $this->expectNotToPerformAssertions(); + + return $result; + } + + /** + * @throws \InvalidArgumentException + */ + public function setMaxRunningTime(int $maxRunningTime) + { + if ($maxRunningTime < 0) { + throw new \InvalidArgumentException(); + } + + $this->maxRunningTime = $maxRunningTime; + } + + public function getMaxRunningTime(): int + { + return $this->maxRunningTime; + } +} diff --git a/lib/symfony/form/Test/Traits/RunTestTrait.php b/lib/symfony/form/Test/Traits/RunTestTrait.php new file mode 100644 index 000000000..17204b967 --- /dev/null +++ b/lib/symfony/form/Test/Traits/RunTestTrait.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Test\Traits; + +use PHPUnit\Framework\TestCase; + +if ((new \ReflectionMethod(TestCase::class, 'runTest'))->hasReturnType()) { + // PHPUnit 10 + /** @internal */ + trait RunTestTrait + { + protected function runTest(): mixed + { + return $this->doRunTest(); + } + } +} else { + // PHPUnit 9 + /** @internal */ + trait RunTestTrait + { + protected function runTest() + { + return $this->doRunTest(); + } + } +} diff --git a/lib/symfony/form/Test/Traits/ValidatorExtensionTrait.php b/lib/symfony/form/Test/Traits/ValidatorExtensionTrait.php new file mode 100644 index 000000000..70240fc3e --- /dev/null +++ b/lib/symfony/form/Test/Traits/ValidatorExtensionTrait.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Test\Traits; + +use Symfony\Component\Form\Extension\Validator\ValidatorExtension; +use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +trait ValidatorExtensionTrait +{ + /** + * @var ValidatorInterface|null + */ + protected $validator; + + protected function getValidatorExtension(): ValidatorExtension + { + if (!interface_exists(ValidatorInterface::class)) { + throw new \Exception('In order to use the "ValidatorExtensionTrait", the symfony/validator component must be installed.'); + } + + if (!$this instanceof TypeTestCase) { + throw new \Exception(sprintf('The trait "ValidatorExtensionTrait" can only be added to a class that extends "%s".', TypeTestCase::class)); + } + + $this->validator = $this->createMock(ValidatorInterface::class); + $metadata = $this->getMockBuilder(ClassMetadata::class)->setConstructorArgs([''])->onlyMethods(['addPropertyConstraint'])->getMock(); + $this->validator->expects($this->any())->method('getMetadataFor')->willReturn($metadata); + $this->validator->expects($this->any())->method('validate')->willReturn(new ConstraintViolationList()); + + return new ValidatorExtension($this->validator, false); + } +} diff --git a/lib/symfony/form/Test/TypeTestCase.php b/lib/symfony/form/Test/TypeTestCase.php new file mode 100644 index 000000000..ac8eb9baa --- /dev/null +++ b/lib/symfony/form/Test/TypeTestCase.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Test; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Form\FormBuilder; +use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait; + +abstract class TypeTestCase extends FormIntegrationTestCase +{ + /** + * @var FormBuilder + */ + protected $builder; + + /** + * @var EventDispatcherInterface + */ + protected $dispatcher; + + protected function setUp(): void + { + parent::setUp(); + + $this->dispatcher = $this->createMock(EventDispatcherInterface::class); + $this->builder = new FormBuilder('', null, $this->dispatcher, $this->factory); + } + + protected function getExtensions() + { + $extensions = []; + + if (\in_array(ValidatorExtensionTrait::class, class_uses($this))) { + $extensions[] = $this->getValidatorExtension(); + } + + return $extensions; + } + + public static function assertDateTimeEquals(\DateTime $expected, \DateTime $actual) + { + self::assertEquals($expected->format('c'), $actual->format('c')); + } + + public static function assertDateIntervalEquals(\DateInterval $expected, \DateInterval $actual) + { + self::assertEquals($expected->format('%RP%yY%mM%dDT%hH%iM%sS'), $actual->format('%RP%yY%mM%dDT%hH%iM%sS')); + } +} diff --git a/lib/symfony/form/Util/FormUtil.php b/lib/symfony/form/Util/FormUtil.php new file mode 100644 index 000000000..1a5cd3b15 --- /dev/null +++ b/lib/symfony/form/Util/FormUtil.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\Util; + +/** + * @author Bernhard Schussek + */ +class FormUtil +{ + /** + * This class should not be instantiated. + */ + private function __construct() + { + } + + /** + * Returns whether the given data is empty. + * + * This logic is reused multiple times throughout the processing of + * a form and needs to be consistent. PHP keyword `empty` cannot + * be used as it also considers 0 and "0" to be empty. + */ + public static function isEmpty(mixed $data): bool + { + // Should not do a check for [] === $data!!! + // This method is used in occurrences where arrays are + // not considered to be empty, ever. + return null === $data || '' === $data; + } + + /** + * Recursively replaces or appends elements of the first array with elements + * of second array. If the key is an integer, the values will be appended to + * the new array; otherwise, the value from the second array will replace + * the one from the first array. + */ + public static function mergeParamsAndFiles(array $params, array $files): array + { + $isFilesList = array_is_list($files); + + foreach ($params as $key => $value) { + if (\is_array($value) && \is_array($files[$key] ?? null)) { + $params[$key] = self::mergeParamsAndFiles($value, $files[$key]); + unset($files[$key]); + } + } + + if (!$isFilesList) { + return array_replace($params, $files); + } + + foreach ($files as $value) { + $params[] = $value; + } + + return $params; + } +} diff --git a/lib/symfony/form/Util/InheritDataAwareIterator.php b/lib/symfony/form/Util/InheritDataAwareIterator.php new file mode 100644 index 000000000..26a213522 --- /dev/null +++ b/lib/symfony/form/Util/InheritDataAwareIterator.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Util; + +/** + * Iterator that traverses an array of forms. + * + * Contrary to \ArrayIterator, this iterator recognizes changes in the original + * array during iteration. + * + * You can wrap the iterator into a {@link \RecursiveIteratorIterator} in order to + * enter any child form that inherits its parent's data and iterate the children + * of that form as well. + * + * @author Bernhard Schussek + */ +class InheritDataAwareIterator extends \IteratorIterator implements \RecursiveIterator +{ + public function getChildren(): static + { + return new static($this->current()); + } + + public function hasChildren(): bool + { + return (bool) $this->current()->getConfig()->getInheritData(); + } +} diff --git a/lib/symfony/form/Util/OptionsResolverWrapper.php b/lib/symfony/form/Util/OptionsResolverWrapper.php new file mode 100644 index 000000000..51cba4e08 --- /dev/null +++ b/lib/symfony/form/Util/OptionsResolverWrapper.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\Util; + +use Symfony\Component\OptionsResolver\Exception\AccessException; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Yonel Ceruto + * + * @internal + */ +class OptionsResolverWrapper extends OptionsResolver +{ + private array $undefined = []; + + /** + * @return $this + */ + public function setNormalizer(string $option, \Closure $normalizer): static + { + try { + parent::setNormalizer($option, $normalizer); + } catch (UndefinedOptionsException) { + $this->undefined[$option] = true; + } + + return $this; + } + + /** + * @return $this + */ + public function setAllowedValues(string $option, mixed $allowedValues): static + { + try { + parent::setAllowedValues($option, $allowedValues); + } catch (UndefinedOptionsException) { + $this->undefined[$option] = true; + } + + return $this; + } + + /** + * @return $this + */ + public function addAllowedValues(string $option, mixed $allowedValues): static + { + try { + parent::addAllowedValues($option, $allowedValues); + } catch (UndefinedOptionsException) { + $this->undefined[$option] = true; + } + + return $this; + } + + /** + * @param string|array $allowedTypes + * + * @return $this + */ + public function setAllowedTypes(string $option, $allowedTypes): static + { + try { + parent::setAllowedTypes($option, $allowedTypes); + } catch (UndefinedOptionsException) { + $this->undefined[$option] = true; + } + + return $this; + } + + /** + * @param string|array $allowedTypes + * + * @return $this + */ + public function addAllowedTypes(string $option, $allowedTypes): static + { + try { + parent::addAllowedTypes($option, $allowedTypes); + } catch (UndefinedOptionsException) { + $this->undefined[$option] = true; + } + + return $this; + } + + public function resolve(array $options = []): array + { + throw new AccessException('Resolve options is not supported.'); + } + + public function getUndefinedOptions(): array + { + return array_keys($this->undefined); + } +} diff --git a/lib/symfony/form/Util/OrderedHashMap.php b/lib/symfony/form/Util/OrderedHashMap.php new file mode 100644 index 000000000..32d08caa8 --- /dev/null +++ b/lib/symfony/form/Util/OrderedHashMap.php @@ -0,0 +1,162 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Util; + +/** + * A hash map which keeps track of deletions and additions. + * + * Like in associative arrays, elements can be mapped to integer or string keys. + * Unlike associative arrays, the map keeps track of the order in which keys + * were added and removed. This order is reflected during iteration. + * + * The map supports concurrent modification during iteration. That means that + * you can insert and remove elements from within a foreach loop and the + * iterator will reflect those changes accordingly. + * + * While elements that are added during the loop are recognized by the iterator, + * changed elements are not. Otherwise the loop could be infinite if each loop + * changes the current element: + * + * $map = new OrderedHashMap(); + * $map[1] = 1; + * $map[2] = 2; + * $map[3] = 3; + * + * foreach ($map as $index => $value) { + * echo "$index: $value\n" + * if (1 === $index) { + * $map[1] = 4; + * $map[] = 5; + * } + * } + * + * print_r(iterator_to_array($map)); + * + * // => 1: 1 + * // 2: 2 + * // 3: 3 + * // 4: 5 + * // Array + * // ( + * // [1] => 4 + * // [2] => 2 + * // [3] => 3 + * // [4] => 5 + * // ) + * + * The map also supports multiple parallel iterators. That means that you can + * nest foreach loops without affecting each other's iteration: + * + * foreach ($map as $index => $value) { + * foreach ($map as $index2 => $value2) { + * // ... + * } + * } + * + * @author Bernhard Schussek + * + * @template TValue + * + * @implements \ArrayAccess + * @implements \IteratorAggregate + */ +class OrderedHashMap implements \ArrayAccess, \IteratorAggregate, \Countable +{ + /** + * The elements of the map, indexed by their keys. + * + * @var TValue[] + */ + private array $elements = []; + + /** + * The keys of the map in the order in which they were inserted or changed. + * + * @var list + */ + private array $orderedKeys = []; + + /** + * References to the cursors of all open iterators. + * + * @var array + */ + private array $managedCursors = []; + + /** + * Creates a new map. + * + * @param TValue[] $elements The elements to insert initially + */ + public function __construct(array $elements = []) + { + $this->elements = $elements; + // the explicit string type-cast is necessary as digit-only keys would be returned as integers otherwise + $this->orderedKeys = array_map(strval(...), array_keys($elements)); + } + + public function offsetExists(mixed $key): bool + { + return isset($this->elements[$key]); + } + + public function offsetGet(mixed $key): mixed + { + if (!isset($this->elements[$key])) { + throw new \OutOfBoundsException(sprintf('The offset "%s" does not exist.', $key)); + } + + return $this->elements[$key]; + } + + public function offsetSet(mixed $key, mixed $value): void + { + if (null === $key || !isset($this->elements[$key])) { + if (null === $key) { + $key = [] === $this->orderedKeys + // If the array is empty, use 0 as key + ? 0 + // Imitate PHP behavior of generating a key that equals + // the highest existing integer key + 1 + : 1 + (int) max($this->orderedKeys); + } + + $this->orderedKeys[] = (string) $key; + } + + $this->elements[$key] = $value; + } + + public function offsetUnset(mixed $key): void + { + if (false !== ($position = array_search((string) $key, $this->orderedKeys))) { + array_splice($this->orderedKeys, $position, 1); + unset($this->elements[$key]); + + foreach ($this->managedCursors as $i => $cursor) { + if ($cursor >= $position) { + --$this->managedCursors[$i]; + } + } + } + } + + public function getIterator(): \Traversable + { + return new OrderedHashMapIterator($this->elements, $this->orderedKeys, $this->managedCursors); + } + + public function count(): int + { + return \count($this->elements); + } +} diff --git a/lib/symfony/form/Util/OrderedHashMapIterator.php b/lib/symfony/form/Util/OrderedHashMapIterator.php new file mode 100644 index 000000000..a7a40779b --- /dev/null +++ b/lib/symfony/form/Util/OrderedHashMapIterator.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Util; + +/** + * Iterator for {@link OrderedHashMap} objects. + * + * @author Bernhard Schussek + * + * @internal + * + * @template-covariant TValue + * + * @implements \Iterator + */ +class OrderedHashMapIterator implements \Iterator +{ + /** @var TValue[] */ + private array $elements; + /** @var list */ + private array $orderedKeys; + private int $cursor = 0; + private int $cursorId; + /** @var array */ + private array $managedCursors; + private ?string $key = null; + /** @var TValue|null */ + private mixed $current = null; + + /** + * @param TValue[] $elements The elements of the map, indexed by their + * keys + * @param list $orderedKeys The keys of the map in the order in which + * they should be iterated + * @param array $managedCursors An array from which to reference the + * iterator's cursor as long as it is alive. + * This array is managed by the corresponding + * {@link OrderedHashMap} instance to support + * recognizing the deletion of elements. + */ + public function __construct(array &$elements, array &$orderedKeys, array &$managedCursors) + { + $this->elements = &$elements; + $this->orderedKeys = &$orderedKeys; + $this->managedCursors = &$managedCursors; + $this->cursorId = \count($managedCursors); + + $this->managedCursors[$this->cursorId] = &$this->cursor; + } + + public function __sleep(): array + { + throw new \BadMethodCallException('Cannot serialize '.__CLASS__); + } + + /** + * @return void + */ + public function __wakeup() + { + throw new \BadMethodCallException('Cannot unserialize '.__CLASS__); + } + + /** + * Removes the iterator's cursors from the managed cursors of the + * corresponding {@link OrderedHashMap} instance. + */ + public function __destruct() + { + // Use array_splice() instead of unset() to prevent holes in the + // array indices, which would break the initialization of $cursorId + array_splice($this->managedCursors, $this->cursorId, 1); + } + + public function current(): mixed + { + return $this->current; + } + + public function next(): void + { + ++$this->cursor; + + if (isset($this->orderedKeys[$this->cursor])) { + $this->key = $this->orderedKeys[$this->cursor]; + $this->current = $this->elements[$this->key]; + } else { + $this->key = null; + $this->current = null; + } + } + + public function key(): mixed + { + if (null === $this->key) { + return null; + } + + return $this->key; + } + + public function valid(): bool + { + return null !== $this->key; + } + + public function rewind(): void + { + $this->cursor = 0; + + if (isset($this->orderedKeys[0])) { + $this->key = $this->orderedKeys[0]; + $this->current = $this->elements[$this->key]; + } else { + $this->key = null; + $this->current = null; + } + } +} diff --git a/lib/symfony/form/Util/ServerParams.php b/lib/symfony/form/Util/ServerParams.php new file mode 100644 index 000000000..e53faaa8a --- /dev/null +++ b/lib/symfony/form/Util/ServerParams.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Util; + +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @author Bernhard Schussek + */ +class ServerParams +{ + private ?RequestStack $requestStack; + + public function __construct(?RequestStack $requestStack = null) + { + $this->requestStack = $requestStack; + } + + /** + * Returns true if the POST max size has been exceeded in the request. + */ + public function hasPostMaxSizeBeenExceeded(): bool + { + $contentLength = $this->getContentLength(); + $maxContentLength = $this->getPostMaxSize(); + + return $maxContentLength && $contentLength > $maxContentLength; + } + + /** + * Returns maximum post size in bytes. + */ + public function getPostMaxSize(): int|float|null + { + $iniMax = strtolower($this->getNormalizedIniPostMaxSize()); + + if ('' === $iniMax) { + return null; + } + + $max = ltrim($iniMax, '+'); + if (str_starts_with($max, '0x')) { + $max = \intval($max, 16); + } elseif (str_starts_with($max, '0')) { + $max = \intval($max, 8); + } else { + $max = (int) $max; + } + + switch (substr($iniMax, -1)) { + case 't': $max *= 1024; + // no break + case 'g': $max *= 1024; + // no break + case 'm': $max *= 1024; + // no break + case 'k': $max *= 1024; + } + + return $max; + } + + /** + * Returns the normalized "post_max_size" ini setting. + */ + public function getNormalizedIniPostMaxSize(): string + { + return strtoupper(trim(\ini_get('post_max_size'))); + } + + /** + * Returns the content length of the request. + */ + public function getContentLength(): mixed + { + if (null !== $this->requestStack && null !== $request = $this->requestStack->getCurrentRequest()) { + return $request->server->get('CONTENT_LENGTH'); + } + + return isset($_SERVER['CONTENT_LENGTH']) + ? (int) $_SERVER['CONTENT_LENGTH'] + : null; + } +} diff --git a/lib/symfony/form/Util/StringUtil.php b/lib/symfony/form/Util/StringUtil.php new file mode 100644 index 000000000..45a50c1ad --- /dev/null +++ b/lib/symfony/form/Util/StringUtil.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Util; + +/** + * @author Issei Murasawa + * @author Bernhard Schussek + */ +class StringUtil +{ + /** + * This class should not be instantiated. + */ + private function __construct() + { + } + + /** + * Returns the trimmed data. + */ + public static function trim(string $string): string + { + if (null !== $result = @preg_replace('/^[\pZ\p{Cc}\p{Cf}]+|[\pZ\p{Cc}\p{Cf}]+$/u', '', $string)) { + return $result; + } + + return trim($string); + } + + /** + * Converts a fully-qualified class name to a block prefix. + * + * @param string $fqcn The fully-qualified class name + */ + public static function fqcnToBlockPrefix(string $fqcn): ?string + { + // Non-greedy ("+?") to match "type" suffix, if present + if (preg_match('~([^\\\\]+?)(type)?$~i', $fqcn, $matches)) { + return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], ['\\1_\\2', '\\1_\\2'], $matches[1])); + } + + return null; + } +} diff --git a/lib/symfony/form/composer.json b/lib/symfony/form/composer.json new file mode 100644 index 000000000..0042f8b6d --- /dev/null +++ b/lib/symfony/form/composer.json @@ -0,0 +1,64 @@ +{ + "name": "symfony/form", + "type": "library", + "description": "Allows to easily create, process and reuse HTML forms", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/options-resolver": "^5.4|^6.0|^7.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3" + }, + "require-dev": { + "doctrine/collections": "^1.0|^2.0", + "symfony/validator": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/html-sanitizer": "^6.1|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/security-core": "^6.2|^7.0", + "symfony/security-csrf": "^5.4|^6.0|^7.0", + "symfony/translation": "^5.4.35|~6.3.12|^6.4.3|^7.0.3", + "symfony/var-dumper": "^5.4|^6.0|^7.0", + "symfony/uid": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/doctrine-bridge": "<5.4.21|>=6,<6.2.7", + "symfony/error-handler": "<5.4", + "symfony/framework-bundle": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/translation": "<5.4.35|>=6.0,<6.3.12|>=6.4,<6.4.3|>=7.0,<7.0.3", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Form\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/lib/symfony/options-resolver/CHANGELOG.md b/lib/symfony/options-resolver/CHANGELOG.md new file mode 100644 index 000000000..f4de6d01f --- /dev/null +++ b/lib/symfony/options-resolver/CHANGELOG.md @@ -0,0 +1,96 @@ +CHANGELOG +========= + +6.4 +--- + +* Improve message with full path on invalid type in nested option + +6.3 +--- + + * Add `OptionsResolver::setIgnoreUndefined()` and `OptionConfigurator::ignoreUndefined()` to ignore not defined options while resolving + +6.0 +--- + + * Remove `OptionsResolverIntrospector::getDeprecationMessage()` + +5.3 +--- + + * Add prototype definition for nested options + +5.1.0 +----- + + * added fluent configuration of options using `OptionResolver::define()` + * added `setInfo()` and `getInfo()` methods + * updated the signature of method `OptionsResolver::setDeprecated()` to `OptionsResolver::setDeprecation(string $option, string $package, string $version, $message)` + * deprecated `OptionsResolverIntrospector::getDeprecationMessage()`, use `OptionsResolverIntrospector::getDeprecation()` instead + +5.0.0 +----- + + * added argument `$triggerDeprecation` to `OptionsResolver::offsetGet()` + +4.3.0 +----- + + * added `OptionsResolver::addNormalizer` method + +4.2.0 +----- + + * added support for nested options definition + * added `setDeprecated` and `isDeprecated` methods + +3.4.0 +----- + + * added `OptionsResolverIntrospector` to inspect options definitions inside an `OptionsResolver` instance + * added array of types support in allowed types (e.g int[]) + +2.6.0 +----- + + * deprecated OptionsResolverInterface + * [BC BREAK] removed "array" type hint from OptionsResolverInterface methods + setRequired(), setAllowedValues(), addAllowedValues(), setAllowedTypes() and + addAllowedTypes() + * added OptionsResolver::setDefault() + * added OptionsResolver::hasDefault() + * added OptionsResolver::setNormalizer() + * added OptionsResolver::isRequired() + * added OptionsResolver::getRequiredOptions() + * added OptionsResolver::isMissing() + * added OptionsResolver::getMissingOptions() + * added OptionsResolver::setDefined() + * added OptionsResolver::isDefined() + * added OptionsResolver::getDefinedOptions() + * added OptionsResolver::remove() + * added OptionsResolver::clear() + * deprecated OptionsResolver::replaceDefaults() + * deprecated OptionsResolver::setOptional() in favor of setDefined() + * deprecated OptionsResolver::isKnown() in favor of isDefined() + * [BC BREAK] OptionsResolver::isRequired() returns true now if a required + option has a default value set + * [BC BREAK] merged Options into OptionsResolver and turned Options into an + interface + * deprecated Options::overload() (now in OptionsResolver) + * deprecated Options::set() (now in OptionsResolver) + * deprecated Options::get() (now in OptionsResolver) + * deprecated Options::has() (now in OptionsResolver) + * deprecated Options::replace() (now in OptionsResolver) + * [BC BREAK] Options::get() (now in OptionsResolver) can only be used within + lazy option/normalizer closures now + * [BC BREAK] removed Traversable interface from Options since using within + lazy option/normalizer closures resulted in exceptions + * [BC BREAK] removed Options::all() since using within lazy option/normalizer + closures resulted in exceptions + * [BC BREAK] OptionDefinitionException now extends LogicException instead of + RuntimeException + * [BC BREAK] normalizers are not executed anymore for unset options + * normalizers are executed after validating the options now + * [BC BREAK] an UndefinedOptionsException is now thrown instead of an + InvalidOptionsException when non-existing options are passed diff --git a/lib/symfony/options-resolver/Debug/OptionsResolverIntrospector.php b/lib/symfony/options-resolver/Debug/OptionsResolverIntrospector.php new file mode 100644 index 000000000..f55ab147b --- /dev/null +++ b/lib/symfony/options-resolver/Debug/OptionsResolverIntrospector.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver\Debug; + +use Symfony\Component\OptionsResolver\Exception\NoConfigurationException; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Maxime Steinhausser + * + * @final + */ +class OptionsResolverIntrospector +{ + private \Closure $get; + + public function __construct(OptionsResolver $optionsResolver) + { + $this->get = \Closure::bind(function ($property, $option, $message) { + /** @var OptionsResolver $this */ + if (!$this->isDefined($option)) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist.', $option)); + } + + if (!\array_key_exists($option, $this->{$property})) { + throw new NoConfigurationException($message); + } + + return $this->{$property}[$option]; + }, $optionsResolver, $optionsResolver); + } + + /** + * @throws NoConfigurationException on no configured value + */ + public function getDefault(string $option): mixed + { + return ($this->get)('defaults', $option, sprintf('No default value was set for the "%s" option.', $option)); + } + + /** + * @return \Closure[] + * + * @throws NoConfigurationException on no configured closures + */ + public function getLazyClosures(string $option): array + { + return ($this->get)('lazy', $option, sprintf('No lazy closures were set for the "%s" option.', $option)); + } + + /** + * @return string[] + * + * @throws NoConfigurationException on no configured types + */ + public function getAllowedTypes(string $option): array + { + return ($this->get)('allowedTypes', $option, sprintf('No allowed types were set for the "%s" option.', $option)); + } + + /** + * @return mixed[] + * + * @throws NoConfigurationException on no configured values + */ + public function getAllowedValues(string $option): array + { + return ($this->get)('allowedValues', $option, sprintf('No allowed values were set for the "%s" option.', $option)); + } + + /** + * @throws NoConfigurationException on no configured normalizer + */ + public function getNormalizer(string $option): \Closure + { + return current($this->getNormalizers($option)); + } + + /** + * @throws NoConfigurationException when no normalizer is configured + */ + public function getNormalizers(string $option): array + { + return ($this->get)('normalizers', $option, sprintf('No normalizer was set for the "%s" option.', $option)); + } + + /** + * @throws NoConfigurationException on no configured deprecation + */ + public function getDeprecation(string $option): array + { + return ($this->get)('deprecated', $option, sprintf('No deprecation was set for the "%s" option.', $option)); + } +} diff --git a/lib/symfony/options-resolver/Exception/AccessException.php b/lib/symfony/options-resolver/Exception/AccessException.php new file mode 100644 index 000000000..c12b68064 --- /dev/null +++ b/lib/symfony/options-resolver/Exception/AccessException.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver\Exception; + +/** + * Thrown when trying to read an option outside of or write it inside of + * {@link \Symfony\Component\OptionsResolver\Options::resolve()}. + * + * @author Bernhard Schussek + */ +class AccessException extends \LogicException implements ExceptionInterface +{ +} diff --git a/lib/symfony/options-resolver/Exception/ExceptionInterface.php b/lib/symfony/options-resolver/Exception/ExceptionInterface.php new file mode 100644 index 000000000..ea99d050e --- /dev/null +++ b/lib/symfony/options-resolver/Exception/ExceptionInterface.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\OptionsResolver\Exception; + +/** + * Marker interface for all exceptions thrown by the OptionsResolver component. + * + * @author Bernhard Schussek + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/lib/symfony/options-resolver/Exception/InvalidArgumentException.php b/lib/symfony/options-resolver/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..6d421d68b --- /dev/null +++ b/lib/symfony/options-resolver/Exception/InvalidArgumentException.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\OptionsResolver\Exception; + +/** + * Thrown when an argument is invalid. + * + * @author Bernhard Schussek + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/lib/symfony/options-resolver/Exception/InvalidOptionsException.php b/lib/symfony/options-resolver/Exception/InvalidOptionsException.php new file mode 100644 index 000000000..6fd4f125f --- /dev/null +++ b/lib/symfony/options-resolver/Exception/InvalidOptionsException.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\OptionsResolver\Exception; + +/** + * Thrown when the value of an option does not match its validation rules. + * + * You should make sure a valid value is passed to the option. + * + * @author Bernhard Schussek + */ +class InvalidOptionsException extends InvalidArgumentException +{ +} diff --git a/lib/symfony/options-resolver/Exception/MissingOptionsException.php b/lib/symfony/options-resolver/Exception/MissingOptionsException.php new file mode 100644 index 000000000..faa487f16 --- /dev/null +++ b/lib/symfony/options-resolver/Exception/MissingOptionsException.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\OptionsResolver\Exception; + +/** + * Exception thrown when a required option is missing. + * + * Add the option to the passed options array. + * + * @author Bernhard Schussek + */ +class MissingOptionsException extends InvalidArgumentException +{ +} diff --git a/lib/symfony/options-resolver/Exception/NoConfigurationException.php b/lib/symfony/options-resolver/Exception/NoConfigurationException.php new file mode 100644 index 000000000..6693ec14d --- /dev/null +++ b/lib/symfony/options-resolver/Exception/NoConfigurationException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver\Exception; + +use Symfony\Component\OptionsResolver\Debug\OptionsResolverIntrospector; + +/** + * Thrown when trying to introspect an option definition property + * for which no value was configured inside the OptionsResolver instance. + * + * @see OptionsResolverIntrospector + * + * @author Maxime Steinhausser + */ +class NoConfigurationException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/lib/symfony/options-resolver/Exception/NoSuchOptionException.php b/lib/symfony/options-resolver/Exception/NoSuchOptionException.php new file mode 100644 index 000000000..4c3280f4c --- /dev/null +++ b/lib/symfony/options-resolver/Exception/NoSuchOptionException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver\Exception; + +/** + * Thrown when trying to read an option that has no value set. + * + * When accessing optional options from within a lazy option or normalizer you should first + * check whether the optional option is set. You can do this with `isset($options['optional'])`. + * In contrast to the {@link UndefinedOptionsException}, this is a runtime exception that can + * occur when evaluating lazy options. + * + * @author Tobias Schultze + */ +class NoSuchOptionException extends \OutOfBoundsException implements ExceptionInterface +{ +} diff --git a/lib/symfony/options-resolver/Exception/OptionDefinitionException.php b/lib/symfony/options-resolver/Exception/OptionDefinitionException.php new file mode 100644 index 000000000..e8e339d44 --- /dev/null +++ b/lib/symfony/options-resolver/Exception/OptionDefinitionException.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\OptionsResolver\Exception; + +/** + * Thrown when two lazy options have a cyclic dependency. + * + * @author Bernhard Schussek + */ +class OptionDefinitionException extends \LogicException implements ExceptionInterface +{ +} diff --git a/lib/symfony/options-resolver/Exception/UndefinedOptionsException.php b/lib/symfony/options-resolver/Exception/UndefinedOptionsException.php new file mode 100644 index 000000000..6ca3fce47 --- /dev/null +++ b/lib/symfony/options-resolver/Exception/UndefinedOptionsException.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver\Exception; + +/** + * Exception thrown when an undefined option is passed. + * + * You should remove the options in question from your code or define them + * beforehand. + * + * @author Bernhard Schussek + */ +class UndefinedOptionsException extends InvalidArgumentException +{ +} diff --git a/lib/symfony/options-resolver/LICENSE b/lib/symfony/options-resolver/LICENSE new file mode 100644 index 000000000..0138f8f07 --- /dev/null +++ b/lib/symfony/options-resolver/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/symfony/options-resolver/OptionConfigurator.php b/lib/symfony/options-resolver/OptionConfigurator.php new file mode 100644 index 000000000..3aa37288a --- /dev/null +++ b/lib/symfony/options-resolver/OptionConfigurator.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver; + +use Symfony\Component\OptionsResolver\Exception\AccessException; + +final class OptionConfigurator +{ + private string $name; + private OptionsResolver $resolver; + + public function __construct(string $name, OptionsResolver $resolver) + { + $this->name = $name; + $this->resolver = $resolver; + $this->resolver->setDefined($name); + } + + /** + * Adds allowed types for this option. + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function allowedTypes(string ...$types): static + { + $this->resolver->setAllowedTypes($this->name, $types); + + return $this; + } + + /** + * Sets allowed values for this option. + * + * @param mixed ...$values One or more acceptable values/closures + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function allowedValues(mixed ...$values): static + { + $this->resolver->setAllowedValues($this->name, $values); + + return $this; + } + + /** + * Sets the default value for this option. + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function default(mixed $value): static + { + $this->resolver->setDefault($this->name, $value); + + return $this; + } + + /** + * Defines an option configurator with the given name. + */ + public function define(string $option): self + { + return $this->resolver->define($option); + } + + /** + * Marks this option as deprecated. + * + * @param string $package The name of the composer package that is triggering the deprecation + * @param string $version The version of the package that introduced the deprecation + * @param string|\Closure $message The deprecation message to use + * + * @return $this + */ + public function deprecated(string $package, string $version, string|\Closure $message = 'The option "%name%" is deprecated.'): static + { + $this->resolver->setDeprecated($this->name, $package, $version, $message); + + return $this; + } + + /** + * Sets the normalizer for this option. + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function normalize(\Closure $normalizer): static + { + $this->resolver->setNormalizer($this->name, $normalizer); + + return $this; + } + + /** + * Marks this option as required. + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function required(): static + { + $this->resolver->setRequired($this->name); + + return $this; + } + + /** + * Sets an info message for an option. + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function info(string $info): static + { + $this->resolver->setInfo($this->name, $info); + + return $this; + } + + /** + * Sets whether ignore undefined options. + * + * @return $this + */ + public function ignoreUndefined(bool $ignore = true): static + { + $this->resolver->setIgnoreUndefined($ignore); + + return $this; + } +} diff --git a/lib/symfony/options-resolver/Options.php b/lib/symfony/options-resolver/Options.php new file mode 100644 index 000000000..d444ec423 --- /dev/null +++ b/lib/symfony/options-resolver/Options.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver; + +/** + * Contains resolved option values. + * + * @author Bernhard Schussek + * @author Tobias Schultze + */ +interface Options extends \ArrayAccess, \Countable +{ +} diff --git a/lib/symfony/options-resolver/OptionsResolver.php b/lib/symfony/options-resolver/OptionsResolver.php new file mode 100644 index 000000000..b13c2c43b --- /dev/null +++ b/lib/symfony/options-resolver/OptionsResolver.php @@ -0,0 +1,1317 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver; + +use Symfony\Component\OptionsResolver\Exception\AccessException; +use Symfony\Component\OptionsResolver\Exception\InvalidArgumentException; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\OptionsResolver\Exception\NoSuchOptionException; +use Symfony\Component\OptionsResolver\Exception\OptionDefinitionException; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; + +/** + * Validates options and merges them with default values. + * + * @author Bernhard Schussek + * @author Tobias Schultze + */ +class OptionsResolver implements Options +{ + private const VALIDATION_FUNCTIONS = [ + 'bool' => 'is_bool', + 'boolean' => 'is_bool', + 'int' => 'is_int', + 'integer' => 'is_int', + 'long' => 'is_int', + 'float' => 'is_float', + 'double' => 'is_float', + 'real' => 'is_float', + 'numeric' => 'is_numeric', + 'string' => 'is_string', + 'scalar' => 'is_scalar', + 'array' => 'is_array', + 'iterable' => 'is_iterable', + 'countable' => 'is_countable', + 'callable' => 'is_callable', + 'object' => 'is_object', + 'resource' => 'is_resource', + ]; + + /** + * The names of all defined options. + */ + private array $defined = []; + + /** + * The default option values. + */ + private array $defaults = []; + + /** + * A list of closure for nested options. + * + * @var \Closure[][] + */ + private array $nested = []; + + /** + * The names of required options. + */ + private array $required = []; + + /** + * The resolved option values. + */ + private array $resolved = []; + + /** + * A list of normalizer closures. + * + * @var \Closure[][] + */ + private array $normalizers = []; + + /** + * A list of accepted values for each option. + */ + private array $allowedValues = []; + + /** + * A list of accepted types for each option. + */ + private array $allowedTypes = []; + + /** + * A list of info messages for each option. + */ + private array $info = []; + + /** + * A list of closures for evaluating lazy options. + */ + private array $lazy = []; + + /** + * A list of lazy options whose closure is currently being called. + * + * This list helps detecting circular dependencies between lazy options. + */ + private array $calling = []; + + /** + * A list of deprecated options. + */ + private array $deprecated = []; + + /** + * The list of options provided by the user. + */ + private array $given = []; + + /** + * Whether the instance is locked for reading. + * + * Once locked, the options cannot be changed anymore. This is + * necessary in order to avoid inconsistencies during the resolving + * process. If any option is changed after being read, all evaluated + * lazy options that depend on this option would become invalid. + */ + private bool $locked = false; + + private array $parentsOptions = []; + + /** + * Whether the whole options definition is marked as array prototype. + */ + private ?bool $prototype = null; + + /** + * The prototype array's index that is being read. + */ + private int|string|null $prototypeIndex = null; + + /** + * Whether to ignore undefined options. + */ + private bool $ignoreUndefined = false; + + /** + * Sets the default value of a given option. + * + * If the default value should be set based on other options, you can pass + * a closure with the following signature: + * + * function (Options $options) { + * // ... + * } + * + * The closure will be evaluated when {@link resolve()} is called. The + * closure has access to the resolved values of other options through the + * passed {@link Options} instance: + * + * function (Options $options) { + * if (isset($options['port'])) { + * // ... + * } + * } + * + * If you want to access the previously set default value, add a second + * argument to the closure's signature: + * + * $options->setDefault('name', 'Default Name'); + * + * $options->setDefault('name', function (Options $options, $previousValue) { + * // 'Default Name' === $previousValue + * }); + * + * This is mostly useful if the configuration of the {@link Options} object + * is spread across different locations of your code, such as base and + * sub-classes. + * + * If you want to define nested options, you can pass a closure with the + * following signature: + * + * $options->setDefault('database', function (OptionsResolver $resolver) { + * $resolver->setDefined(['dbname', 'host', 'port', 'user', 'pass']); + * } + * + * To get access to the parent options, add a second argument to the closure's + * signature: + * + * function (OptionsResolver $resolver, Options $parent) { + * // 'default' === $parent['connection'] + * } + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function setDefault(string $option, mixed $value): static + { + // Setting is not possible once resolving starts, because then lazy + // options could manipulate the state of the object, leading to + // inconsistent results. + if ($this->locked) { + throw new AccessException('Default values cannot be set from a lazy option or normalizer.'); + } + + // If an option is a closure that should be evaluated lazily, store it + // in the "lazy" property. + if ($value instanceof \Closure) { + $reflClosure = new \ReflectionFunction($value); + $params = $reflClosure->getParameters(); + + if (isset($params[0]) && Options::class === $this->getParameterClassName($params[0])) { + // Initialize the option if no previous value exists + if (!isset($this->defaults[$option])) { + $this->defaults[$option] = null; + } + + // Ignore previous lazy options if the closure has no second parameter + if (!isset($this->lazy[$option]) || !isset($params[1])) { + $this->lazy[$option] = []; + } + + // Store closure for later evaluation + $this->lazy[$option][] = $value; + $this->defined[$option] = true; + + // Make sure the option is processed and is not nested anymore + unset($this->resolved[$option], $this->nested[$option]); + + return $this; + } + + if (isset($params[0]) && ($type = $params[0]->getType()) instanceof \ReflectionNamedType && self::class === $type->getName() && (!isset($params[1]) || (($type = $params[1]->getType()) instanceof \ReflectionNamedType && Options::class === $type->getName()))) { + // Store closure for later evaluation + $this->nested[$option][] = $value; + $this->defaults[$option] = []; + $this->defined[$option] = true; + + // Make sure the option is processed and is not lazy anymore + unset($this->resolved[$option], $this->lazy[$option]); + + return $this; + } + } + + // This option is not lazy nor nested anymore + unset($this->lazy[$option], $this->nested[$option]); + + // Yet undefined options can be marked as resolved, because we only need + // to resolve options with lazy closures, normalizers or validation + // rules, none of which can exist for undefined options + // If the option was resolved before, update the resolved value + if (!isset($this->defined[$option]) || \array_key_exists($option, $this->resolved)) { + $this->resolved[$option] = $value; + } + + $this->defaults[$option] = $value; + $this->defined[$option] = true; + + return $this; + } + + /** + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function setDefaults(array $defaults): static + { + foreach ($defaults as $option => $value) { + $this->setDefault($option, $value); + } + + return $this; + } + + /** + * Returns whether a default value is set for an option. + * + * Returns true if {@link setDefault()} was called for this option. + * An option is also considered set if it was set to null. + */ + public function hasDefault(string $option): bool + { + return \array_key_exists($option, $this->defaults); + } + + /** + * Marks one or more options as required. + * + * @param string|string[] $optionNames One or more option names + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function setRequired(string|array $optionNames): static + { + if ($this->locked) { + throw new AccessException('Options cannot be made required from a lazy option or normalizer.'); + } + + foreach ((array) $optionNames as $option) { + $this->defined[$option] = true; + $this->required[$option] = true; + } + + return $this; + } + + /** + * Returns whether an option is required. + * + * An option is required if it was passed to {@link setRequired()}. + */ + public function isRequired(string $option): bool + { + return isset($this->required[$option]); + } + + /** + * Returns the names of all required options. + * + * @return string[] + * + * @see isRequired() + */ + public function getRequiredOptions(): array + { + return array_keys($this->required); + } + + /** + * Returns whether an option is missing a default value. + * + * An option is missing if it was passed to {@link setRequired()}, but not + * to {@link setDefault()}. This option must be passed explicitly to + * {@link resolve()}, otherwise an exception will be thrown. + */ + public function isMissing(string $option): bool + { + return isset($this->required[$option]) && !\array_key_exists($option, $this->defaults); + } + + /** + * Returns the names of all options missing a default value. + * + * @return string[] + */ + public function getMissingOptions(): array + { + return array_keys(array_diff_key($this->required, $this->defaults)); + } + + /** + * Defines a valid option name. + * + * Defines an option name without setting a default value. The option will + * be accepted when passed to {@link resolve()}. When not passed, the + * option will not be included in the resolved options. + * + * @param string|string[] $optionNames One or more option names + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function setDefined(string|array $optionNames): static + { + if ($this->locked) { + throw new AccessException('Options cannot be defined from a lazy option or normalizer.'); + } + + foreach ((array) $optionNames as $option) { + $this->defined[$option] = true; + } + + return $this; + } + + /** + * Returns whether an option is defined. + * + * Returns true for any option passed to {@link setDefault()}, + * {@link setRequired()} or {@link setDefined()}. + */ + public function isDefined(string $option): bool + { + return isset($this->defined[$option]); + } + + /** + * Returns the names of all defined options. + * + * @return string[] + * + * @see isDefined() + */ + public function getDefinedOptions(): array + { + return array_keys($this->defined); + } + + public function isNested(string $option): bool + { + return isset($this->nested[$option]); + } + + /** + * Deprecates an option, allowed types or values. + * + * Instead of passing the message, you may also pass a closure with the + * following signature: + * + * function (Options $options, $value): string { + * // ... + * } + * + * The closure receives the value as argument and should return a string. + * Return an empty string to ignore the option deprecation. + * + * The closure is invoked when {@link resolve()} is called. The parameter + * passed to the closure is the value of the option after validating it + * and before normalizing it. + * + * @param string $package The name of the composer package that is triggering the deprecation + * @param string $version The version of the package that introduced the deprecation + * @param string|\Closure $message The deprecation message to use + * + * @return $this + */ + public function setDeprecated(string $option, string $package, string $version, string|\Closure $message = 'The option "%name%" is deprecated.'): static + { + if ($this->locked) { + throw new AccessException('Options cannot be deprecated from a lazy option or normalizer.'); + } + + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist, defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + if (!\is_string($message) && !$message instanceof \Closure) { + throw new InvalidArgumentException(sprintf('Invalid type for deprecation message argument, expected string or \Closure, but got "%s".', get_debug_type($message))); + } + + // ignore if empty string + if ('' === $message) { + return $this; + } + + $this->deprecated[$option] = [ + 'package' => $package, + 'version' => $version, + 'message' => $message, + ]; + + // Make sure the option is processed + unset($this->resolved[$option]); + + return $this; + } + + public function isDeprecated(string $option): bool + { + return isset($this->deprecated[$option]); + } + + /** + * Sets the normalizer for an option. + * + * The normalizer should be a closure with the following signature: + * + * function (Options $options, $value) { + * // ... + * } + * + * The closure is invoked when {@link resolve()} is called. The closure + * has access to the resolved values of other options through the passed + * {@link Options} instance. + * + * The second parameter passed to the closure is the value of + * the option. + * + * The resolved option value is set to the return value of the closure. + * + * @return $this + * + * @throws UndefinedOptionsException If the option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function setNormalizer(string $option, \Closure $normalizer) + { + if ($this->locked) { + throw new AccessException('Normalizers cannot be set from a lazy option or normalizer.'); + } + + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + $this->normalizers[$option] = [$normalizer]; + + // Make sure the option is processed + unset($this->resolved[$option]); + + return $this; + } + + /** + * Adds a normalizer for an option. + * + * The normalizer should be a closure with the following signature: + * + * function (Options $options, $value): mixed { + * // ... + * } + * + * The closure is invoked when {@link resolve()} is called. The closure + * has access to the resolved values of other options through the passed + * {@link Options} instance. + * + * The second parameter passed to the closure is the value of + * the option. + * + * The resolved option value is set to the return value of the closure. + * + * @return $this + * + * @throws UndefinedOptionsException If the option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function addNormalizer(string $option, \Closure $normalizer, bool $forcePrepend = false): static + { + if ($this->locked) { + throw new AccessException('Normalizers cannot be set from a lazy option or normalizer.'); + } + + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + if ($forcePrepend) { + $this->normalizers[$option] ??= []; + array_unshift($this->normalizers[$option], $normalizer); + } else { + $this->normalizers[$option][] = $normalizer; + } + + // Make sure the option is processed + unset($this->resolved[$option]); + + return $this; + } + + /** + * Sets allowed values for an option. + * + * Instead of passing values, you may also pass a closures with the + * following signature: + * + * function ($value) { + * // return true or false + * } + * + * The closure receives the value as argument and should return true to + * accept the value and false to reject the value. + * + * @param mixed $allowedValues One or more acceptable values/closures + * + * @return $this + * + * @throws UndefinedOptionsException If the option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function setAllowedValues(string $option, mixed $allowedValues) + { + if ($this->locked) { + throw new AccessException('Allowed values cannot be set from a lazy option or normalizer.'); + } + + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + $this->allowedValues[$option] = \is_array($allowedValues) ? $allowedValues : [$allowedValues]; + + // Make sure the option is processed + unset($this->resolved[$option]); + + return $this; + } + + /** + * Adds allowed values for an option. + * + * The values are merged with the allowed values defined previously. + * + * Instead of passing values, you may also pass a closures with the + * following signature: + * + * function ($value) { + * // return true or false + * } + * + * The closure receives the value as argument and should return true to + * accept the value and false to reject the value. + * + * @param mixed $allowedValues One or more acceptable values/closures + * + * @return $this + * + * @throws UndefinedOptionsException If the option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function addAllowedValues(string $option, mixed $allowedValues) + { + if ($this->locked) { + throw new AccessException('Allowed values cannot be added from a lazy option or normalizer.'); + } + + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + if (!\is_array($allowedValues)) { + $allowedValues = [$allowedValues]; + } + + if (!isset($this->allowedValues[$option])) { + $this->allowedValues[$option] = $allowedValues; + } else { + $this->allowedValues[$option] = array_merge($this->allowedValues[$option], $allowedValues); + } + + // Make sure the option is processed + unset($this->resolved[$option]); + + return $this; + } + + /** + * Sets allowed types for an option. + * + * Any type for which a corresponding is_() function exists is + * acceptable. Additionally, fully-qualified class or interface names may + * be passed. + * + * @param string|string[] $allowedTypes One or more accepted types + * + * @return $this + * + * @throws UndefinedOptionsException If the option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function setAllowedTypes(string $option, string|array $allowedTypes) + { + if ($this->locked) { + throw new AccessException('Allowed types cannot be set from a lazy option or normalizer.'); + } + + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + $this->allowedTypes[$option] = (array) $allowedTypes; + + // Make sure the option is processed + unset($this->resolved[$option]); + + return $this; + } + + /** + * Adds allowed types for an option. + * + * The types are merged with the allowed types defined previously. + * + * Any type for which a corresponding is_() function exists is + * acceptable. Additionally, fully-qualified class or interface names may + * be passed. + * + * @param string|string[] $allowedTypes One or more accepted types + * + * @return $this + * + * @throws UndefinedOptionsException If the option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function addAllowedTypes(string $option, string|array $allowedTypes) + { + if ($this->locked) { + throw new AccessException('Allowed types cannot be added from a lazy option or normalizer.'); + } + + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + if (!isset($this->allowedTypes[$option])) { + $this->allowedTypes[$option] = (array) $allowedTypes; + } else { + $this->allowedTypes[$option] = array_merge($this->allowedTypes[$option], (array) $allowedTypes); + } + + // Make sure the option is processed + unset($this->resolved[$option]); + + return $this; + } + + /** + * Defines an option configurator with the given name. + */ + public function define(string $option): OptionConfigurator + { + if (isset($this->defined[$option])) { + throw new OptionDefinitionException(sprintf('The option "%s" is already defined.', $option)); + } + + return new OptionConfigurator($option, $this); + } + + /** + * Sets an info message for an option. + * + * @return $this + * + * @throws UndefinedOptionsException If the option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function setInfo(string $option, string $info): static + { + if ($this->locked) { + throw new AccessException('The Info message cannot be set from a lazy option or normalizer.'); + } + + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + $this->info[$option] = $info; + + return $this; + } + + /** + * Gets the info message for an option. + */ + public function getInfo(string $option): ?string + { + if (!isset($this->defined[$option])) { + throw new UndefinedOptionsException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + return $this->info[$option] ?? null; + } + + /** + * Marks the whole options definition as array prototype. + * + * @return $this + * + * @throws AccessException If called from a lazy option, a normalizer or a root definition + */ + public function setPrototype(bool $prototype): static + { + if ($this->locked) { + throw new AccessException('The prototype property cannot be set from a lazy option or normalizer.'); + } + + if (null === $this->prototype && $prototype) { + throw new AccessException('The prototype property cannot be set from a root definition.'); + } + + $this->prototype = $prototype; + + return $this; + } + + public function isPrototype(): bool + { + return $this->prototype ?? false; + } + + /** + * Removes the option with the given name. + * + * Undefined options are ignored. + * + * @param string|string[] $optionNames One or more option names + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function remove(string|array $optionNames): static + { + if ($this->locked) { + throw new AccessException('Options cannot be removed from a lazy option or normalizer.'); + } + + foreach ((array) $optionNames as $option) { + unset($this->defined[$option], $this->defaults[$option], $this->required[$option], $this->resolved[$option]); + unset($this->lazy[$option], $this->normalizers[$option], $this->allowedTypes[$option], $this->allowedValues[$option], $this->info[$option]); + } + + return $this; + } + + /** + * Removes all options. + * + * @return $this + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function clear(): static + { + if ($this->locked) { + throw new AccessException('Options cannot be cleared from a lazy option or normalizer.'); + } + + $this->defined = []; + $this->defaults = []; + $this->nested = []; + $this->required = []; + $this->resolved = []; + $this->lazy = []; + $this->normalizers = []; + $this->allowedTypes = []; + $this->allowedValues = []; + $this->deprecated = []; + $this->info = []; + + return $this; + } + + /** + * Merges options with the default values stored in the container and + * validates them. + * + * Exceptions are thrown if: + * + * - Undefined options are passed; + * - Required options are missing; + * - Options have invalid types; + * - Options have invalid values. + * + * @throws UndefinedOptionsException If an option name is undefined + * @throws InvalidOptionsException If an option doesn't fulfill the + * specified validation rules + * @throws MissingOptionsException If a required option is missing + * @throws OptionDefinitionException If there is a cyclic dependency between + * lazy options and/or normalizers + * @throws NoSuchOptionException If a lazy option reads an unavailable option + * @throws AccessException If called from a lazy option or normalizer + */ + public function resolve(array $options = []): array + { + if ($this->locked) { + throw new AccessException('Options cannot be resolved from a lazy option or normalizer.'); + } + + // Allow this method to be called multiple times + $clone = clone $this; + + // Make sure that no unknown options are passed + $diff = $this->ignoreUndefined ? [] : array_diff_key($options, $clone->defined); + + if (\count($diff) > 0) { + ksort($clone->defined); + ksort($diff); + + throw new UndefinedOptionsException(sprintf((\count($diff) > 1 ? 'The options "%s" do not exist.' : 'The option "%s" does not exist.').' Defined options are: "%s".', $this->formatOptions(array_keys($diff)), implode('", "', array_keys($clone->defined)))); + } + + // Override options set by the user + foreach ($options as $option => $value) { + if ($this->ignoreUndefined && !isset($clone->defined[$option])) { + continue; + } + + $clone->given[$option] = true; + $clone->defaults[$option] = $value; + unset($clone->resolved[$option], $clone->lazy[$option]); + } + + // Check whether any required option is missing + $diff = array_diff_key($clone->required, $clone->defaults); + + if (\count($diff) > 0) { + ksort($diff); + + throw new MissingOptionsException(sprintf(\count($diff) > 1 ? 'The required options "%s" are missing.' : 'The required option "%s" is missing.', $this->formatOptions(array_keys($diff)))); + } + + // Lock the container + $clone->locked = true; + + // Now process the individual options. Use offsetGet(), which resolves + // the option itself and any options that the option depends on + foreach ($clone->defaults as $option => $_) { + $clone->offsetGet($option); + } + + return $clone->resolved; + } + + /** + * Returns the resolved value of an option. + * + * @param bool $triggerDeprecation Whether to trigger the deprecation or not (true by default) + * + * @throws AccessException If accessing this method outside of + * {@link resolve()} + * @throws NoSuchOptionException If the option is not set + * @throws InvalidOptionsException If the option doesn't fulfill the + * specified validation rules + * @throws OptionDefinitionException If there is a cyclic dependency between + * lazy options and/or normalizers + */ + public function offsetGet(mixed $option, bool $triggerDeprecation = true): mixed + { + if (!$this->locked) { + throw new AccessException('Array access is only supported within closures of lazy options and normalizers.'); + } + + // Shortcut for resolved options + if (isset($this->resolved[$option]) || \array_key_exists($option, $this->resolved)) { + if ($triggerDeprecation && isset($this->deprecated[$option]) && (isset($this->given[$option]) || $this->calling) && \is_string($this->deprecated[$option]['message'])) { + trigger_deprecation($this->deprecated[$option]['package'], $this->deprecated[$option]['version'], strtr($this->deprecated[$option]['message'], ['%name%' => $option])); + } + + return $this->resolved[$option]; + } + + // Check whether the option is set at all + if (!isset($this->defaults[$option]) && !\array_key_exists($option, $this->defaults)) { + if (!isset($this->defined[$option])) { + throw new NoSuchOptionException(sprintf('The option "%s" does not exist. Defined options are: "%s".', $this->formatOptions([$option]), implode('", "', array_keys($this->defined)))); + } + + throw new NoSuchOptionException(sprintf('The optional option "%s" has no value set. You should make sure it is set with "isset" before reading it.', $this->formatOptions([$option]))); + } + + $value = $this->defaults[$option]; + + // Resolve the option if it is a nested definition + if (isset($this->nested[$option])) { + // If the closure is already being called, we have a cyclic dependency + if (isset($this->calling[$option])) { + throw new OptionDefinitionException(sprintf('The options "%s" have a cyclic dependency.', $this->formatOptions(array_keys($this->calling)))); + } + + if (!\is_array($value)) { + throw new InvalidOptionsException(sprintf('The nested option "%s" with value %s is expected to be of type array, but is of type "%s".', $this->formatOptions([$option]), $this->formatValue($value), get_debug_type($value))); + } + + // The following section must be protected from cyclic calls. + $this->calling[$option] = true; + try { + $resolver = new self(); + $resolver->prototype = false; + $resolver->parentsOptions = $this->parentsOptions; + $resolver->parentsOptions[] = $option; + foreach ($this->nested[$option] as $closure) { + $closure($resolver, $this); + } + + if ($resolver->prototype) { + $values = []; + foreach ($value as $index => $prototypeValue) { + if (!\is_array($prototypeValue)) { + throw new InvalidOptionsException(sprintf('The value of the option "%s" is expected to be of type array of array, but is of type array of "%s".', $this->formatOptions([$option]), get_debug_type($prototypeValue))); + } + + $resolver->prototypeIndex = $index; + $values[$index] = $resolver->resolve($prototypeValue); + } + $value = $values; + } else { + $value = $resolver->resolve($value); + } + } finally { + $resolver->prototypeIndex = null; + unset($this->calling[$option]); + } + } + + // Resolve the option if the default value is lazily evaluated + if (isset($this->lazy[$option])) { + // If the closure is already being called, we have a cyclic + // dependency + if (isset($this->calling[$option])) { + throw new OptionDefinitionException(sprintf('The options "%s" have a cyclic dependency.', $this->formatOptions(array_keys($this->calling)))); + } + + // The following section must be protected from cyclic + // calls. Set $calling for the current $option to detect a cyclic + // dependency + // BEGIN + $this->calling[$option] = true; + try { + foreach ($this->lazy[$option] as $closure) { + $value = $closure($this, $value); + } + } finally { + unset($this->calling[$option]); + } + // END + } + + // Validate the type of the resolved option + if (isset($this->allowedTypes[$option])) { + $valid = true; + $invalidTypes = []; + + foreach ($this->allowedTypes[$option] as $type) { + if ($valid = $this->verifyTypes($type, $value, $invalidTypes)) { + break; + } + } + + if (!$valid) { + $fmtActualValue = $this->formatValue($value); + $fmtAllowedTypes = implode('" or "', $this->allowedTypes[$option]); + $fmtProvidedTypes = implode('|', array_keys($invalidTypes)); + $allowedContainsArrayType = \count(array_filter($this->allowedTypes[$option], static fn ($item) => str_ends_with($item, '[]'))) > 0; + + if (\is_array($value) && $allowedContainsArrayType) { + throw new InvalidOptionsException(sprintf('The option "%s" with value %s is expected to be of type "%s", but one of the elements is of type "%s".', $this->formatOptions([$option]), $fmtActualValue, $fmtAllowedTypes, $fmtProvidedTypes)); + } + + throw new InvalidOptionsException(sprintf('The option "%s" with value %s is expected to be of type "%s", but is of type "%s".', $this->formatOptions([$option]), $fmtActualValue, $fmtAllowedTypes, $fmtProvidedTypes)); + } + } + + // Validate the value of the resolved option + if (isset($this->allowedValues[$option])) { + $success = false; + $printableAllowedValues = []; + + foreach ($this->allowedValues[$option] as $allowedValue) { + if ($allowedValue instanceof \Closure) { + if ($allowedValue($value)) { + $success = true; + break; + } + + // Don't include closures in the exception message + continue; + } + + if ($value === $allowedValue) { + $success = true; + break; + } + + $printableAllowedValues[] = $allowedValue; + } + + if (!$success) { + $message = sprintf( + 'The option "%s" with value %s is invalid.', + $this->formatOptions([$option]), + $this->formatValue($value) + ); + + if (\count($printableAllowedValues) > 0) { + $message .= sprintf( + ' Accepted values are: %s.', + $this->formatValues($printableAllowedValues) + ); + } + + if (isset($this->info[$option])) { + $message .= sprintf(' Info: %s.', $this->info[$option]); + } + + throw new InvalidOptionsException($message); + } + } + + // Check whether the option is deprecated + // and it is provided by the user or is being called from a lazy evaluation + if ($triggerDeprecation && isset($this->deprecated[$option]) && (isset($this->given[$option]) || ($this->calling && \is_string($this->deprecated[$option]['message'])))) { + $deprecation = $this->deprecated[$option]; + $message = $this->deprecated[$option]['message']; + + if ($message instanceof \Closure) { + // If the closure is already being called, we have a cyclic dependency + if (isset($this->calling[$option])) { + throw new OptionDefinitionException(sprintf('The options "%s" have a cyclic dependency.', $this->formatOptions(array_keys($this->calling)))); + } + + $this->calling[$option] = true; + try { + if (!\is_string($message = $message($this, $value))) { + throw new InvalidOptionsException(sprintf('Invalid type for deprecation message, expected string but got "%s", return an empty string to ignore.', get_debug_type($message))); + } + } finally { + unset($this->calling[$option]); + } + } + + if ('' !== $message) { + trigger_deprecation($deprecation['package'], $deprecation['version'], strtr($message, ['%name%' => $option])); + } + } + + // Normalize the validated option + if (isset($this->normalizers[$option])) { + // If the closure is already being called, we have a cyclic + // dependency + if (isset($this->calling[$option])) { + throw new OptionDefinitionException(sprintf('The options "%s" have a cyclic dependency.', $this->formatOptions(array_keys($this->calling)))); + } + + // The following section must be protected from cyclic + // calls. Set $calling for the current $option to detect a cyclic + // dependency + // BEGIN + $this->calling[$option] = true; + try { + foreach ($this->normalizers[$option] as $normalizer) { + $value = $normalizer($this, $value); + } + } finally { + unset($this->calling[$option]); + } + // END + } + + // Mark as resolved + $this->resolved[$option] = $value; + + return $value; + } + + private function verifyTypes(string $type, mixed $value, array &$invalidTypes, int $level = 0): bool + { + if (\is_array($value) && str_ends_with($type, '[]')) { + $type = substr($type, 0, -2); + $valid = true; + + foreach ($value as $val) { + if (!$this->verifyTypes($type, $val, $invalidTypes, $level + 1)) { + $valid = false; + } + } + + return $valid; + } + + if (('null' === $type && null === $value) || (isset(self::VALIDATION_FUNCTIONS[$type]) ? self::VALIDATION_FUNCTIONS[$type]($value) : $value instanceof $type)) { + return true; + } + + if (!$invalidTypes || $level > 0) { + $invalidTypes[get_debug_type($value)] = true; + } + + return false; + } + + /** + * Returns whether a resolved option with the given name exists. + * + * @throws AccessException If accessing this method outside of {@link resolve()} + * + * @see \ArrayAccess::offsetExists() + */ + public function offsetExists(mixed $option): bool + { + if (!$this->locked) { + throw new AccessException('Array access is only supported within closures of lazy options and normalizers.'); + } + + return \array_key_exists($option, $this->defaults); + } + + /** + * Not supported. + * + * @throws AccessException + */ + public function offsetSet(mixed $option, mixed $value): void + { + throw new AccessException('Setting options via array access is not supported. Use setDefault() instead.'); + } + + /** + * Not supported. + * + * @throws AccessException + */ + public function offsetUnset(mixed $option): void + { + throw new AccessException('Removing options via array access is not supported. Use remove() instead.'); + } + + /** + * Returns the number of set options. + * + * This may be only a subset of the defined options. + * + * @throws AccessException If accessing this method outside of {@link resolve()} + * + * @see \Countable::count() + */ + public function count(): int + { + if (!$this->locked) { + throw new AccessException('Counting is only supported within closures of lazy options and normalizers.'); + } + + return \count($this->defaults); + } + + /** + * Sets whether ignore undefined options. + * + * @return $this + */ + public function setIgnoreUndefined(bool $ignore = true): static + { + $this->ignoreUndefined = $ignore; + + return $this; + } + + /** + * Returns a string representation of the value. + * + * This method returns the equivalent PHP tokens for most scalar types + * (i.e. "false" for false, "1" for 1 etc.). Strings are always wrapped + * in double quotes ("). + */ + private function formatValue(mixed $value): string + { + if (\is_object($value)) { + return $value::class; + } + + if (\is_array($value)) { + return 'array'; + } + + if (\is_string($value)) { + return '"'.$value.'"'; + } + + if (\is_resource($value)) { + return 'resource'; + } + + if (null === $value) { + return 'null'; + } + + if (false === $value) { + return 'false'; + } + + if (true === $value) { + return 'true'; + } + + return (string) $value; + } + + /** + * Returns a string representation of a list of values. + * + * Each of the values is converted to a string using + * {@link formatValue()}. The values are then concatenated with commas. + * + * @see formatValue() + */ + private function formatValues(array $values): string + { + foreach ($values as $key => $value) { + $values[$key] = $this->formatValue($value); + } + + return implode(', ', $values); + } + + private function formatOptions(array $options): string + { + if ($this->parentsOptions) { + $prefix = array_shift($this->parentsOptions); + if ($this->parentsOptions) { + $prefix .= sprintf('[%s]', implode('][', $this->parentsOptions)); + } + + if ($this->prototype && null !== $this->prototypeIndex) { + $prefix .= sprintf('[%s]', $this->prototypeIndex); + } + + $options = array_map(static fn (string $option): string => sprintf('%s[%s]', $prefix, $option), $options); + } + + return implode('", "', $options); + } + + private function getParameterClassName(\ReflectionParameter $parameter): ?string + { + if (!($type = $parameter->getType()) instanceof \ReflectionNamedType || $type->isBuiltin()) { + return null; + } + + return $type->getName(); + } +} diff --git a/lib/symfony/options-resolver/README.md b/lib/symfony/options-resolver/README.md new file mode 100644 index 000000000..c63b9005e --- /dev/null +++ b/lib/symfony/options-resolver/README.md @@ -0,0 +1,15 @@ +OptionsResolver Component +========================= + +The OptionsResolver component is `array_replace` on steroids. It allows you to +create an options system with required options, defaults, validation (type, +value), normalization and more. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/options_resolver.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/lib/symfony/options-resolver/composer.json b/lib/symfony/options-resolver/composer.json new file mode 100644 index 000000000..9f2daf4e7 --- /dev/null +++ b/lib/symfony/options-resolver/composer.json @@ -0,0 +1,29 @@ +{ + "name": "symfony/options-resolver", + "type": "library", + "description": "Provides an improved replacement for the array_replace PHP function", + "keywords": ["options", "config", "configuration"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\OptionsResolver\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/lib/symfony/polyfill-intl-icu/Collator.php b/lib/symfony/polyfill-intl-icu/Collator.php new file mode 100644 index 000000000..2f952cdf5 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Collator.php @@ -0,0 +1,262 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; + +/** + * Replacement for PHP's native {@link \Collator} class. + * + * The only methods currently supported in this class are: + * + * - {@link \__construct} + * - {@link create} + * - {@link asort} + * - {@link getErrorCode} + * - {@link getErrorMessage} + * - {@link getLocale} + * + * @author Igor Wiedler + * @author Bernhard Schussek + * + * @internal + */ +abstract class Collator +{ + /* Attribute constants */ + public const FRENCH_COLLATION = 0; + public const ALTERNATE_HANDLING = 1; + public const CASE_FIRST = 2; + public const CASE_LEVEL = 3; + public const NORMALIZATION_MODE = 4; + public const STRENGTH = 5; + public const HIRAGANA_QUATERNARY_MODE = 6; + public const NUMERIC_COLLATION = 7; + + /* Attribute constants values */ + public const DEFAULT_VALUE = -1; + + public const PRIMARY = 0; + public const SECONDARY = 1; + public const TERTIARY = 2; + public const DEFAULT_STRENGTH = 2; + public const QUATERNARY = 3; + public const IDENTICAL = 15; + + public const OFF = 16; + public const ON = 17; + + public const SHIFTED = 20; + public const NON_IGNORABLE = 21; + + public const LOWER_FIRST = 24; + public const UPPER_FIRST = 25; + + /* Sorting options */ + public const SORT_REGULAR = 0; + public const SORT_NUMERIC = 2; + public const SORT_STRING = 1; + + /** + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + */ + public function __construct(?string $locale) + { + if ('en' !== $locale && null !== $locale) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'locale', $locale, 'Only the locale "en" is supported'); + } + } + + /** + * Static constructor. + * + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * + * @return static + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + */ + public static function create(?string $locale) + { + return new static($locale); + } + + /** + * Sort array maintaining index association. + * + * @param array &$array Input array + * @param int $flags Flags for sorting, can be one of the following: + * Collator::SORT_REGULAR - compare items normally (don't change types) + * Collator::SORT_NUMERIC - compare items numerically + * Collator::SORT_STRING - compare items as strings + * + * @return bool True on success or false on failure + */ + public function asort(array &$array, int $flags = self::SORT_REGULAR) + { + $intlToPlainFlagMap = [ + self::SORT_REGULAR => \SORT_REGULAR, + self::SORT_NUMERIC => \SORT_NUMERIC, + self::SORT_STRING => \SORT_STRING, + ]; + + $plainSortFlag = $intlToPlainFlagMap[$flags] ?? self::SORT_REGULAR; + + return asort($array, $plainSortFlag); + } + + /** + * Not supported. Compare two Unicode strings. + * + * @return int|false + * + * @see https://php.net/collator.compare + * + * @throws MethodNotImplementedException + */ + public function compare(string $string1, string $string2) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Get a value of an integer collator attribute. + * + * @return int|false The attribute value on success or false on error + * + * @see https://php.net/collator.getattribute + * + * @throws MethodNotImplementedException + */ + public function getAttribute(int $attribute) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns collator's last error code. Always returns the U_ZERO_ERROR class constant value. + * + * @return int|false The error code from last collator call + */ + public function getErrorCode() + { + return Icu::U_ZERO_ERROR; + } + + /** + * Returns collator's last error message. Always returns the U_ZERO_ERROR_MESSAGE class constant value. + * + * @return string|false The error message from last collator call + */ + public function getErrorMessage() + { + return 'U_ZERO_ERROR'; + } + + /** + * Returns the collator's locale. + * + * @return string|false The locale used to create the collator. Currently + * always returns "en". + */ + public function getLocale(int $type = Locale::ACTUAL_LOCALE) + { + return 'en'; + } + + /** + * Not supported. Get sorting key for a string. + * + * @return string|false The collation key for $string + * + * @see https://php.net/collator.getsortkey + * + * @throws MethodNotImplementedException + */ + public function getSortKey(string $string) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Get current collator's strength. + * + * @return int The current collator's strength or false on failure + * + * @see https://php.net/collator.getstrength + * + * @throws MethodNotImplementedException + */ + public function getStrength() + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Set a collator's attribute. + * + * @return bool True on success or false on failure + * + * @see https://php.net/collator.setattribute + * + * @throws MethodNotImplementedException + */ + public function setAttribute(int $attribute, int $value) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Set the collator's strength. + * + * @return bool True on success or false on failure + * + * @see https://php.net/collator.setstrength + * + * @throws MethodNotImplementedException + */ + public function setStrength(int $strength) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Sort array using specified collator and sort keys. + * + * @return bool True on success or false on failure + * + * @see https://php.net/collator.sortwithsortkeys + * + * @throws MethodNotImplementedException + */ + public function sortWithSortKeys(array &$array) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Sort array using specified collator. + * + * @return bool True on success or false on failure + * + * @see https://php.net/collator.sort + * + * @throws MethodNotImplementedException + */ + public function sort(array &$array, int $flags = self::SORT_REGULAR) + { + throw new MethodNotImplementedException(__METHOD__); + } +} diff --git a/lib/symfony/polyfill-intl-icu/Currencies.php b/lib/symfony/polyfill-intl-icu/Currencies.php new file mode 100644 index 000000000..90b1efa69 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Currencies.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +/** + * @author Nicolas Grekas + * + * @internal + */ +class Currencies +{ + private static $data; + + public static function getSymbol(string $currency): ?string + { + $data = self::$data ?? self::$data = require __DIR__.'/Resources/currencies.php'; + + return $data[$currency][0] ?? $data[strtoupper($currency)][0] ?? null; + } + + public static function getFractionDigits(string $currency): int + { + $data = self::$data ?? self::$data = require __DIR__.'/Resources/currencies.php'; + + return $data[$currency][1] ?? $data[strtoupper($currency)][1] ?? $data['DEFAULT'][1]; + } + + public static function getRoundingIncrement(string $currency): int + { + $data = self::$data ?? self::$data = require __DIR__.'/Resources/currencies.php'; + + return $data[$currency][2] ?? $data[strtoupper($currency)][2] ?? $data['DEFAULT'][2]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/AmPmTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/AmPmTransformer.php new file mode 100644 index 000000000..196c604be --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/AmPmTransformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for AM/PM markers format. + * + * @author Igor Wiedler + * + * @internal + */ +class AmPmTransformer extends Transformer +{ + public function format(\DateTime $dateTime, int $length): string + { + return $dateTime->format('A'); + } + + public function getReverseMatchingRegExp(int $length): string + { + return 'AM|PM'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'marker' => $matched, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/DayOfWeekTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/DayOfWeekTransformer.php new file mode 100644 index 000000000..6eedd2444 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/DayOfWeekTransformer.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for day of week format. + * + * @author Igor Wiedler + * + * @internal + */ +class DayOfWeekTransformer extends Transformer +{ + public function format(\DateTime $dateTime, int $length): string + { + $dayOfWeek = $dateTime->format('l'); + switch ($length) { + case 4: + return $dayOfWeek; + case 5: + return $dayOfWeek[0]; + case 6: + return substr($dayOfWeek, 0, 2); + default: + return substr($dayOfWeek, 0, 3); + } + } + + public function getReverseMatchingRegExp(int $length): string + { + switch ($length) { + case 4: + return 'Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday'; + case 5: + return '[MTWFS]'; + case 6: + return 'Mo|Tu|We|Th|Fr|Sa|Su'; + default: + return 'Mon|Tue|Wed|Thu|Fri|Sat|Sun'; + } + } + + public function extractDateOptions(string $matched, int $length): array + { + return []; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/DayOfYearTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/DayOfYearTransformer.php new file mode 100644 index 000000000..ed78853e0 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/DayOfYearTransformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for day of year format. + * + * @author Igor Wiedler + * + * @internal + */ +class DayOfYearTransformer extends Transformer +{ + public function format(\DateTime $dateTime, int $length): string + { + $dayOfYear = (int) $dateTime->format('z') + 1; + + return $this->padLeft($dayOfYear, $length); + } + + public function getReverseMatchingRegExp(int $length): string + { + return '\d{'.$length.'}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return []; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/DayTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/DayTransformer.php new file mode 100644 index 000000000..bdce79e6e --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/DayTransformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for day format. + * + * @author Igor Wiedler + * + * @internal + */ +class DayTransformer extends Transformer +{ + public function format(\DateTime $dateTime, int $length): string + { + return $this->padLeft($dateTime->format('j'), $length); + } + + public function getReverseMatchingRegExp(int $length): string + { + return 1 === $length ? '\d{1,2}' : '\d{1,'.$length.'}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'day' => (int) $matched, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/FullTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/FullTransformer.php new file mode 100644 index 000000000..02d071da5 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/FullTransformer.php @@ -0,0 +1,312 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; +use Symfony\Polyfill\Intl\Icu\Icu; + +/** + * Parser and formatter for date formats. + * + * @author Igor Wiedler + * + * @internal + */ +class FullTransformer +{ + private $quoteMatch = "'(?:[^']+|'')*'"; + private $implementedChars = 'MLydQqhDEaHkKmsz'; + private $notImplementedChars = 'GYuwWFgecSAZvVW'; + private $regExp; + + /** + * @var Transformer[] + */ + private $transformers; + + private $pattern; + private $timezone; + + /** + * @param string $pattern The pattern to be used to format and/or parse values + * @param string $timezone The timezone to perform the date/time calculations + */ + public function __construct(string $pattern, string $timezone) + { + $this->pattern = $pattern; + $this->timezone = $timezone; + + $implementedCharsMatch = $this->buildCharsMatch($this->implementedChars); + $notImplementedCharsMatch = $this->buildCharsMatch($this->notImplementedChars); + $this->regExp = "/($this->quoteMatch|$implementedCharsMatch|$notImplementedCharsMatch)/"; + + $this->transformers = [ + 'M' => new MonthTransformer(), + 'L' => new MonthTransformer(), + 'y' => new YearTransformer(), + 'd' => new DayTransformer(), + 'q' => new QuarterTransformer(), + 'Q' => new QuarterTransformer(), + 'h' => new Hour1201Transformer(), + 'D' => new DayOfYearTransformer(), + 'E' => new DayOfWeekTransformer(), + 'a' => new AmPmTransformer(), + 'H' => new Hour2400Transformer(), + 'K' => new Hour1200Transformer(), + 'k' => new Hour2401Transformer(), + 'm' => new MinuteTransformer(), + 's' => new SecondTransformer(), + 'z' => new TimezoneTransformer(), + ]; + } + + /** + * Format a DateTime using ICU dateformat pattern. + * + * @return string The formatted value + */ + public function format(\DateTime $dateTime): string + { + $formatted = preg_replace_callback($this->regExp, function ($matches) use ($dateTime) { + return $this->formatReplace($matches[0], $dateTime); + }, $this->pattern); + + return $formatted; + } + + /** + * Return the formatted ICU value for the matched date characters. + * + * @throws NotImplementedException When it encounters a not implemented date character + */ + private function formatReplace(string $dateChars, \DateTime $dateTime): string + { + $length = \strlen($dateChars); + + if ($this->isQuoteMatch($dateChars)) { + return $this->replaceQuoteMatch($dateChars); + } + + if (isset($this->transformers[$dateChars[0]])) { + $transformer = $this->transformers[$dateChars[0]]; + + return $transformer->format($dateTime, $length); + } + + // handle unimplemented characters + if (false !== strpos($this->notImplementedChars, $dateChars[0])) { + throw new NotImplementedException(sprintf('Unimplemented date character "%s" in format "%s".', $dateChars[0], $this->pattern)); + } + + return ''; + } + + /** + * Parse a pattern based string to a timestamp value. + * + * @param \DateTime $dateTime A configured DateTime object to use to perform the date calculation + * @param string $value String to convert to a time value + * + * @return int|false The corresponding Unix timestamp + * + * @throws \InvalidArgumentException When the value can not be matched with pattern + */ + public function parse(\DateTime $dateTime, string $value) + { + $reverseMatchingRegExp = $this->getReverseMatchingRegExp($this->pattern); + $reverseMatchingRegExp = '/^'.$reverseMatchingRegExp.'$/'; + + $options = []; + + if (preg_match($reverseMatchingRegExp, $value, $matches)) { + $matches = $this->normalizeArray($matches); + + foreach ($this->transformers as $char => $transformer) { + if (isset($matches[$char])) { + $length = \strlen($matches[$char]['pattern']); + $options = array_merge($options, $transformer->extractDateOptions($matches[$char]['value'], $length)); + } + } + + // reset error code and message + Icu::setError(Icu::U_ZERO_ERROR); + + return $this->calculateUnixTimestamp($dateTime, $options); + } + + // behave like the intl extension + Icu::setError(Icu::U_PARSE_ERROR, 'Date parsing failed'); + + return false; + } + + /** + * Retrieve a regular expression to match with a formatted value. + * + * @return string The reverse matching regular expression with named captures being formed by the + * transformer index in the $transformer array + */ + private function getReverseMatchingRegExp(string $pattern): string + { + $escapedPattern = preg_quote($pattern, '/'); + + // ICU 4.8 recognizes slash ("/") in a value to be parsed as a dash ("-") and vice-versa + // when parsing a date/time value + $escapedPattern = preg_replace('/\\\[\-|\/]/', '[\/\-]', $escapedPattern); + + $reverseMatchingRegExp = preg_replace_callback($this->regExp, function ($matches) { + $length = \strlen($matches[0]); + $transformerIndex = $matches[0][0]; + + $dateChars = $matches[0]; + if ($this->isQuoteMatch($dateChars)) { + return $this->replaceQuoteMatch($dateChars); + } + + if (isset($this->transformers[$transformerIndex])) { + $transformer = $this->transformers[$transformerIndex]; + $captureName = str_repeat($transformerIndex, $length); + + return "(?P<$captureName>".$transformer->getReverseMatchingRegExp($length).')'; + } + + return null; + }, $escapedPattern); + + return $reverseMatchingRegExp; + } + + /** + * Check if the first char of a string is a single quote. + */ + private function isQuoteMatch(string $quoteMatch): bool + { + return "'" === $quoteMatch[0]; + } + + /** + * Replaces single quotes at the start or end of a string with two single quotes. + */ + private function replaceQuoteMatch(string $quoteMatch): string + { + if (preg_match("/^'+$/", $quoteMatch)) { + return str_replace("''", "'", $quoteMatch); + } + + return str_replace("''", "'", substr($quoteMatch, 1, -1)); + } + + /** + * Builds a chars match regular expression. + */ + private function buildCharsMatch(string $specialChars): string + { + $specialCharsArray = str_split($specialChars); + + $specialCharsMatch = implode('|', array_map(function ($char) { + return $char.'+'; + }, $specialCharsArray)); + + return $specialCharsMatch; + } + + /** + * Normalize a preg_replace match array, removing the numeric keys and returning an associative array + * with the value and pattern values for the matched Transformer. + */ + private function normalizeArray(array $data): array + { + $ret = []; + + foreach ($data as $key => $value) { + if (!\is_string($key)) { + continue; + } + + $ret[$key[0]] = [ + 'value' => $value, + 'pattern' => $key, + ]; + } + + return $ret; + } + + /** + * Calculates the Unix timestamp based on the matched values by the reverse matching regular + * expression of parse(). + * + * @return bool|int The calculated timestamp or false if matched date is invalid + */ + private function calculateUnixTimestamp(\DateTime $dateTime, array $options) + { + $options = $this->getDefaultValueForOptions($options); + + $year = $options['year']; + $month = $options['month']; + $day = $options['day']; + $hour = $options['hour']; + $hourInstance = $options['hourInstance']; + $minute = $options['minute']; + $second = $options['second']; + $marker = $options['marker']; + $timezone = $options['timezone']; + + // If month is false, return immediately (intl behavior) + if (false === $month) { + Icu::setError(Icu::U_PARSE_ERROR, 'Date parsing failed'); + + return false; + } + + // Normalize hour + if ($hourInstance instanceof HourTransformer) { + $hour = $hourInstance->normalizeHour($hour, $marker); + } + + // Set the timezone if different from the default one + if (null !== $timezone && $timezone !== $this->timezone) { + $dateTime->setTimezone(new \DateTimeZone($timezone)); + } + + // Normalize yy year + preg_match_all($this->regExp, $this->pattern, $matches); + if (\in_array('yy', $matches[0])) { + $dateTime->setTimestamp(time()); + $year = $year > (int) $dateTime->format('y') + 20 ? 1900 + $year : 2000 + $year; + } + + $dateTime->setDate($year, $month, $day); + $dateTime->setTime($hour, $minute, $second); + + return $dateTime->getTimestamp(); + } + + /** + * Add sensible default values for missing items in the extracted date/time options array. The values + * are base in the beginning of the Unix era. + */ + private function getDefaultValueForOptions(array $options): array + { + return [ + 'year' => $options['year'] ?? 1970, + 'month' => $options['month'] ?? 1, + 'day' => $options['day'] ?? 1, + 'hour' => $options['hour'] ?? 0, + 'hourInstance' => $options['hourInstance'] ?? null, + 'minute' => $options['minute'] ?? 0, + 'second' => $options['second'] ?? 0, + 'marker' => $options['marker'] ?? null, + 'timezone' => $options['timezone'] ?? null, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/Hour1200Transformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/Hour1200Transformer.php new file mode 100644 index 000000000..68891a79a --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/Hour1200Transformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for 12 hour format (0-11). + * + * @author Igor Wiedler + * + * @internal + */ +class Hour1200Transformer extends HourTransformer +{ + public function format(\DateTime $dateTime, int $length): string + { + $hourOfDay = $dateTime->format('g'); + $hourOfDay = '12' === $hourOfDay ? '0' : $hourOfDay; + + return $this->padLeft($hourOfDay, $length); + } + + public function normalizeHour(int $hour, ?string $marker = null): int + { + if ('PM' === $marker) { + $hour += 12; + } + + return $hour; + } + + public function getReverseMatchingRegExp(int $length): string + { + return '\d{1,2}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'hour' => (int) $matched, + 'hourInstance' => $this, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/Hour1201Transformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/Hour1201Transformer.php new file mode 100644 index 000000000..4ac9b2a35 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/Hour1201Transformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for 12 hour format (1-12). + * + * @author Igor Wiedler + * + * @internal + */ +class Hour1201Transformer extends HourTransformer +{ + public function format(\DateTime $dateTime, int $length): string + { + return $this->padLeft($dateTime->format('g'), $length); + } + + public function normalizeHour(int $hour, ?string $marker = null): int + { + if ('PM' !== $marker && 12 === $hour) { + $hour = 0; + } elseif ('PM' === $marker && 12 !== $hour) { + // If PM and hour is not 12 (1-12), sum 12 hour + $hour += 12; + } + + return $hour; + } + + public function getReverseMatchingRegExp(int $length): string + { + return '\d{1,2}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'hour' => (int) $matched, + 'hourInstance' => $this, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/Hour2400Transformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/Hour2400Transformer.php new file mode 100644 index 000000000..bc259e288 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/Hour2400Transformer.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for 24 hour format (0-23). + * + * @author Igor Wiedler + * + * @internal + */ +class Hour2400Transformer extends HourTransformer +{ + public function format(\DateTime $dateTime, int $length): string + { + return $this->padLeft($dateTime->format('G'), $length); + } + + public function normalizeHour(int $hour, ?string $marker = null): int + { + if ('AM' === $marker) { + $hour = 0; + } elseif ('PM' === $marker) { + $hour = 12; + } + + return $hour; + } + + public function getReverseMatchingRegExp(int $length): string + { + return '\d{1,2}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'hour' => (int) $matched, + 'hourInstance' => $this, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/Hour2401Transformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/Hour2401Transformer.php new file mode 100644 index 000000000..f8d3367b1 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/Hour2401Transformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for 24 hour format (1-24). + * + * @author Igor Wiedler + * + * @internal + */ +class Hour2401Transformer extends HourTransformer +{ + public function format(\DateTime $dateTime, int $length): string + { + $hourOfDay = $dateTime->format('G'); + $hourOfDay = '0' === $hourOfDay ? '24' : $hourOfDay; + + return $this->padLeft($hourOfDay, $length); + } + + public function normalizeHour(int $hour, ?string $marker = null): int + { + if ((null === $marker && 24 === $hour) || 'AM' === $marker) { + $hour = 0; + } elseif ('PM' === $marker) { + $hour = 12; + } + + return $hour; + } + + public function getReverseMatchingRegExp(int $length): string + { + return '\d{1,2}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'hour' => (int) $matched, + 'hourInstance' => $this, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/HourTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/HourTransformer.php new file mode 100644 index 000000000..e973db181 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/HourTransformer.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\Polyfill\Intl\Icu\DateFormat; + +/** + * Base class for hour transformers. + * + * @author Eriksen Costa + * + * @internal + */ +abstract class HourTransformer extends Transformer +{ + /** + * Returns a normalized hour value suitable for the hour transformer type. + * + * @param int $hour The hour value + * @param string $marker An optional AM/PM marker + * + * @return int The normalized hour value + */ + abstract public function normalizeHour(int $hour, ?string $marker = null): int; +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/MinuteTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/MinuteTransformer.php new file mode 100644 index 000000000..e8bddc6fd --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/MinuteTransformer.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for minute format. + * + * @author Igor Wiedler + * + * @internal + */ +class MinuteTransformer extends Transformer +{ + public function format(\DateTime $dateTime, int $length): string + { + $minuteOfHour = (int) $dateTime->format('i'); + + return $this->padLeft($minuteOfHour, $length); + } + + public function getReverseMatchingRegExp(int $length): string + { + return 1 === $length ? '\d{1,2}' : '\d{'.$length.'}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'minute' => (int) $matched, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/MonthTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/MonthTransformer.php new file mode 100644 index 000000000..6712ed282 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/MonthTransformer.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for month format. + * + * @author Igor Wiedler + * + * @internal + */ +class MonthTransformer extends Transformer +{ + protected static $months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + /** + * Short months names (first 3 letters). + */ + protected static $shortMonths = []; + + /** + * Flipped $months array, $name => $index. + */ + protected static $flippedMonths = []; + + /** + * Flipped $shortMonths array, $name => $index. + */ + protected static $flippedShortMonths = []; + + public function __construct() + { + if (0 === \count(self::$shortMonths)) { + self::$shortMonths = array_map(function ($month) { + return substr($month, 0, 3); + }, self::$months); + + self::$flippedMonths = array_flip(self::$months); + self::$flippedShortMonths = array_flip(self::$shortMonths); + } + } + + public function format(\DateTime $dateTime, int $length): string + { + $matchLengthMap = [ + 1 => 'n', + 2 => 'm', + 3 => 'M', + 4 => 'F', + ]; + + if (isset($matchLengthMap[$length])) { + return $dateTime->format($matchLengthMap[$length]); + } + + if (5 === $length) { + return substr($dateTime->format('M'), 0, 1); + } + + return $this->padLeft($dateTime->format('m'), $length); + } + + public function getReverseMatchingRegExp(int $length): string + { + switch ($length) { + case 1: + $regExp = '\d{1,2}'; + break; + case 3: + $regExp = implode('|', self::$shortMonths); + break; + case 4: + $regExp = implode('|', self::$months); + break; + case 5: + $regExp = '[JFMASOND]'; + break; + default: + $regExp = '\d{1,'.$length.'}'; + break; + } + + return $regExp; + } + + public function extractDateOptions(string $matched, int $length): array + { + if (!is_numeric($matched)) { + if (3 === $length) { + $matched = self::$flippedShortMonths[$matched] + 1; + } elseif (4 === $length) { + $matched = self::$flippedMonths[$matched] + 1; + } elseif (5 === $length) { + // IntlDateFormatter::parse() always returns false for MMMMM or LLLLL + $matched = false; + } + } else { + $matched = (int) $matched; + } + + return [ + 'month' => $matched, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/QuarterTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/QuarterTransformer.php new file mode 100644 index 000000000..a549deeda --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/QuarterTransformer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for quarter format. + * + * @author Igor Wiedler + * + * @internal + */ +class QuarterTransformer extends Transformer +{ + public function format(\DateTime $dateTime, int $length): string + { + $month = (int) $dateTime->format('n'); + $quarter = (int) floor(($month - 1) / 3) + 1; + switch ($length) { + case 1: + case 2: + return $this->padLeft($quarter, $length); + case 3: + return 'Q'.$quarter; + case 4: + $map = [1 => '1st quarter', 2 => '2nd quarter', 3 => '3rd quarter', 4 => '4th quarter']; + + return $map[$quarter]; + default: + if (\defined('INTL_ICU_VERSION') && version_compare(\INTL_ICU_VERSION, '70.1', '<')) { + $map = [1 => '1st quarter', 2 => '2nd quarter', 3 => '3rd quarter', 4 => '4th quarter']; + + return $map[$quarter]; + } else { + return $quarter; + } + } + } + + public function getReverseMatchingRegExp(int $length): string + { + switch ($length) { + case 1: + case 2: + return '\d{'.$length.'}'; + case 3: + return 'Q\d'; + default: + return '(?:1st|2nd|3rd|4th) quarter'; + } + } + + public function extractDateOptions(string $matched, int $length): array + { + return []; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/SecondTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/SecondTransformer.php new file mode 100644 index 000000000..fcb1028f4 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/SecondTransformer.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for the second format. + * + * @author Igor Wiedler + * + * @internal + */ +class SecondTransformer extends Transformer +{ + public function format(\DateTime $dateTime, int $length): string + { + $secondOfMinute = (int) $dateTime->format('s'); + + return $this->padLeft($secondOfMinute, $length); + } + + public function getReverseMatchingRegExp(int $length): string + { + return 1 === $length ? '\d{1,2}' : '\d{'.$length.'}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'second' => (int) $matched, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/TimezoneTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/TimezoneTransformer.php new file mode 100644 index 000000000..bab7a96f8 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/TimezoneTransformer.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; + +/** + * Parser and formatter for time zone format. + * + * @author Igor Wiedler + * + * @internal + */ +class TimezoneTransformer extends Transformer +{ + /** + * @throws NotImplementedException When time zone is different than UTC or GMT (Etc/GMT) + */ + public function format(\DateTime $dateTime, int $length): string + { + $timeZone = substr($dateTime->getTimezone()->getName(), 0, 3); + + if (!\in_array($timeZone, ['Etc', 'UTC', 'GMT'])) { + throw new NotImplementedException('Time zone different than GMT or UTC is not supported as a formatting output.'); + } + + if ('Etc' === $timeZone) { + // i.e. Etc/GMT+1, Etc/UTC, Etc/Zulu + $timeZone = substr($dateTime->getTimezone()->getName(), 4); + } + + // From ICU >= 59.1 GMT and UTC are no longer unified + if (\in_array($timeZone, ['UTC', 'UCT', 'Universal', 'Zulu'])) { + // offset is not supported with UTC + return $length > 3 ? 'Coordinated Universal Time' : 'UTC'; + } + + $offset = (int) $dateTime->format('O'); + + // From ICU >= 4.8, the zero offset is no more used, example: GMT instead of GMT+00:00 + if (0 === $offset) { + return $length > 3 ? 'Greenwich Mean Time' : 'GMT'; + } + + if ($length > 3) { + return $dateTime->format('\G\M\TP'); + } + + return sprintf('GMT%s%d', $offset >= 0 ? '+' : '', $offset / 100); + } + + public function getReverseMatchingRegExp(int $length): string + { + return 'GMT[+-]\d{2}:?\d{2}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'timezone' => self::getEtcTimeZoneId($matched), + ]; + } + + /** + * Get an Etc/GMT timezone identifier for the specified timezone. + * + * The PHP documentation for timezones states to not use the 'Other' time zones because them exists + * "for backwards compatibility". However all Etc/GMT time zones are in the tz database 'etcetera' file, + * which indicates they are not deprecated (neither are old names). + * + * Only GMT, Etc/Universal, Etc/Zulu, Etc/Greenwich, Etc/GMT-0, Etc/GMT+0 and Etc/GMT0 are old names and + * are linked to Etc/GMT or Etc/UTC. + * + * @param string $formattedTimeZone A GMT timezone string (GMT-03:00, e.g.) + * + * @return string A timezone identifier + * + * @see https://php.net/timezones.others + * + * @throws NotImplementedException When the GMT time zone have minutes offset different than zero + * @throws \InvalidArgumentException When the value can not be matched with pattern + */ + public static function getEtcTimeZoneId(string $formattedTimeZone): string + { + if (preg_match('/GMT(?P[+-])(?P\d{2}):?(?P\d{2})/', $formattedTimeZone, $matches)) { + $hours = (int) $matches['hours']; + $minutes = (int) $matches['minutes']; + $signal = '-' === $matches['signal'] ? '+' : '-'; + + if (0 < $minutes) { + throw new NotImplementedException(sprintf('It is not possible to use a GMT time zone with minutes offset different than zero (0). GMT time zone tried: "%s".', $formattedTimeZone)); + } + + return 'Etc/GMT'.(0 !== $hours ? $signal.$hours : ''); + } + + throw new \InvalidArgumentException(sprintf('The GMT time zone "%s" does not match with the supported formats GMT[+-]HH:MM or GMT[+-]HHMM.', $formattedTimeZone)); + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/Transformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/Transformer.php new file mode 100644 index 000000000..7f8bf25b5 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/Transformer.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for date formats. + * + * @author Igor Wiedler + * + * @internal + */ +abstract class Transformer +{ + /** + * Format a value using a configured DateTime as date/time source. + * + * @param \DateTime $dateTime A DateTime object to be used to generate the formatted value + * @param int $length The formatted value string length + * + * @return string The formatted value + */ + abstract public function format(\DateTime $dateTime, int $length): string; + + /** + * Returns a reverse matching regular expression of a string generated by format(). + * + * @param int $length The length of the value to be reverse matched + * + * @return string The reverse matching regular expression + */ + abstract public function getReverseMatchingRegExp(int $length): string; + + /** + * Extract date options from a matched value returned by the processing of the reverse matching + * regular expression. + * + * @param string $matched The matched value + * @param int $length The length of the Transformer pattern string + * + * @return array An associative array + */ + abstract public function extractDateOptions(string $matched, int $length): array; + + /** + * Pad a string with zeros to the left. + * + * @param string $value The string to be padded + * @param int $length The length to pad + * + * @return string The padded string + */ + protected function padLeft(string $value, int $length): string + { + return str_pad($value, $length, '0', \STR_PAD_LEFT); + } +} diff --git a/lib/symfony/polyfill-intl-icu/DateFormat/YearTransformer.php b/lib/symfony/polyfill-intl-icu/DateFormat/YearTransformer.php new file mode 100644 index 000000000..a27ce8555 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/DateFormat/YearTransformer.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\DateFormat; + +/** + * Parser and formatter for year format. + * + * @author Igor Wiedler + * + * @internal + */ +class YearTransformer extends Transformer +{ + public function format(\DateTime $dateTime, int $length): string + { + if (2 === $length) { + return $dateTime->format('y'); + } + + return $this->padLeft($dateTime->format('Y'), $length); + } + + public function getReverseMatchingRegExp(int $length): string + { + return 2 === $length ? '\d{2}' : '\d{1,4}'; + } + + public function extractDateOptions(string $matched, int $length): array + { + return [ + 'year' => (int) $matched, + ]; + } +} diff --git a/lib/symfony/polyfill-intl-icu/Exception/ExceptionInterface.php b/lib/symfony/polyfill-intl-icu/Exception/ExceptionInterface.php new file mode 100644 index 000000000..a453b5e2f --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Exception/ExceptionInterface.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\Polyfill\Intl\Icu\Exception; + +/** + * Base ExceptionInterface for the Intl component. + * + * @author Bernhard Schussek + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/lib/symfony/polyfill-intl-icu/Exception/MethodArgumentNotImplementedException.php b/lib/symfony/polyfill-intl-icu/Exception/MethodArgumentNotImplementedException.php new file mode 100644 index 000000000..db120a340 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Exception/MethodArgumentNotImplementedException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * @author Eriksen Costa + */ +class MethodArgumentNotImplementedException extends NotImplementedException +{ + /** + * @param string $methodName The method name that raised the exception + * @param string $argName The argument name that is not implemented + */ + public function __construct(string $methodName, string $argName) + { + $message = sprintf('The %s() method\'s argument $%s behavior is not implemented.', $methodName, $argName); + parent::__construct($message); + } +} diff --git a/lib/symfony/polyfill-intl-icu/Exception/MethodArgumentValueNotImplementedException.php b/lib/symfony/polyfill-intl-icu/Exception/MethodArgumentValueNotImplementedException.php new file mode 100644 index 000000000..bd9204234 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Exception/MethodArgumentValueNotImplementedException.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * @author Eriksen Costa + */ +class MethodArgumentValueNotImplementedException extends NotImplementedException +{ + /** + * @param string $methodName The method name that raised the exception + * @param string $argName The argument name + * @param mixed $argValue The argument value that is not implemented + * @param string $additionalMessage An optional additional message to append to the exception message + */ + public function __construct(string $methodName, string $argName, $argValue, string $additionalMessage = '') + { + $message = sprintf( + 'The %s() method\'s argument $%s value %s behavior is not implemented.%s', + $methodName, + $argName, + var_export($argValue, true), + '' !== $additionalMessage ? ' '.$additionalMessage.'. ' : '' + ); + + parent::__construct($message); + } +} diff --git a/lib/symfony/polyfill-intl-icu/Exception/MethodNotImplementedException.php b/lib/symfony/polyfill-intl-icu/Exception/MethodNotImplementedException.php new file mode 100644 index 000000000..9e1a43985 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Exception/MethodNotImplementedException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * @author Eriksen Costa + */ +class MethodNotImplementedException extends NotImplementedException +{ + /** + * @param string $methodName The name of the method + */ + public function __construct(string $methodName) + { + parent::__construct(sprintf('The %s() is not implemented.', $methodName)); + } +} diff --git a/lib/symfony/polyfill-intl-icu/Exception/NotImplementedException.php b/lib/symfony/polyfill-intl-icu/Exception/NotImplementedException.php new file mode 100644 index 000000000..929b9334d --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Exception/NotImplementedException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu\Exception; + +/** + * Base exception class for not implemented behaviors of the intl extension in the Locale component. + * + * @author Eriksen Costa + */ +class NotImplementedException extends RuntimeException +{ + public const INTL_INSTALL_MESSAGE = 'Please install the "intl" extension for full localization capabilities.'; + + /** + * @param string $message The exception message. A note to install the intl extension is appended to this string + */ + public function __construct(string $message) + { + parent::__construct($message.' '.self::INTL_INSTALL_MESSAGE); + } +} diff --git a/lib/symfony/polyfill-intl-icu/Exception/RuntimeException.php b/lib/symfony/polyfill-intl-icu/Exception/RuntimeException.php new file mode 100644 index 000000000..ceedffe8e --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Exception/RuntimeException.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\Polyfill\Intl\Icu\Exception; + +/** + * RuntimeException for the Intl component. + * + * @author Bernhard Schussek + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/lib/symfony/polyfill-intl-icu/Icu.php b/lib/symfony/polyfill-intl-icu/Icu.php new file mode 100644 index 000000000..b9590f43d --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Icu.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +/** + * Provides fake static versions of the global functions in the intl extension. + * + * @author Bernhard Schussek + * + * @internal + */ +abstract class Icu +{ + /** + * Indicates that no error occurred. + */ + public const U_ZERO_ERROR = 0; + + /** + * Indicates that an invalid argument was passed. + */ + public const U_ILLEGAL_ARGUMENT_ERROR = 1; + + /** + * Indicates that the parse() operation failed. + */ + public const U_PARSE_ERROR = 9; + + /** + * All known error codes. + */ + private static $errorCodes = [ + self::U_ZERO_ERROR => 'U_ZERO_ERROR', + self::U_ILLEGAL_ARGUMENT_ERROR => 'U_ILLEGAL_ARGUMENT_ERROR', + self::U_PARSE_ERROR => 'U_PARSE_ERROR', + ]; + + /** + * The error code of the last operation. + */ + private static $errorCode = self::U_ZERO_ERROR; + + /** + * The error code of the last operation. + */ + private static $errorMessage = 'U_ZERO_ERROR'; + + /** + * Returns whether the error code indicates a failure. + * + * @param int $errorCode The error code returned by Icu::getErrorCode() + */ + public static function isFailure(int $errorCode): bool + { + return isset(self::$errorCodes[$errorCode]) + && $errorCode > self::U_ZERO_ERROR; + } + + /** + * Returns the error code of the last operation. + * + * Returns Icu::U_ZERO_ERROR if no error occurred. + * + * @return int + */ + public static function getErrorCode() + { + return self::$errorCode; + } + + /** + * Returns the error message of the last operation. + * + * Returns "U_ZERO_ERROR" if no error occurred. + */ + public static function getErrorMessage(): string + { + return self::$errorMessage; + } + + /** + * Returns the symbolic name for a given error code. + * + * @param int $code The error code returned by Icu::getErrorCode() + */ + public static function getErrorName(int $code): string + { + return self::$errorCodes[$code] ?? '[BOGUS UErrorCode]'; + } + + /** + * Sets the current error. + * + * @param int $code One of the error constants in this class + * @param string $message The ICU class error message + * + * @throws \InvalidArgumentException If the code is not one of the error constants in this class + */ + public static function setError(int $code, string $message = '') + { + if (!isset(self::$errorCodes[$code])) { + throw new \InvalidArgumentException(sprintf('No such error code: "%s".', $code)); + } + + self::$errorMessage = $message ? sprintf('%s: %s', $message, self::$errorCodes[$code]) : self::$errorCodes[$code]; + self::$errorCode = $code; + } +} diff --git a/lib/symfony/polyfill-intl-icu/IntlDateFormatter.php b/lib/symfony/polyfill-intl-icu/IntlDateFormatter.php new file mode 100644 index 000000000..b2674f906 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/IntlDateFormatter.php @@ -0,0 +1,645 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +use Symfony\Polyfill\Intl\Icu\DateFormat\FullTransformer; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; + +/** + * Replacement for PHP's native {@link \IntlDateFormatter} class. + * + * The only methods currently supported in this class are: + * + * - {@link __construct} + * - {@link create} + * - {@link format} + * - {@link getCalendar} + * - {@link getDateType} + * - {@link getErrorCode} + * - {@link getErrorMessage} + * - {@link getLocale} + * - {@link getPattern} + * - {@link getTimeType} + * - {@link getTimeZoneId} + * - {@link isLenient} + * - {@link parse} + * - {@link setLenient} + * - {@link setPattern} + * - {@link setTimeZoneId} + * - {@link setTimeZone} + * + * @author Igor Wiedler + * @author Bernhard Schussek + * + * @internal + */ +abstract class IntlDateFormatter +{ + /** + * The error code from the last operation. + * + * @var int + */ + protected $errorCode = Icu::U_ZERO_ERROR; + + /** + * The error message from the last operation. + * + * @var string + */ + protected $errorMessage = 'U_ZERO_ERROR'; + + /* date/time format types */ + public const NONE = -1; + public const FULL = 0; + public const LONG = 1; + public const MEDIUM = 2; + public const SHORT = 3; + + /* date format types */ + public const RELATIVE_FULL = 128; + public const RELATIVE_LONG = 129; + public const RELATIVE_MEDIUM = 130; + public const RELATIVE_SHORT = 131; + + /* calendar formats */ + public const TRADITIONAL = 0; + public const GREGORIAN = 1; + + /** + * Patterns used to format the date when no pattern is provided. + */ + private $defaultDateFormats = [ + self::NONE => '', + self::FULL => 'EEEE, MMMM d, y', + self::LONG => 'MMMM d, y', + self::MEDIUM => 'MMM d, y', + self::SHORT => 'M/d/yy', + self::RELATIVE_FULL => 'EEEE, MMMM d, y', + self::RELATIVE_LONG => 'MMMM d, y', + self::RELATIVE_MEDIUM => 'MMM d, y', + self::RELATIVE_SHORT => 'M/d/yy', + ]; + + /** + * Patterns used to format the time when no pattern is provided. + */ + private $defaultTimeFormats = [ + self::FULL => 'h:mm:ss a zzzz', + self::LONG => 'h:mm:ss a z', + self::MEDIUM => 'h:mm:ss a', + self::SHORT => 'h:mm a', + ]; + + private $dateType; + private $timeType; + + /** + * @var string + */ + private $pattern; + + /** + * @var \DateTimeZone + */ + private $dateTimeZone; + + /** + * @var bool + */ + private $uninitializedTimeZoneId = false; + + /** + * @var string + */ + private $timezoneId; + + /** + * @var bool + */ + private $isRelativeDateType = false; + + /** + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * @param \IntlTimeZone|\DateTimeZone|string|null $timezone Timezone identifier + * @param \IntlCalendar|int|null $calendar Calendar to use for formatting or parsing. The only currently + * supported value is IntlDateFormatter::GREGORIAN (or null using the default calendar, i.e. "GREGORIAN") + * + * @see https://php.net/intldateformatter.create + * @see http://userguide.icu-project.org/formatparse/datetime + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + * @throws MethodArgumentValueNotImplementedException When $calendar different than GREGORIAN is passed + */ + public function __construct(?string $locale, ?int $dateType, ?int $timeType, $timezone = null, $calendar = null, ?string $pattern = '') + { + if ('en' !== $locale && null !== $locale) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'locale', $locale, 'Only the locale "en" is supported'); + } + + if (self::GREGORIAN !== $calendar && null !== $calendar) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'calendar', $calendar, 'Only the GREGORIAN calendar is supported'); + } + + if (\PHP_VERSION_ID >= 80100) { + if (null === $dateType) { + @trigger_error('Passing null to parameter #2 ($dateType) of type int is deprecated', \E_USER_DEPRECATED); + } + + if (null === $timeType) { + @trigger_error('Passing null to parameter #3 ($timeType) of type int is deprecated', \E_USER_DEPRECATED); + } + } + + $this->dateType = $dateType ?? self::FULL; + $this->timeType = $timeType ?? self::FULL; + + if ('' === ($pattern ?? '')) { + $pattern = $this->getDefaultPattern(); + } + + $this->setPattern($pattern); + $this->setTimeZone($timezone); + + if (\in_array($this->dateType, [self::RELATIVE_FULL, self::RELATIVE_LONG, self::RELATIVE_MEDIUM, self::RELATIVE_SHORT], true)) { + $this->isRelativeDateType = true; + } + } + + /** + * Static constructor. + * + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * @param \IntlTimeZone|\DateTimeZone|string|null $timezone Timezone identifier + * @param \IntlCalendar|int|null $calendar Calendar to use for formatting or parsing; default is Gregorian + * One of the calendar constants + * + * @return static + * + * @see https://php.net/intldateformatter.create + * @see http://userguide.icu-project.org/formatparse/datetime + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + * @throws MethodArgumentValueNotImplementedException When $calendar different than GREGORIAN is passed + */ + public static function create(?string $locale, ?int $dateType, ?int $timeType, $timezone = null, ?int $calendar = null, ?string $pattern = '') + { + return new static($locale, $dateType, $timeType, $timezone, $calendar, $pattern); + } + + /** + * Format the date/time value (timestamp) as a string. + * + * @param int|string|\DateTimeInterface $datetime The timestamp to format + * + * @return string|false The formatted value or false if formatting failed + * + * @see https://php.net/intldateformatter.format + * + * @throws MethodArgumentValueNotImplementedException If one of the formatting characters is not implemented + */ + public function format($datetime) + { + // intl allows timestamps to be passed as arrays - we don't + if (\is_array($datetime)) { + $message = 'Only Unix timestamps and DateTime objects are supported'; + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'datetime', $datetime, $message); + } + + if (\is_string($datetime) && $dt = \DateTime::createFromFormat('U', $datetime)) { + $datetime = $dt; + } + + // behave like the intl extension + $argumentError = null; + if (!\is_int($datetime) && !$datetime instanceof \DateTimeInterface) { + $argumentError = sprintf('datefmt_format: string \'%s\' is not numeric, which would be required for it to be a valid date', $datetime); + } + + if (null !== $argumentError) { + Icu::setError(Icu::U_ILLEGAL_ARGUMENT_ERROR, $argumentError); + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::getErrorMessage(); + + return false; + } + + if ($datetime instanceof \DateTimeInterface) { + $datetime = $datetime->format('U'); + } + + $pattern = $this->getPattern(); + $formatted = ''; + + if ($this->isRelativeDateType && $formatted = $this->getRelativeDateFormat($datetime)) { + if (self::NONE === $this->timeType) { + $pattern = ''; + } else { + $pattern = $this->defaultTimeFormats[$this->timeType]; + if (\in_array($this->dateType, [self::RELATIVE_MEDIUM, self::RELATIVE_SHORT], true)) { + $formatted .= ', '; + } else { + $formatted .= ' at '; + } + } + } + + $transformer = new FullTransformer($pattern, $this->getTimeZoneId()); + $formatted .= $transformer->format($this->createDateTime($datetime)); + + // behave like the intl extension + Icu::setError(Icu::U_ZERO_ERROR); + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::getErrorMessage(); + + return $formatted; + } + + /** + * Not supported. Formats an object. + * + * @return string The formatted value + * + * @see https://php.net/intldateformatter.formatobject + * + * @throws MethodNotImplementedException + */ + public static function formatObject($datetime, $format = null, ?string $locale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns the formatter's calendar. + * + * @return int The calendar being used by the formatter. Currently always returns + * IntlDateFormatter::GREGORIAN. + * + * @see https://php.net/intldateformatter.getcalendar + */ + public function getCalendar() + { + return self::GREGORIAN; + } + + /** + * Not supported. Returns the formatter's calendar object. + * + * @return object The calendar's object being used by the formatter + * + * @see https://php.net/intldateformatter.getcalendarobject + * + * @throws MethodNotImplementedException + */ + public function getCalendarObject() + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns the formatter's datetype. + * + * @return int The current value of the formatter + * + * @see https://php.net/intldateformatter.getdatetype + */ + public function getDateType() + { + return $this->dateType; + } + + /** + * Returns formatter's last error code. Always returns the U_ZERO_ERROR class constant value. + * + * @return int The error code from last formatter call + * + * @see https://php.net/intldateformatter.geterrorcode + */ + public function getErrorCode() + { + return $this->errorCode; + } + + /** + * Returns formatter's last error message. Always returns the U_ZERO_ERROR_MESSAGE class constant value. + * + * @return string The error message from last formatter call + * + * @see https://php.net/intldateformatter.geterrormessage + */ + public function getErrorMessage() + { + return $this->errorMessage; + } + + /** + * Returns the formatter's locale. + * + * @param int $type Not supported. The locale name type to return (Locale::VALID_LOCALE or Locale::ACTUAL_LOCALE) + * + * @return string The locale used to create the formatter. Currently always + * returns "en". + * + * @see https://php.net/intldateformatter.getlocale + */ + public function getLocale(int $type = Locale::ACTUAL_LOCALE) + { + return 'en'; + } + + /** + * Returns the formatter's pattern. + * + * @return string The pattern string used by the formatter + * + * @see https://php.net/intldateformatter.getpattern + */ + public function getPattern() + { + return $this->pattern; + } + + /** + * Returns the formatter's time type. + * + * @return int The time type used by the formatter + * + * @see https://php.net/intldateformatter.gettimetype + */ + public function getTimeType() + { + return $this->timeType; + } + + /** + * Returns the formatter's timezone identifier. + * + * @return string The timezone identifier used by the formatter + * + * @see https://php.net/intldateformatter.gettimezoneid + */ + public function getTimeZoneId() + { + if (!$this->uninitializedTimeZoneId) { + return $this->timezoneId; + } + + return date_default_timezone_get(); + } + + /** + * Not supported. Returns the formatter's timezone. + * + * @return mixed The timezone used by the formatter + * + * @see https://php.net/intldateformatter.gettimezone + * + * @throws MethodNotImplementedException + */ + public function getTimeZone() + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns whether the formatter is lenient. + * + * @return bool Currently always returns false + * + * @see https://php.net/intldateformatter.islenient + * + * @throws MethodNotImplementedException + */ + public function isLenient() + { + return false; + } + + /** + * Not supported. Parse string to a field-based time value. + * + * @return string Localtime compatible array of integers: contains 24 hour clock value in tm_hour field + * + * @see https://php.net/intldateformatter.localtime + * + * @throws MethodNotImplementedException + */ + public function localtime(string $string, &$offset = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Parse string to a timestamp value. + * + * @return int|false Parsed value as a timestamp + * + * @see https://php.net/intldateformatter.parse + * + * @throws MethodArgumentNotImplementedException When $offset different than null, behavior not implemented + */ + public function parse(string $string, &$offset = null) + { + // We don't calculate the position when parsing the value + if (null !== $offset) { + throw new MethodArgumentNotImplementedException(__METHOD__, 'offset'); + } + + $dateTime = $this->createDateTime(0); + $transformer = new FullTransformer($this->getPattern(), $this->getTimeZoneId()); + + $timestamp = $transformer->parse($dateTime, $string); + + // behave like the intl extension. FullTransformer::parse() set the proper error + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::getErrorMessage(); + + return $timestamp; + } + + /** + * Not supported. Set the formatter's calendar. + * + * @param \IntlCalendar|int|null $calendar + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.setcalendar + * + * @throws MethodNotImplementedException + */ + public function setCalendar($calendar) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Set the leniency of the parser. + * + * Define if the parser is strict or lenient in interpreting inputs that do not match the pattern + * exactly. Enabling lenient parsing allows the parser to accept otherwise flawed date or time + * patterns, parsing as much as possible to obtain a value. Extra space, unrecognized tokens, or + * invalid values ("February 30th") are not accepted. + * + * @param bool $lenient Sets whether the parser is lenient or not. Currently + * only false (strict) is supported. + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.setlenient + * + * @throws MethodArgumentValueNotImplementedException When $lenient is true + */ + public function setLenient(bool $lenient) + { + if ($lenient) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'lenient', $lenient, 'Only the strict parser is supported'); + } + + return true; + } + + /** + * Set the formatter's pattern. + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.setpattern + * @see http://userguide.icu-project.org/formatparse/datetime + */ + public function setPattern(string $pattern) + { + $this->pattern = $pattern; + + return true; + } + + /** + * Sets formatterʼs timezone. + * + * @param \IntlTimeZone|\DateTimeZone|string|null $timezone + * + * @return bool true on success or false on failure + * + * @see https://php.net/intldateformatter.settimezone + */ + public function setTimeZone($timezone) + { + if ($timezone instanceof \IntlTimeZone) { + $timezone = $timezone->getID(); + } + + if ($timezone instanceof \DateTimeZone) { + $timezone = $timezone->getName(); + + // DateTimeZone returns the GMT offset timezones without the leading GMT, while our parsing requires it. + if (!empty($timezone) && ('+' === $timezone[0] || '-' === $timezone[0])) { + $timezone = 'GMT'.$timezone; + } + } + + if (null === $timezone) { + $timezone = date_default_timezone_get(); + + $this->uninitializedTimeZoneId = true; + } + + // Backup original passed time zone + $timezoneId = $timezone; + + // Get an Etc/GMT time zone that is accepted for \DateTimeZone + if ('GMT' !== $timezone && 0 === strpos($timezone, 'GMT')) { + try { + $timezone = DateFormat\TimezoneTransformer::getEtcTimeZoneId($timezone); + } catch (\InvalidArgumentException $e) { + // Does nothing, will fallback to UTC + } + } + + try { + $this->dateTimeZone = new \DateTimeZone($timezone); + if ('GMT' !== $timezone && $this->dateTimeZone->getName() !== $timezone) { + $timezoneId = $this->getTimeZoneId(); + } + } catch (\Exception $e) { + $timezoneId = $timezone = $this->getTimeZoneId(); + $this->dateTimeZone = new \DateTimeZone($timezone); + } + + $this->timezoneId = $timezoneId; + + return true; + } + + /** + * Create and returns a DateTime object with the specified timestamp and with the + * current time zone. + * + * @return \DateTime + */ + protected function createDateTime($timestamp) + { + $dateTime = \DateTime::createFromFormat('U', $timestamp); + $dateTime->setTimezone($this->dateTimeZone); + + return $dateTime; + } + + /** + * Returns a pattern string based in the datetype and timetype values. + * + * @return string + */ + protected function getDefaultPattern() + { + $pattern = ''; + if (self::NONE !== $this->dateType) { + $pattern = $this->defaultDateFormats[$this->dateType]; + } + if (self::NONE !== $this->timeType) { + if (\in_array($this->dateType, [self::FULL, self::LONG, self::RELATIVE_FULL, self::RELATIVE_LONG], true)) { + $pattern .= ' \'at\' '; + } elseif (self::NONE !== $this->dateType) { + $pattern .= ', '; + } + $pattern .= $this->defaultTimeFormats[$this->timeType]; + } + + return $pattern; + } + + private function getRelativeDateFormat(int $timestamp): string + { + $today = $this->createDateTime(time()); + $today->setTime(0, 0, 0); + + $datetime = $this->createDateTime($timestamp); + $datetime->setTime(0, 0, 0); + + $interval = $today->diff($datetime); + + if (false !== $interval) { + if (0 === $interval->days) { + return 'today'; + } + + if (1 === $interval->days) { + return 1 === $interval->invert ? 'yesterday' : 'tomorrow'; + } + } + + return ''; + } +} diff --git a/lib/symfony/polyfill-intl-icu/LICENSE b/lib/symfony/polyfill-intl-icu/LICENSE new file mode 100644 index 000000000..0138f8f07 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/symfony/polyfill-intl-icu/Locale.php b/lib/symfony/polyfill-intl-icu/Locale.php new file mode 100644 index 000000000..f449fd5df --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Locale.php @@ -0,0 +1,310 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; + +/** + * Replacement for PHP's native {@link \Locale} class. + * + * The only methods supported in this class are `getDefault` and `canonicalize`. + * All other methods will throw an exception when used. + * + * @author Eriksen Costa + * @author Bernhard Schussek + * + * @internal + */ +abstract class Locale +{ + public const DEFAULT_LOCALE = null; + + /* Locale method constants */ + public const ACTUAL_LOCALE = 0; + public const VALID_LOCALE = 1; + + /* Language tags constants */ + public const LANG_TAG = 'language'; + public const EXTLANG_TAG = 'extlang'; + public const SCRIPT_TAG = 'script'; + public const REGION_TAG = 'region'; + public const VARIANT_TAG = 'variant'; + public const GRANDFATHERED_LANG_TAG = 'grandfathered'; + public const PRIVATE_TAG = 'private'; + + /** + * Not supported. Returns the best available locale based on HTTP "Accept-Language" header according to RFC 2616. + * + * @return string The corresponding locale code + * + * @see https://php.net/locale.acceptfromhttp + * + * @throws MethodNotImplementedException + */ + public static function acceptFromHttp(string $header) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns a canonicalized locale string. + * + * This polyfill doesn't implement the full-spec algorithm. It only + * canonicalizes locale strings handled by the `LocaleBundle` class. + * + * @return string + */ + public static function canonicalize(string $locale) + { + if ('' === $locale || '.' === $locale[0]) { + return self::getDefault(); + } + + if (!preg_match('/^([a-z]{2})[-_]([a-z]{2})(?:([a-z]{2})(?:[-_]([a-z]{2}))?)?(?:\..*)?$/i', $locale, $m)) { + return $locale; + } + + if (!empty($m[4])) { + return strtolower($m[1]).'_'.ucfirst(strtolower($m[2].$m[3])).'_'.strtoupper($m[4]); + } + + if (!empty($m[3])) { + return strtolower($m[1]).'_'.ucfirst(strtolower($m[2].$m[3])); + } + + return strtolower($m[1]).'_'.strtoupper($m[2]); + } + + /** + * Not supported. Returns a correctly ordered and delimited locale code. + * + * @return string The corresponding locale code + * + * @see https://php.net/locale.composelocale + * + * @throws MethodNotImplementedException + */ + public static function composeLocale(array $subtags) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Checks if a language tag filter matches with locale. + * + * @return string The corresponding locale code + * + * @see https://php.net/locale.filtermatches + * + * @throws MethodNotImplementedException + */ + public static function filterMatches(string $languageTag, string $locale, bool $canonicalize = false) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the variants for the input locale. + * + * @return array The locale variants + * + * @see https://php.net/locale.getallvariants + * + * @throws MethodNotImplementedException + */ + public static function getAllVariants(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Returns the default locale. + * + * @return string The default locale code. Always returns 'en' + * + * @see https://php.net/locale.getdefault + */ + public static function getDefault() + { + return 'en'; + } + + /** + * Not supported. Returns the localized display name for the locale language. + * + * @return string The localized language display name + * + * @see https://php.net/locale.getdisplaylanguage + * + * @throws MethodNotImplementedException + */ + public static function getDisplayLanguage(string $locale, ?string $displayLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the localized display name for the locale. + * + * @return string The localized locale display name + * + * @see https://php.net/locale.getdisplayname + * + * @throws MethodNotImplementedException + */ + public static function getDisplayName(string $locale, ?string $displayLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the localized display name for the locale region. + * + * @return string The localized region display name + * + * @see https://php.net/locale.getdisplayregion + * + * @throws MethodNotImplementedException + */ + public static function getDisplayRegion(string $locale, ?string $displayLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the localized display name for the locale script. + * + * @return string The localized script display name + * + * @see https://php.net/locale.getdisplayscript + * + * @throws MethodNotImplementedException + */ + public static function getDisplayScript(string $locale, ?string $displayLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the localized display name for the locale variant. + * + * @return string The localized variant display name + * + * @see https://php.net/locale.getdisplayvariant + * + * @throws MethodNotImplementedException + */ + public static function getDisplayVariant(string $locale, ?string $displayLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the keywords for the locale. + * + * @return array Associative array with the extracted variants + * + * @see https://php.net/locale.getkeywords + * + * @throws MethodNotImplementedException + */ + public static function getKeywords(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the primary language for the locale. + * + * @return string|null The extracted language code or null in case of error + * + * @see https://php.net/locale.getprimarylanguage + * + * @throws MethodNotImplementedException + */ + public static function getPrimaryLanguage(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the region for the locale. + * + * @return string|null The extracted region code or null if not present + * + * @see https://php.net/locale.getregion + * + * @throws MethodNotImplementedException + */ + public static function getRegion(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the script for the locale. + * + * @return string|null The extracted script code or null if not present + * + * @see https://php.net/locale.getscript + * + * @throws MethodNotImplementedException + */ + public static function getScript(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns the closest language tag for the locale. + * + * @see https://php.net/locale.lookup + * + * @throws MethodNotImplementedException + */ + public static function lookup(array $languageTag, string $locale, bool $canonicalize = false, ?string $defaultLocale = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns an associative array of locale identifier subtags. + * + * @return array|null Associative array with the extracted subtags + * + * @see https://php.net/locale.parselocale + * + * @throws MethodNotImplementedException + */ + public static function parseLocale(string $locale) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Sets the default runtime locale. + * + * @return bool true on success or false on failure + * + * @see https://php.net/locale.setdefault + * + * @throws MethodNotImplementedException + */ + public static function setDefault(string $locale) + { + if ('en' !== $locale) { + throw new MethodNotImplementedException(__METHOD__); + } + + return true; + } +} diff --git a/lib/symfony/polyfill-intl-icu/NumberFormatter.php b/lib/symfony/polyfill-intl-icu/NumberFormatter.php new file mode 100644 index 000000000..5b247066c --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/NumberFormatter.php @@ -0,0 +1,835 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Polyfill\Intl\Icu; + +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodArgumentValueNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\MethodNotImplementedException; +use Symfony\Polyfill\Intl\Icu\Exception\NotImplementedException; + +/** + * Replacement for PHP's native {@link \NumberFormatter} class. + * + * The only methods currently supported in this class are: + * + * - {@link __construct} + * - {@link create} + * - {@link formatCurrency} + * - {@link format} + * - {@link getAttribute} + * - {@link getErrorCode} + * - {@link getErrorMessage} + * - {@link getLocale} + * - {@link parse} + * - {@link setAttribute} + * + * @author Eriksen Costa + * @author Bernhard Schussek + * + * @internal + */ +abstract class NumberFormatter +{ + /* Format style constants */ + public const PATTERN_DECIMAL = 0; + public const DECIMAL = 1; + public const CURRENCY = 2; + public const PERCENT = 3; + public const SCIENTIFIC = 4; + public const SPELLOUT = 5; + public const ORDINAL = 6; + public const DURATION = 7; + public const PATTERN_RULEBASED = 9; + public const IGNORE = 0; + public const DEFAULT_STYLE = 1; + + /* Format type constants */ + public const TYPE_DEFAULT = 0; + public const TYPE_INT32 = 1; + public const TYPE_INT64 = 2; + public const TYPE_DOUBLE = 3; + public const TYPE_CURRENCY = 4; + + /* Numeric attribute constants */ + public const PARSE_INT_ONLY = 0; + public const GROUPING_USED = 1; + public const DECIMAL_ALWAYS_SHOWN = 2; + public const MAX_INTEGER_DIGITS = 3; + public const MIN_INTEGER_DIGITS = 4; + public const INTEGER_DIGITS = 5; + public const MAX_FRACTION_DIGITS = 6; + public const MIN_FRACTION_DIGITS = 7; + public const FRACTION_DIGITS = 8; + public const MULTIPLIER = 9; + public const GROUPING_SIZE = 10; + public const ROUNDING_MODE = 11; + public const ROUNDING_INCREMENT = 12; + public const FORMAT_WIDTH = 13; + public const PADDING_POSITION = 14; + public const SECONDARY_GROUPING_SIZE = 15; + public const SIGNIFICANT_DIGITS_USED = 16; + public const MIN_SIGNIFICANT_DIGITS = 17; + public const MAX_SIGNIFICANT_DIGITS = 18; + public const LENIENT_PARSE = 19; + + /* Text attribute constants */ + public const POSITIVE_PREFIX = 0; + public const POSITIVE_SUFFIX = 1; + public const NEGATIVE_PREFIX = 2; + public const NEGATIVE_SUFFIX = 3; + public const PADDING_CHARACTER = 4; + public const CURRENCY_CODE = 5; + public const DEFAULT_RULESET = 6; + public const PUBLIC_RULESETS = 7; + + /* Format symbol constants */ + public const DECIMAL_SEPARATOR_SYMBOL = 0; + public const GROUPING_SEPARATOR_SYMBOL = 1; + public const PATTERN_SEPARATOR_SYMBOL = 2; + public const PERCENT_SYMBOL = 3; + public const ZERO_DIGIT_SYMBOL = 4; + public const DIGIT_SYMBOL = 5; + public const MINUS_SIGN_SYMBOL = 6; + public const PLUS_SIGN_SYMBOL = 7; + public const CURRENCY_SYMBOL = 8; + public const INTL_CURRENCY_SYMBOL = 9; + public const MONETARY_SEPARATOR_SYMBOL = 10; + public const EXPONENTIAL_SYMBOL = 11; + public const PERMILL_SYMBOL = 12; + public const PAD_ESCAPE_SYMBOL = 13; + public const INFINITY_SYMBOL = 14; + public const NAN_SYMBOL = 15; + public const SIGNIFICANT_DIGIT_SYMBOL = 16; + public const MONETARY_GROUPING_SEPARATOR_SYMBOL = 17; + + /* Rounding mode values used by NumberFormatter::setAttribute() with NumberFormatter::ROUNDING_MODE attribute */ + public const ROUND_CEILING = 0; + public const ROUND_FLOOR = 1; + public const ROUND_DOWN = 2; + public const ROUND_UP = 3; + public const ROUND_HALFEVEN = 4; + public const ROUND_HALFDOWN = 5; + public const ROUND_HALFUP = 6; + + /* Pad position values used by NumberFormatter::setAttribute() with NumberFormatter::PADDING_POSITION attribute */ + public const PAD_BEFORE_PREFIX = 0; + public const PAD_AFTER_PREFIX = 1; + public const PAD_BEFORE_SUFFIX = 2; + public const PAD_AFTER_SUFFIX = 3; + + /** + * The error code from the last operation. + * + * @var int + */ + protected $errorCode = Icu::U_ZERO_ERROR; + + /** + * The error message from the last operation. + * + * @var string + */ + protected $errorMessage = 'U_ZERO_ERROR'; + + /** + * @var int + */ + private $style; + + /** + * Default values for the en locale. + */ + private $attributes = [ + self::FRACTION_DIGITS => 0, + self::GROUPING_USED => 1, + self::ROUNDING_MODE => self::ROUND_HALFEVEN, + ]; + + /** + * Holds the initialized attributes code. + */ + private $initializedAttributes = []; + + /** + * The supported styles to the constructor $styles argument. + */ + private static $supportedStyles = [ + 'CURRENCY' => self::CURRENCY, + 'DECIMAL' => self::DECIMAL, + ]; + + /** + * Supported attributes to the setAttribute() $attr argument. + */ + private static $supportedAttributes = [ + 'FRACTION_DIGITS' => self::FRACTION_DIGITS, + 'GROUPING_USED' => self::GROUPING_USED, + 'ROUNDING_MODE' => self::ROUNDING_MODE, + ]; + + /** + * The available rounding modes for setAttribute() usage with + * NumberFormatter::ROUNDING_MODE. NumberFormatter::ROUND_DOWN + * and NumberFormatter::ROUND_UP does not have a PHP only equivalent. + */ + private static $roundingModes = [ + 'ROUND_HALFEVEN' => self::ROUND_HALFEVEN, + 'ROUND_HALFDOWN' => self::ROUND_HALFDOWN, + 'ROUND_HALFUP' => self::ROUND_HALFUP, + 'ROUND_CEILING' => self::ROUND_CEILING, + 'ROUND_FLOOR' => self::ROUND_FLOOR, + 'ROUND_DOWN' => self::ROUND_DOWN, + 'ROUND_UP' => self::ROUND_UP, + ]; + + /** + * The mapping between NumberFormatter rounding modes to the available + * modes in PHP's round() function. + * + * @see https://php.net/round + */ + private static $phpRoundingMap = [ + self::ROUND_HALFDOWN => \PHP_ROUND_HALF_DOWN, + self::ROUND_HALFEVEN => \PHP_ROUND_HALF_EVEN, + self::ROUND_HALFUP => \PHP_ROUND_HALF_UP, + ]; + + /** + * The list of supported rounding modes which aren't available modes in + * PHP's round() function, but there's an equivalent. Keys are rounding + * modes, values does not matter. + */ + private static $customRoundingList = [ + self::ROUND_CEILING => true, + self::ROUND_FLOOR => true, + self::ROUND_DOWN => true, + self::ROUND_UP => true, + ]; + + /** + * The maximum value of the integer type in 32 bit platforms. + */ + private static $int32Max = 2147483647; + + /** + * The maximum value of the integer type in 64 bit platforms. + * + * @var int|float + */ + private static $int64Max = 9223372036854775807; + + private static $enSymbols = [ + self::DECIMAL => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','], + self::CURRENCY => ['.', ',', ';', '%', '0', '#', '-', '+', '¤', '¤¤', '.', 'E', '‰', '*', '∞', 'NaN', '@', ','], + ]; + + private static $enTextAttributes = [ + self::DECIMAL => ['', '', '-', '', ' ', 'XXX', ''], + self::CURRENCY => ['¤', '', '-¤', '', ' ', 'XXX'], + ]; + + /** + * @param string|null $locale The locale code. The only currently supported locale is "en" (or null using the default locale, i.e. "en") + * @param int $style Style of the formatting, one of the format style constants. + * The only supported styles are NumberFormatter::DECIMAL + * and NumberFormatter::CURRENCY. + * @param string $pattern Not supported. A pattern string in case $style is NumberFormat::PATTERN_DECIMAL or + * NumberFormat::PATTERN_RULEBASED. It must conform to the syntax + * described in the ICU DecimalFormat or ICU RuleBasedNumberFormat documentation + * + * @see https://php.net/numberformatter.create + * @see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classicu_1_1DecimalFormat.html#details + * @see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classicu_1_1RuleBasedNumberFormat.html#details + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + * @throws MethodArgumentValueNotImplementedException When the $style is not supported + * @throws MethodArgumentNotImplementedException When the pattern value is different than null + */ + public function __construct(?string $locale = 'en', ?int $style = null, ?string $pattern = null) + { + if ('en' !== $locale && null !== $locale) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'locale', $locale, 'Only the locale "en" is supported'); + } + + if (!\in_array($style, self::$supportedStyles)) { + $message = sprintf('The available styles are: %s.', implode(', ', array_keys(self::$supportedStyles))); + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'style', $style, $message); + } + + if (null !== $pattern) { + throw new MethodArgumentNotImplementedException(__METHOD__, 'pattern'); + } + + $this->style = $style; + } + + /** + * Static constructor. + * + * @param string|null $locale The locale code. The only supported locale is "en" (or null using the default locale, i.e. "en") + * @param int $style Style of the formatting, one of the format style constants. + * The only currently supported styles are NumberFormatter::DECIMAL + * and NumberFormatter::CURRENCY. + * @param string $pattern Not supported. A pattern string in case $style is NumberFormat::PATTERN_DECIMAL or + * NumberFormat::PATTERN_RULEBASED. It must conform to the syntax + * described in the ICU DecimalFormat or ICU RuleBasedNumberFormat documentation + * + * @return static + * + * @see https://php.net/numberformatter.create + * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details + * @see http://www.icu-project.org/apiref/icu4c/classRuleBasedNumberFormat.html#_details + * + * @throws MethodArgumentValueNotImplementedException When $locale different than "en" or null is passed + * @throws MethodArgumentValueNotImplementedException When the $style is not supported + * @throws MethodArgumentNotImplementedException When the pattern value is different than null + */ + public static function create(?string $locale = 'en', ?int $style = null, ?string $pattern = null) + { + return new static($locale, $style, $pattern); + } + + /** + * Format a currency value. + * + * @return string The formatted currency value + * + * @see https://php.net/numberformatter.formatcurrency + * @see https://en.wikipedia.org/wiki/ISO_4217#Active_codes + */ + public function formatCurrency(float $amount, string $currency) + { + if (self::DECIMAL === $this->style) { + return $this->format($amount); + } + + if (null === $symbol = Currencies::getSymbol($currency)) { + return false; + } + $fractionDigits = Currencies::getFractionDigits($currency); + + $amount = $this->roundCurrency($amount, $currency); + + $negative = false; + if (0 > $amount) { + $negative = true; + $amount *= -1; + } + + $amount = $this->formatNumber($amount, $fractionDigits); + + // There's a non-breaking space after the currency code (i.e. CRC 100), but not if the currency has a symbol (i.e. £100). + $ret = $symbol.(mb_strlen($symbol, 'UTF-8') > 2 ? "\xc2\xa0" : '').$amount; + + return $negative ? '-'.$ret : $ret; + } + + /** + * Format a number. + * + * @param int|float $num The value to format + * @param int $type Type of the formatting, one of the format type constants. + * Only type NumberFormatter::TYPE_DEFAULT is currently supported. + * + * @return bool|string The formatted value or false on error + * + * @see https://php.net/numberformatter.format + * + * @throws NotImplementedException If the method is called with the class $style 'CURRENCY' + * @throws MethodArgumentValueNotImplementedException If the $type is different than TYPE_DEFAULT + */ + public function format($num, int $type = self::TYPE_DEFAULT) + { + // The original NumberFormatter does not support this format type + if (self::TYPE_CURRENCY === $type) { + if (\PHP_VERSION_ID >= 80000) { + throw new \ValueError(sprintf('The format type must be a NumberFormatter::TYPE_* constant (%s given).', $type)); + } + + trigger_error(__METHOD__.'(): Unsupported format type '.$type, \E_USER_WARNING); + + return false; + } + + if (self::CURRENCY === $this->style) { + throw new NotImplementedException(sprintf('"%s()" method does not support the formatting of currencies (instance with CURRENCY style). "%s".', __METHOD__, NotImplementedException::INTL_INSTALL_MESSAGE)); + } + + // Only the default type is supported. + if (self::TYPE_DEFAULT !== $type) { + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'type', $type, 'Only TYPE_DEFAULT is supported'); + } + + $fractionDigits = $this->getAttribute(self::FRACTION_DIGITS); + + $num = $this->round($num, $fractionDigits); + $num = $this->formatNumber($num, $fractionDigits); + + // behave like the intl extension + $this->resetError(); + + return $num; + } + + /** + * Returns an attribute value. + * + * @return int|false The attribute value on success or false on error + * + * @see https://php.net/numberformatter.getattribute + */ + public function getAttribute(int $attribute) + { + return $this->attributes[$attribute] ?? null; + } + + /** + * Returns formatter's last error code. Always returns the U_ZERO_ERROR class constant value. + * + * @return int The error code from last formatter call + * + * @see https://php.net/numberformatter.geterrorcode + */ + public function getErrorCode() + { + return $this->errorCode; + } + + /** + * Returns formatter's last error message. Always returns the U_ZERO_ERROR_MESSAGE class constant value. + * + * @return string The error message from last formatter call + * + * @see https://php.net/numberformatter.geterrormessage + */ + public function getErrorMessage() + { + return $this->errorMessage; + } + + /** + * Returns the formatter's locale. + * + * The parameter $type is currently ignored. + * + * @param int $type Not supported. The locale name type to return (Locale::VALID_LOCALE or Locale::ACTUAL_LOCALE) + * + * @return string The locale used to create the formatter. Currently always + * returns "en". + * + * @see https://php.net/numberformatter.getlocale + */ + public function getLocale(int $type = Locale::ACTUAL_LOCALE) + { + return 'en'; + } + + /** + * Not supported. Returns the formatter's pattern. + * + * @return string|false The pattern string used by the formatter or false on error + * + * @see https://php.net/numberformatter.getpattern + * + * @throws MethodNotImplementedException + */ + public function getPattern() + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Returns a formatter symbol value. + * + * @return string|false The symbol value or false on error + * + * @see https://php.net/numberformatter.getsymbol + */ + public function getSymbol(int $symbol) + { + return \array_key_exists($this->style, self::$enSymbols) && \array_key_exists($symbol, self::$enSymbols[$this->style]) ? self::$enSymbols[$this->style][$symbol] : false; + } + + /** + * Not supported. Returns a formatter text attribute value. + * + * @return string|false The attribute value or false on error + * + * @see https://php.net/numberformatter.gettextattribute + */ + public function getTextAttribute(int $attribute) + { + return \array_key_exists($this->style, self::$enTextAttributes) && \array_key_exists($attribute, self::$enTextAttributes[$this->style]) ? self::$enTextAttributes[$this->style][$attribute] : false; + } + + /** + * Not supported. Parse a currency number. + * + * @return float|false The parsed numeric value or false on error + * + * @see https://php.net/numberformatter.parsecurrency + * + * @throws MethodNotImplementedException + */ + public function parseCurrency(string $string, &$currency, &$offset = null) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Parse a number. + * + * @return int|float|false The parsed value or false on error + * + * @see https://php.net/numberformatter.parse + */ + public function parse(string $string, int $type = self::TYPE_DOUBLE, &$offset = null) + { + if (self::TYPE_DEFAULT === $type || self::TYPE_CURRENCY === $type) { + if (\PHP_VERSION_ID >= 80000) { + throw new \ValueError(sprintf('The format type must be a NumberFormatter::TYPE_* constant (%d given).', $type)); + } + + trigger_error(__METHOD__.'(): Unsupported format type '.$type, \E_USER_WARNING); + + return false; + } + + // Any invalid number at the end of the string is removed. + // Only numbers and the fraction separator is expected in the string. + // If grouping is used, grouping separator also becomes a valid character. + $groupingMatch = $this->getAttribute(self::GROUPING_USED) ? '|(?P\d++(,{1}\d+)++(\.\d*+)?)' : ''; + if (preg_match("/^-?(?:\.\d++{$groupingMatch}|\d++(\.\d*+)?)/", $string, $matches)) { + $string = $matches[0]; + $offset = \strlen($string); + // value is not valid if grouping is used, but digits are not grouped in groups of three + if ($error = isset($matches['grouping']) && !preg_match('/^-?(?:\d{1,3}+)?(?:(?:,\d{3})++|\d*+)(?:\.\d*+)?$/', $string)) { + // the position on error is 0 for positive and 1 for negative numbers + $offset = 0 === strpos($string, '-') ? 1 : 0; + } + } else { + $error = true; + $offset = 0; + } + + if ($error) { + Icu::setError(Icu::U_PARSE_ERROR, 'Number parsing failed'); + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::getErrorMessage(); + + return false; + } + + $string = str_replace(',', '', $string); + $string = $this->convertValueDataType($string, $type); + + // behave like the intl extension + $this->resetError(); + + return $string; + } + + /** + * Set an attribute. + * + * @param int|float $value + * + * @return bool true on success or false on failure + * + * @see https://php.net/numberformatter.setattribute + * + * @throws MethodArgumentValueNotImplementedException When the $attribute is not supported + * @throws MethodArgumentValueNotImplementedException When the $value is not supported + */ + public function setAttribute(int $attribute, $value) + { + if (!\in_array($attribute, self::$supportedAttributes)) { + $message = sprintf( + 'The available attributes are: %s', + implode(', ', array_keys(self::$supportedAttributes)) + ); + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attribute', $value, $message); + } + + if (self::$supportedAttributes['ROUNDING_MODE'] === $attribute && $this->isInvalidRoundingMode($value)) { + $message = sprintf( + 'The supported values for ROUNDING_MODE are: %s', + implode(', ', array_keys(self::$roundingModes)) + ); + + throw new MethodArgumentValueNotImplementedException(__METHOD__, 'attribute', $value, $message); + } + + if (self::$supportedAttributes['GROUPING_USED'] === $attribute) { + $value = $this->normalizeGroupingUsedValue($value); + } + + if (self::$supportedAttributes['FRACTION_DIGITS'] === $attribute) { + $value = $this->normalizeFractionDigitsValue($value); + if ($value < 0) { + // ignore negative values but do not raise an error + return true; + } + } + + $this->attributes[$attribute] = $value; + $this->initializedAttributes[$attribute] = true; + + return true; + } + + /** + * Not supported. Set the formatter's pattern. + * + * @return bool true on success or false on failure + * + * @see https://php.net/numberformatter.setpattern + * @see http://www.icu-project.org/apiref/icu4c/classDecimalFormat.html#_details + * + * @throws MethodNotImplementedException + */ + public function setPattern(string $pattern) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Set the formatter's symbol. + * + * @return bool true on success or false on failure + * + * @see https://php.net/numberformatter.setsymbol + * + * @throws MethodNotImplementedException + */ + public function setSymbol(int $symbol, string $value) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Not supported. Set a text attribute. + * + * @return bool true on success or false on failure + * + * @see https://php.net/numberformatter.settextattribute + * + * @throws MethodNotImplementedException + */ + public function setTextAttribute(int $attribute, string $value) + { + throw new MethodNotImplementedException(__METHOD__); + } + + /** + * Set the error to the default U_ZERO_ERROR. + */ + protected function resetError() + { + Icu::setError(Icu::U_ZERO_ERROR); + $this->errorCode = Icu::getErrorCode(); + $this->errorMessage = Icu::getErrorMessage(); + } + + /** + * Rounds a currency value, applying increment rounding if applicable. + * + * When a currency have a rounding increment, an extra round is made after the first one. The rounding factor is + * determined in the ICU data and is explained as of: + * + * "the rounding increment is given in units of 10^(-fraction_digits)" + * + * The only actual rounding data as of this writing, is CHF. + * + * @see http://en.wikipedia.org/wiki/Swedish_rounding + * @see http://www.docjar.com/html/api/com/ibm/icu/util/Currency.java.html#1007 + */ + private function roundCurrency(float $value, string $currency): float + { + $fractionDigits = Currencies::getFractionDigits($currency); + $roundingIncrement = Currencies::getRoundingIncrement($currency); + + // Round with the formatter rounding mode + $value = $this->round($value, $fractionDigits); + + // Swiss rounding + if (0 < $roundingIncrement && 0 < $fractionDigits) { + $roundingFactor = $roundingIncrement / 10 ** $fractionDigits; + $value = round($value / $roundingFactor) * $roundingFactor; + } + + return $value; + } + + /** + * Rounds a value. + * + * @param int|float $value The value to round + * + * @return int|float The rounded value + */ + private function round($value, int $precision) + { + $precision = $this->getUninitializedPrecision($value, $precision); + + $roundingModeAttribute = $this->getAttribute(self::ROUNDING_MODE); + if (isset(self::$phpRoundingMap[$roundingModeAttribute])) { + $value = round($value, $precision, self::$phpRoundingMap[$roundingModeAttribute]); + } elseif (isset(self::$customRoundingList[$roundingModeAttribute])) { + $roundingCoef = 10 ** $precision; + $value *= $roundingCoef; + $value = (float) (string) $value; + + switch ($roundingModeAttribute) { + case self::ROUND_CEILING: + $value = ceil($value); + break; + case self::ROUND_FLOOR: + $value = floor($value); + break; + case self::ROUND_UP: + $value = $value > 0 ? ceil($value) : floor($value); + break; + case self::ROUND_DOWN: + $value = $value > 0 ? floor($value) : ceil($value); + break; + } + + $value /= $roundingCoef; + } + + return $value; + } + + /** + * Formats a number. + * + * @param int|float $value The numeric value to format + */ + private function formatNumber($value, int $precision): string + { + $precision = $this->getUninitializedPrecision($value, $precision); + + return number_format($value, $precision, '.', $this->getAttribute(self::GROUPING_USED) ? ',' : ''); + } + + /** + * Returns the precision value if the DECIMAL style is being used and the FRACTION_DIGITS attribute is uninitialized. + * + * @param int|float $value The value to get the precision from if the FRACTION_DIGITS attribute is uninitialized + */ + private function getUninitializedPrecision($value, int $precision): int + { + if (self::CURRENCY === $this->style) { + return $precision; + } + + if (!$this->isInitializedAttribute(self::FRACTION_DIGITS)) { + preg_match('/.*\.(.*)/', (string) $value, $digits); + if (isset($digits[1])) { + $precision = \strlen($digits[1]); + } + } + + return $precision; + } + + /** + * Check if the attribute is initialized (value set by client code). + */ + private function isInitializedAttribute(string $attr): bool + { + return isset($this->initializedAttributes[$attr]); + } + + /** + * Returns the numeric value using the $type to convert to the right data type. + * + * @param mixed $value The value to be converted + * + * @return int|float|false The converted value + */ + private function convertValueDataType($value, int $type) + { + if (self::TYPE_DOUBLE === $type) { + $value = (float) $value; + } elseif (self::TYPE_INT32 === $type) { + $value = $this->getInt32Value($value); + } elseif (self::TYPE_INT64 === $type) { + $value = $this->getInt64Value($value); + } + + return $value; + } + + /** + * Convert the value data type to int or returns false if the value is out of the integer value range. + * + * @return int|false The converted value + */ + private function getInt32Value($value) + { + if ($value > self::$int32Max || $value < -self::$int32Max - 1) { + return false; + } + + return (int) $value; + } + + /** + * Convert the value data type to int or returns false if the value is out of the integer value range. + * + * @return int|float|false The converted value + */ + private function getInt64Value($value) + { + if ($value > self::$int64Max || $value < -self::$int64Max - 1) { + return false; + } + + if (\PHP_INT_SIZE !== 8 && ($value > self::$int32Max || $value < -self::$int32Max - 1)) { + return (float) $value; + } + + return (int) $value; + } + + /** + * Check if the rounding mode is invalid. + */ + private function isInvalidRoundingMode(int $value): bool + { + if (\in_array($value, self::$roundingModes, true)) { + return false; + } + + return true; + } + + /** + * Returns the normalized value for the GROUPING_USED attribute. Any value that can be converted to int will be + * cast to Boolean and then to int again. This way, negative values are converted to 1 and string values to 0. + */ + private function normalizeGroupingUsedValue($value): int + { + return (int) (bool) (int) $value; + } + + /** + * Returns the normalized value for the FRACTION_DIGITS attribute. + */ + private function normalizeFractionDigitsValue($value): int + { + return (int) $value; + } +} diff --git a/lib/symfony/polyfill-intl-icu/README.md b/lib/symfony/polyfill-intl-icu/README.md new file mode 100644 index 000000000..b7faedc5d --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/README.md @@ -0,0 +1,23 @@ +Symfony Polyfill / Intl: ICU +============================ + +This package provides fallback implementations when the +[Intl](https://php.net/intl) extension is not installed. +It is limited to the "en" locale and to: + +- [`intl_is_failure()`](https://php.net/intl-is-failure) +- [`intl_get_error_code()`](https://php.net/intl-get-error-code) +- [`intl_get_error_message()`](https://php.net/intl-get-error-message) +- [`intl_error_name()`](https://php.net/intl-error-name) +- [`Collator`](https://php.net/Collator) +- [`NumberFormatter`](https://php.net/NumberFormatter) +- [`Locale`](https://php.net/Locale) +- [`IntlDateFormatter`](https://php.net/IntlDateFormatter) + +More information can be found in the +[main Polyfill README](https://github.com/symfony/polyfill/blob/main/README.md). + +License +======= + +This library is released under the [MIT license](LICENSE). diff --git a/lib/symfony/polyfill-intl-icu/Resources/currencies.php b/lib/symfony/polyfill-intl-icu/Resources/currencies.php new file mode 100644 index 000000000..f802b7a89 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Resources/currencies.php @@ -0,0 +1,1321 @@ + + array ( + 0 => 'ADP', + 1 => 0, + 2 => 0, + ), + 'AED' => + array ( + 0 => 'AED', + ), + 'AFA' => + array ( + 0 => 'AFA', + ), + 'AFN' => + array ( + 0 => 'AFN', + 1 => 0, + 2 => 0, + ), + 'ALK' => + array ( + 0 => 'ALK', + ), + 'ALL' => + array ( + 0 => 'ALL', + 1 => 0, + 2 => 0, + ), + 'AMD' => + array ( + 0 => 'AMD', + 1 => 2, + 2 => 0, + ), + 'ANG' => + array ( + 0 => 'ANG', + ), + 'AOA' => + array ( + 0 => 'AOA', + ), + 'AOK' => + array ( + 0 => 'AOK', + ), + 'AON' => + array ( + 0 => 'AON', + ), + 'AOR' => + array ( + 0 => 'AOR', + ), + 'ARA' => + array ( + 0 => 'ARA', + ), + 'ARL' => + array ( + 0 => 'ARL', + ), + 'ARM' => + array ( + 0 => 'ARM', + ), + 'ARP' => + array ( + 0 => 'ARP', + ), + 'ARS' => + array ( + 0 => 'ARS', + ), + 'ATS' => + array ( + 0 => 'ATS', + ), + 'AUD' => + array ( + 0 => 'A$', + ), + 'AWG' => + array ( + 0 => 'AWG', + ), + 'AZM' => + array ( + 0 => 'AZM', + ), + 'AZN' => + array ( + 0 => 'AZN', + ), + 'BAD' => + array ( + 0 => 'BAD', + ), + 'BAM' => + array ( + 0 => 'BAM', + ), + 'BAN' => + array ( + 0 => 'BAN', + ), + 'BBD' => + array ( + 0 => 'BBD', + ), + 'BDT' => + array ( + 0 => 'BDT', + ), + 'BEC' => + array ( + 0 => 'BEC', + ), + 'BEF' => + array ( + 0 => 'BEF', + ), + 'BEL' => + array ( + 0 => 'BEL', + ), + 'BGL' => + array ( + 0 => 'BGL', + ), + 'BGM' => + array ( + 0 => 'BGM', + ), + 'BGN' => + array ( + 0 => 'BGN', + ), + 'BGO' => + array ( + 0 => 'BGO', + ), + 'BHD' => + array ( + 0 => 'BHD', + 1 => 3, + 2 => 0, + ), + 'BIF' => + array ( + 0 => 'BIF', + 1 => 0, + 2 => 0, + ), + 'BMD' => + array ( + 0 => 'BMD', + ), + 'BND' => + array ( + 0 => 'BND', + ), + 'BOB' => + array ( + 0 => 'BOB', + ), + 'BOL' => + array ( + 0 => 'BOL', + ), + 'BOP' => + array ( + 0 => 'BOP', + ), + 'BOV' => + array ( + 0 => 'BOV', + ), + 'BRB' => + array ( + 0 => 'BRB', + ), + 'BRC' => + array ( + 0 => 'BRC', + ), + 'BRE' => + array ( + 0 => 'BRE', + ), + 'BRL' => + array ( + 0 => 'R$', + ), + 'BRN' => + array ( + 0 => 'BRN', + ), + 'BRR' => + array ( + 0 => 'BRR', + ), + 'BRZ' => + array ( + 0 => 'BRZ', + ), + 'BSD' => + array ( + 0 => 'BSD', + ), + 'BTN' => + array ( + 0 => 'BTN', + ), + 'BUK' => + array ( + 0 => 'BUK', + ), + 'BWP' => + array ( + 0 => 'BWP', + ), + 'BYB' => + array ( + 0 => 'BYB', + ), + 'BYN' => + array ( + 0 => 'BYN', + 1 => 2, + 2 => 0, + ), + 'BYR' => + array ( + 0 => 'BYR', + 1 => 0, + 2 => 0, + ), + 'BZD' => + array ( + 0 => 'BZD', + ), + 'CAD' => + array ( + 0 => 'CA$', + 1 => 2, + 2 => 0, + ), + 'CDF' => + array ( + 0 => 'CDF', + ), + 'CHE' => + array ( + 0 => 'CHE', + ), + 'CHF' => + array ( + 0 => 'CHF', + 1 => 2, + 2 => 0, + ), + 'CHW' => + array ( + 0 => 'CHW', + ), + 'CLE' => + array ( + 0 => 'CLE', + ), + 'CLF' => + array ( + 0 => 'CLF', + 1 => 4, + 2 => 0, + ), + 'CLP' => + array ( + 0 => 'CLP', + 1 => 0, + 2 => 0, + ), + 'CNH' => + array ( + 0 => 'CNH', + ), + 'CNX' => + array ( + 0 => 'CNX', + ), + 'CNY' => + array ( + 0 => 'CN¥', + ), + 'COP' => + array ( + 0 => 'COP', + 1 => 2, + 2 => 0, + ), + 'COU' => + array ( + 0 => 'COU', + ), + 'CRC' => + array ( + 0 => 'CRC', + 1 => 2, + 2 => 0, + ), + 'CSD' => + array ( + 0 => 'CSD', + ), + 'CSK' => + array ( + 0 => 'CSK', + ), + 'CUC' => + array ( + 0 => 'CUC', + ), + 'CUP' => + array ( + 0 => 'CUP', + ), + 'CVE' => + array ( + 0 => 'CVE', + ), + 'CYP' => + array ( + 0 => 'CYP', + ), + 'CZK' => + array ( + 0 => 'CZK', + 1 => 2, + 2 => 0, + ), + 'DDM' => + array ( + 0 => 'DDM', + ), + 'DEM' => + array ( + 0 => 'DEM', + ), + 'DJF' => + array ( + 0 => 'DJF', + 1 => 0, + 2 => 0, + ), + 'DKK' => + array ( + 0 => 'DKK', + 1 => 2, + 2 => 0, + ), + 'DOP' => + array ( + 0 => 'DOP', + ), + 'DZD' => + array ( + 0 => 'DZD', + ), + 'ECS' => + array ( + 0 => 'ECS', + ), + 'ECV' => + array ( + 0 => 'ECV', + ), + 'EEK' => + array ( + 0 => 'EEK', + ), + 'EGP' => + array ( + 0 => 'EGP', + ), + 'ERN' => + array ( + 0 => 'ERN', + ), + 'ESA' => + array ( + 0 => 'ESA', + ), + 'ESB' => + array ( + 0 => 'ESB', + ), + 'ESP' => + array ( + 0 => 'ESP', + 1 => 0, + 2 => 0, + ), + 'ETB' => + array ( + 0 => 'ETB', + ), + 'EUR' => + array ( + 0 => '€', + ), + 'FIM' => + array ( + 0 => 'FIM', + ), + 'FJD' => + array ( + 0 => 'FJD', + ), + 'FKP' => + array ( + 0 => 'FKP', + ), + 'FRF' => + array ( + 0 => 'FRF', + ), + 'GBP' => + array ( + 0 => '£', + ), + 'GEK' => + array ( + 0 => 'GEK', + ), + 'GEL' => + array ( + 0 => 'GEL', + ), + 'GHC' => + array ( + 0 => 'GHC', + ), + 'GHS' => + array ( + 0 => 'GHS', + ), + 'GIP' => + array ( + 0 => 'GIP', + ), + 'GMD' => + array ( + 0 => 'GMD', + ), + 'GNF' => + array ( + 0 => 'GNF', + 1 => 0, + 2 => 0, + ), + 'GNS' => + array ( + 0 => 'GNS', + ), + 'GQE' => + array ( + 0 => 'GQE', + ), + 'GRD' => + array ( + 0 => 'GRD', + ), + 'GTQ' => + array ( + 0 => 'GTQ', + ), + 'GWE' => + array ( + 0 => 'GWE', + ), + 'GWP' => + array ( + 0 => 'GWP', + ), + 'GYD' => + array ( + 0 => 'GYD', + 1 => 2, + 2 => 0, + ), + 'HKD' => + array ( + 0 => 'HK$', + ), + 'HNL' => + array ( + 0 => 'HNL', + ), + 'HRD' => + array ( + 0 => 'HRD', + ), + 'HRK' => + array ( + 0 => 'HRK', + ), + 'HTG' => + array ( + 0 => 'HTG', + ), + 'HUF' => + array ( + 0 => 'HUF', + 1 => 2, + 2 => 0, + ), + 'IDR' => + array ( + 0 => 'IDR', + 1 => 2, + 2 => 0, + ), + 'IEP' => + array ( + 0 => 'IEP', + ), + 'ILP' => + array ( + 0 => 'ILP', + ), + 'ILR' => + array ( + 0 => 'ILR', + ), + 'ILS' => + array ( + 0 => '₪', + ), + 'INR' => + array ( + 0 => '₹', + ), + 'IQD' => + array ( + 0 => 'IQD', + 1 => 0, + 2 => 0, + ), + 'IRR' => + array ( + 0 => 'IRR', + 1 => 0, + 2 => 0, + ), + 'ISJ' => + array ( + 0 => 'ISJ', + ), + 'ISK' => + array ( + 0 => 'ISK', + 1 => 0, + 2 => 0, + ), + 'ITL' => + array ( + 0 => 'ITL', + 1 => 0, + 2 => 0, + ), + 'JMD' => + array ( + 0 => 'JMD', + ), + 'JOD' => + array ( + 0 => 'JOD', + 1 => 3, + 2 => 0, + ), + 'JPY' => + array ( + 0 => '¥', + 1 => 0, + 2 => 0, + ), + 'KES' => + array ( + 0 => 'KES', + ), + 'KGS' => + array ( + 0 => 'KGS', + ), + 'KHR' => + array ( + 0 => 'KHR', + ), + 'KMF' => + array ( + 0 => 'KMF', + 1 => 0, + 2 => 0, + ), + 'KPW' => + array ( + 0 => 'KPW', + 1 => 0, + 2 => 0, + ), + 'KRH' => + array ( + 0 => 'KRH', + ), + 'KRO' => + array ( + 0 => 'KRO', + ), + 'KRW' => + array ( + 0 => '₩', + 1 => 0, + 2 => 0, + ), + 'KWD' => + array ( + 0 => 'KWD', + 1 => 3, + 2 => 0, + ), + 'KYD' => + array ( + 0 => 'KYD', + ), + 'KZT' => + array ( + 0 => 'KZT', + ), + 'LAK' => + array ( + 0 => 'LAK', + 1 => 0, + 2 => 0, + ), + 'LBP' => + array ( + 0 => 'LBP', + 1 => 0, + 2 => 0, + ), + 'LKR' => + array ( + 0 => 'LKR', + ), + 'LRD' => + array ( + 0 => 'LRD', + ), + 'LSL' => + array ( + 0 => 'LSL', + ), + 'LTL' => + array ( + 0 => 'LTL', + ), + 'LTT' => + array ( + 0 => 'LTT', + ), + 'LUC' => + array ( + 0 => 'LUC', + ), + 'LUF' => + array ( + 0 => 'LUF', + 1 => 0, + 2 => 0, + ), + 'LUL' => + array ( + 0 => 'LUL', + ), + 'LVL' => + array ( + 0 => 'LVL', + ), + 'LVR' => + array ( + 0 => 'LVR', + ), + 'LYD' => + array ( + 0 => 'LYD', + 1 => 3, + 2 => 0, + ), + 'MAD' => + array ( + 0 => 'MAD', + ), + 'MAF' => + array ( + 0 => 'MAF', + ), + 'MCF' => + array ( + 0 => 'MCF', + ), + 'MDC' => + array ( + 0 => 'MDC', + ), + 'MDL' => + array ( + 0 => 'MDL', + ), + 'MGA' => + array ( + 0 => 'MGA', + 1 => 0, + 2 => 0, + ), + 'MGF' => + array ( + 0 => 'MGF', + 1 => 0, + 2 => 0, + ), + 'MKD' => + array ( + 0 => 'MKD', + ), + 'MKN' => + array ( + 0 => 'MKN', + ), + 'MLF' => + array ( + 0 => 'MLF', + ), + 'MMK' => + array ( + 0 => 'MMK', + 1 => 0, + 2 => 0, + ), + 'MNT' => + array ( + 0 => 'MNT', + 1 => 2, + 2 => 0, + ), + 'MOP' => + array ( + 0 => 'MOP', + ), + 'MRO' => + array ( + 0 => 'MRO', + 1 => 0, + 2 => 0, + ), + 'MRU' => + array ( + 0 => 'MRU', + ), + 'MTL' => + array ( + 0 => 'MTL', + ), + 'MTP' => + array ( + 0 => 'MTP', + ), + 'MUR' => + array ( + 0 => 'MUR', + 1 => 2, + 2 => 0, + ), + 'MVP' => + array ( + 0 => 'MVP', + ), + 'MVR' => + array ( + 0 => 'MVR', + ), + 'MWK' => + array ( + 0 => 'MWK', + ), + 'MXN' => + array ( + 0 => 'MX$', + ), + 'MXP' => + array ( + 0 => 'MXP', + ), + 'MXV' => + array ( + 0 => 'MXV', + ), + 'MYR' => + array ( + 0 => 'MYR', + ), + 'MZE' => + array ( + 0 => 'MZE', + ), + 'MZM' => + array ( + 0 => 'MZM', + ), + 'MZN' => + array ( + 0 => 'MZN', + ), + 'NAD' => + array ( + 0 => 'NAD', + ), + 'NGN' => + array ( + 0 => 'NGN', + ), + 'NIC' => + array ( + 0 => 'NIC', + ), + 'NIO' => + array ( + 0 => 'NIO', + ), + 'NLG' => + array ( + 0 => 'NLG', + ), + 'NOK' => + array ( + 0 => 'NOK', + 1 => 2, + 2 => 0, + ), + 'NPR' => + array ( + 0 => 'NPR', + ), + 'NZD' => + array ( + 0 => 'NZ$', + ), + 'OMR' => + array ( + 0 => 'OMR', + 1 => 3, + 2 => 0, + ), + 'PAB' => + array ( + 0 => 'PAB', + ), + 'PEI' => + array ( + 0 => 'PEI', + ), + 'PEN' => + array ( + 0 => 'PEN', + ), + 'PES' => + array ( + 0 => 'PES', + ), + 'PGK' => + array ( + 0 => 'PGK', + ), + 'PHP' => + array ( + 0 => '₱', + ), + 'PKR' => + array ( + 0 => 'PKR', + 1 => 2, + 2 => 0, + ), + 'PLN' => + array ( + 0 => 'PLN', + ), + 'PLZ' => + array ( + 0 => 'PLZ', + ), + 'PTE' => + array ( + 0 => 'PTE', + ), + 'PYG' => + array ( + 0 => 'PYG', + 1 => 0, + 2 => 0, + ), + 'QAR' => + array ( + 0 => 'QAR', + ), + 'RHD' => + array ( + 0 => 'RHD', + ), + 'ROL' => + array ( + 0 => 'ROL', + ), + 'RON' => + array ( + 0 => 'RON', + ), + 'RSD' => + array ( + 0 => 'RSD', + 1 => 0, + 2 => 0, + ), + 'RUB' => + array ( + 0 => 'RUB', + ), + 'RUR' => + array ( + 0 => 'RUR', + ), + 'RWF' => + array ( + 0 => 'RWF', + 1 => 0, + 2 => 0, + ), + 'SAR' => + array ( + 0 => 'SAR', + ), + 'SBD' => + array ( + 0 => 'SBD', + ), + 'SCR' => + array ( + 0 => 'SCR', + ), + 'SDD' => + array ( + 0 => 'SDD', + ), + 'SDG' => + array ( + 0 => 'SDG', + ), + 'SDP' => + array ( + 0 => 'SDP', + ), + 'SEK' => + array ( + 0 => 'SEK', + 1 => 2, + 2 => 0, + ), + 'SGD' => + array ( + 0 => 'SGD', + ), + 'SHP' => + array ( + 0 => 'SHP', + ), + 'SIT' => + array ( + 0 => 'SIT', + ), + 'SKK' => + array ( + 0 => 'SKK', + ), + 'SLE' => + array ( + 0 => 'SLE', + 1 => 2, + 2 => 0, + ), + 'SLL' => + array ( + 0 => 'SLL', + 1 => 0, + 2 => 0, + ), + 'SOS' => + array ( + 0 => 'SOS', + 1 => 0, + 2 => 0, + ), + 'SRD' => + array ( + 0 => 'SRD', + ), + 'SRG' => + array ( + 0 => 'SRG', + ), + 'SSP' => + array ( + 0 => 'SSP', + ), + 'STD' => + array ( + 0 => 'STD', + 1 => 0, + 2 => 0, + ), + 'STN' => + array ( + 0 => 'STN', + ), + 'SUR' => + array ( + 0 => 'SUR', + ), + 'SVC' => + array ( + 0 => 'SVC', + ), + 'SYP' => + array ( + 0 => 'SYP', + 1 => 0, + 2 => 0, + ), + 'SZL' => + array ( + 0 => 'SZL', + ), + 'THB' => + array ( + 0 => 'THB', + ), + 'TJR' => + array ( + 0 => 'TJR', + ), + 'TJS' => + array ( + 0 => 'TJS', + ), + 'TMM' => + array ( + 0 => 'TMM', + 1 => 0, + 2 => 0, + ), + 'TMT' => + array ( + 0 => 'TMT', + ), + 'TND' => + array ( + 0 => 'TND', + 1 => 3, + 2 => 0, + ), + 'TOP' => + array ( + 0 => 'TOP', + ), + 'TPE' => + array ( + 0 => 'TPE', + ), + 'TRL' => + array ( + 0 => 'TRL', + 1 => 0, + 2 => 0, + ), + 'TRY' => + array ( + 0 => 'TRY', + ), + 'TTD' => + array ( + 0 => 'TTD', + ), + 'TWD' => + array ( + 0 => 'NT$', + 1 => 2, + 2 => 0, + ), + 'TZS' => + array ( + 0 => 'TZS', + 1 => 2, + 2 => 0, + ), + 'UAH' => + array ( + 0 => 'UAH', + ), + 'UAK' => + array ( + 0 => 'UAK', + ), + 'UGS' => + array ( + 0 => 'UGS', + ), + 'UGX' => + array ( + 0 => 'UGX', + 1 => 0, + 2 => 0, + ), + 'USD' => + array ( + 0 => '$', + ), + 'USN' => + array ( + 0 => 'USN', + ), + 'USS' => + array ( + 0 => 'USS', + ), + 'UYI' => + array ( + 0 => 'UYI', + 1 => 0, + 2 => 0, + ), + 'UYP' => + array ( + 0 => 'UYP', + ), + 'UYU' => + array ( + 0 => 'UYU', + ), + 'UYW' => + array ( + 0 => 'UYW', + 1 => 4, + 2 => 0, + ), + 'UZS' => + array ( + 0 => 'UZS', + 1 => 2, + 2 => 0, + ), + 'VEB' => + array ( + 0 => 'VEB', + ), + 'VED' => + array ( + 0 => 'VED', + ), + 'VEF' => + array ( + 0 => 'VEF', + 1 => 2, + 2 => 0, + ), + 'VES' => + array ( + 0 => 'VES', + ), + 'VND' => + array ( + 0 => '₫', + 1 => 0, + 2 => 0, + ), + 'VNN' => + array ( + 0 => 'VNN', + ), + 'VUV' => + array ( + 0 => 'VUV', + 1 => 0, + 2 => 0, + ), + 'WST' => + array ( + 0 => 'WST', + ), + 'XAF' => + array ( + 0 => 'FCFA', + 1 => 0, + 2 => 0, + ), + 'XCD' => + array ( + 0 => 'EC$', + ), + 'XEU' => + array ( + 0 => 'XEU', + ), + 'XFO' => + array ( + 0 => 'XFO', + ), + 'XFU' => + array ( + 0 => 'XFU', + ), + 'XOF' => + array ( + 0 => 'F CFA', + 1 => 0, + 2 => 0, + ), + 'XPF' => + array ( + 0 => 'CFPF', + 1 => 0, + 2 => 0, + ), + 'XRE' => + array ( + 0 => 'XRE', + ), + 'YDD' => + array ( + 0 => 'YDD', + ), + 'YER' => + array ( + 0 => 'YER', + 1 => 0, + 2 => 0, + ), + 'YUD' => + array ( + 0 => 'YUD', + ), + 'YUM' => + array ( + 0 => 'YUM', + ), + 'YUN' => + array ( + 0 => 'YUN', + ), + 'YUR' => + array ( + 0 => 'YUR', + ), + 'ZAL' => + array ( + 0 => 'ZAL', + ), + 'ZAR' => + array ( + 0 => 'ZAR', + ), + 'ZMK' => + array ( + 0 => 'ZMK', + 1 => 0, + 2 => 0, + ), + 'ZMW' => + array ( + 0 => 'ZMW', + ), + 'ZRN' => + array ( + 0 => 'ZRN', + ), + 'ZRZ' => + array ( + 0 => 'ZRZ', + ), + 'ZWD' => + array ( + 0 => 'ZWD', + 1 => 0, + 2 => 0, + ), + 'ZWL' => + array ( + 0 => 'ZWL', + ), + 'ZWR' => + array ( + 0 => 'ZWR', + ), + 'DEFAULT' => + array ( + 1 => 2, + 2 => 0, + ), +); diff --git a/lib/symfony/polyfill-intl-icu/Resources/stubs/Collator.php b/lib/symfony/polyfill-intl-icu/Resources/stubs/Collator.php new file mode 100644 index 000000000..a1efbcb80 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Resources/stubs/Collator.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu\Collator as CollatorPolyfill; + +/** + * Stub implementation for the Collator class of the intl extension. + * + * @author Bernhard Schussek + */ +class Collator extends CollatorPolyfill +{ +} diff --git a/lib/symfony/polyfill-intl-icu/Resources/stubs/IntlDateFormatter.php b/lib/symfony/polyfill-intl-icu/Resources/stubs/IntlDateFormatter.php new file mode 100644 index 000000000..e7012008e --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Resources/stubs/IntlDateFormatter.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu\IntlDateFormatter as IntlDateFormatterPolyfill; + +/** + * Stub implementation for the IntlDateFormatter class of the intl extension. + * + * @author Bernhard Schussek + */ +class IntlDateFormatter extends IntlDateFormatterPolyfill +{ +} diff --git a/lib/symfony/polyfill-intl-icu/Resources/stubs/Locale.php b/lib/symfony/polyfill-intl-icu/Resources/stubs/Locale.php new file mode 100644 index 000000000..f1b951e13 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Resources/stubs/Locale.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu\Locale as LocalePolyfill; + +/** + * Stub implementation for the Locale class of the intl extension. + * + * @author Bernhard Schussek + */ +class Locale extends LocalePolyfill +{ +} diff --git a/lib/symfony/polyfill-intl-icu/Resources/stubs/NumberFormatter.php b/lib/symfony/polyfill-intl-icu/Resources/stubs/NumberFormatter.php new file mode 100644 index 000000000..9288b9dd6 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/Resources/stubs/NumberFormatter.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu\NumberFormatter as NumberFormatterPolyfill; + +/** + * Stub implementation for the NumberFormatter class of the intl extension. + * + * @author Bernhard Schussek + * + * @see IntlNumberFormatter + */ +class NumberFormatter extends NumberFormatterPolyfill +{ +} diff --git a/lib/symfony/polyfill-intl-icu/bootstrap.php b/lib/symfony/polyfill-intl-icu/bootstrap.php new file mode 100644 index 000000000..77d754379 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/bootstrap.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu as p; + +if (extension_loaded('intl')) { + return; +} + +if (\PHP_VERSION_ID >= 80000) { + return require __DIR__.'/bootstrap80.php'; +} + +if (!function_exists('intl_is_failure')) { + function intl_is_failure($errorCode) { return p\Icu::isFailure($errorCode); } +} +if (!function_exists('intl_get_error_code')) { + function intl_get_error_code() { return p\Icu::getErrorCode(); } +} +if (!function_exists('intl_get_error_message')) { + function intl_get_error_message() { return p\Icu::getErrorMessage(); } +} +if (!function_exists('intl_error_name')) { + function intl_error_name($errorCode) { return p\Icu::getErrorName($errorCode); } +} diff --git a/lib/symfony/polyfill-intl-icu/bootstrap80.php b/lib/symfony/polyfill-intl-icu/bootstrap80.php new file mode 100644 index 000000000..ee1653a38 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/bootstrap80.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Polyfill\Intl\Icu as p; + +if (!function_exists('intl_is_failure')) { + function intl_is_failure(?int $errorCode): bool { return p\Icu::isFailure((int) $errorCode); } +} +if (!function_exists('intl_get_error_code')) { + function intl_get_error_code(): int { return p\Icu::getErrorCode(); } +} +if (!function_exists('intl_get_error_message')) { + function intl_get_error_message(): string { return p\Icu::getErrorMessage(); } +} +if (!function_exists('intl_error_name')) { + function intl_error_name(?int $errorCode): string { return p\Icu::getErrorName((int) $errorCode); } +} diff --git a/lib/symfony/polyfill-intl-icu/composer.json b/lib/symfony/polyfill-intl-icu/composer.json new file mode 100644 index 000000000..33c74ab91 --- /dev/null +++ b/lib/symfony/polyfill-intl-icu/composer.json @@ -0,0 +1,39 @@ +{ + "name": "symfony/polyfill-intl-icu", + "type": "library", + "description": "Symfony polyfill for intl's ICU-related data and classes", + "keywords": ["polyfill", "shim", "compatibility", "portable", "intl", "icu"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2" + }, + "autoload": { + "files": [ "bootstrap.php" ], + "psr-4": { "Symfony\\Polyfill\\Intl\\Icu\\": "" }, + "classmap": [ "Resources/stubs" ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "minimum-stability": "dev", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + } +} diff --git a/lib/symfony/property-access/CHANGELOG.md b/lib/symfony/property-access/CHANGELOG.md new file mode 100644 index 000000000..a48ed823c --- /dev/null +++ b/lib/symfony/property-access/CHANGELOG.md @@ -0,0 +1,88 @@ +CHANGELOG +========= + +6.3 +--- + + * Allow escaping `.` and `[` with `\` in `PropertyPath` + +6.2 +--- + + * Deprecate calling `PropertyAccessorBuilder::setCacheItemPool()` without arguments + * Added method `isNullSafe()` to `PropertyPathInterface`, implementing the interface without implementing this method + is deprecated + * Add support for the null-coalesce operator in property paths + +6.0 +--- + + * make `PropertyAccessor::__construct()` accept a combination of bitwise flags as first and second arguments + +5.3.0 +----- + + * deprecate passing a boolean as the second argument of `PropertyAccessor::__construct()`, expecting a combination of bitwise flags instead + +5.2.0 +----- + + * deprecated passing a boolean as the first argument of `PropertyAccessor::__construct()`, expecting a combination of bitwise flags instead + * added the ability to disable usage of the magic `__get` & `__set` methods + +5.1.0 +----- + + * Added an `UninitializedPropertyException` + * Linking to PropertyInfo extractor to remove a lot of duplicate code + +4.4.0 +----- + + * deprecated passing `null` as `$defaultLifetime` 2nd argument of `PropertyAccessor::createCache()` method, + pass `0` instead + +4.3.0 +----- + + * added a `$throwExceptionOnInvalidPropertyPath` argument to the PropertyAccessor constructor. + * added `enableExceptionOnInvalidPropertyPath()`, `disableExceptionOnInvalidPropertyPath()` and + `isExceptionOnInvalidPropertyPath()` methods to `PropertyAccessorBuilder` + +4.0.0 +----- + + * removed the `StringUtil` class, use `Symfony\Component\Inflector\Inflector` + +3.1.0 +----- + + * deprecated the `StringUtil` class, use `Symfony\Component\Inflector\Inflector` + instead + +2.7.0 +------ + + * `UnexpectedTypeException` now expects three constructor arguments: The invalid property value, + the `PropertyPathInterface` object and the current index of the property path. + +2.5.0 +------ + + * allowed non alpha numeric characters in second level and deeper object properties names + * [BC BREAK] when accessing an index on an object that does not implement + ArrayAccess, a NoSuchIndexException is now thrown instead of the + semantically wrong NoSuchPropertyException + * [BC BREAK] added isReadable() and isWritable() to PropertyAccessorInterface + +2.3.0 +------ + + * added PropertyAccessorBuilder, to enable or disable the support of "__call" + * added support for "__call" in the PropertyAccessor (disabled by default) + * [BC BREAK] changed PropertyAccessor to continue its search for a property or + method even if a non-public match was found. Before, a PropertyAccessDeniedException + was thrown in this case. Class PropertyAccessDeniedException was removed + now. + * deprecated PropertyAccess::getPropertyAccessor + * added PropertyAccess::createPropertyAccessor and PropertyAccess::createPropertyAccessorBuilder diff --git a/lib/symfony/property-access/Exception/AccessException.php b/lib/symfony/property-access/Exception/AccessException.php new file mode 100644 index 000000000..b3a854646 --- /dev/null +++ b/lib/symfony/property-access/Exception/AccessException.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\PropertyAccess\Exception; + +/** + * Thrown when a property path is not available. + * + * @author Stéphane Escandell + */ +class AccessException extends RuntimeException +{ +} diff --git a/lib/symfony/property-access/Exception/ExceptionInterface.php b/lib/symfony/property-access/Exception/ExceptionInterface.php new file mode 100644 index 000000000..fabf9a080 --- /dev/null +++ b/lib/symfony/property-access/Exception/ExceptionInterface.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\PropertyAccess\Exception; + +/** + * Marker interface for the PropertyAccess component. + * + * @author Bernhard Schussek + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/lib/symfony/property-access/Exception/InvalidArgumentException.php b/lib/symfony/property-access/Exception/InvalidArgumentException.php new file mode 100644 index 000000000..47bc7e150 --- /dev/null +++ b/lib/symfony/property-access/Exception/InvalidArgumentException.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\PropertyAccess\Exception; + +/** + * Base InvalidArgumentException for the PropertyAccess component. + * + * @author Bernhard Schussek + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/lib/symfony/property-access/Exception/InvalidPropertyPathException.php b/lib/symfony/property-access/Exception/InvalidPropertyPathException.php new file mode 100644 index 000000000..69de31cee --- /dev/null +++ b/lib/symfony/property-access/Exception/InvalidPropertyPathException.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\PropertyAccess\Exception; + +/** + * Thrown when a property path is malformed. + * + * @author Bernhard Schussek + */ +class InvalidPropertyPathException extends RuntimeException +{ +} diff --git a/lib/symfony/property-access/Exception/NoSuchIndexException.php b/lib/symfony/property-access/Exception/NoSuchIndexException.php new file mode 100644 index 000000000..597b9904a --- /dev/null +++ b/lib/symfony/property-access/Exception/NoSuchIndexException.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\PropertyAccess\Exception; + +/** + * Thrown when an index cannot be found. + * + * @author Stéphane Escandell + */ +class NoSuchIndexException extends AccessException +{ +} diff --git a/lib/symfony/property-access/Exception/NoSuchPropertyException.php b/lib/symfony/property-access/Exception/NoSuchPropertyException.php new file mode 100644 index 000000000..1c7eda5f8 --- /dev/null +++ b/lib/symfony/property-access/Exception/NoSuchPropertyException.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\PropertyAccess\Exception; + +/** + * Thrown when a property cannot be found. + * + * @author Bernhard Schussek + */ +class NoSuchPropertyException extends AccessException +{ +} diff --git a/lib/symfony/property-access/Exception/OutOfBoundsException.php b/lib/symfony/property-access/Exception/OutOfBoundsException.php new file mode 100644 index 000000000..a3c45597d --- /dev/null +++ b/lib/symfony/property-access/Exception/OutOfBoundsException.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\PropertyAccess\Exception; + +/** + * Base OutOfBoundsException for the PropertyAccess component. + * + * @author Bernhard Schussek + */ +class OutOfBoundsException extends \OutOfBoundsException implements ExceptionInterface +{ +} diff --git a/lib/symfony/property-access/Exception/RuntimeException.php b/lib/symfony/property-access/Exception/RuntimeException.php new file mode 100644 index 000000000..9fe843e30 --- /dev/null +++ b/lib/symfony/property-access/Exception/RuntimeException.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\PropertyAccess\Exception; + +/** + * Base RuntimeException for the PropertyAccess component. + * + * @author Bernhard Schussek + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/lib/symfony/property-access/Exception/UnexpectedTypeException.php b/lib/symfony/property-access/Exception/UnexpectedTypeException.php new file mode 100644 index 000000000..aa599379e --- /dev/null +++ b/lib/symfony/property-access/Exception/UnexpectedTypeException.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\PropertyAccess\Exception; + +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * Thrown when a value does not match an expected type. + * + * @author Bernhard Schussek + */ +class UnexpectedTypeException extends RuntimeException +{ + /** + * @param mixed $value The unexpected value found while traversing property path + * @param int $pathIndex The property path index when the unexpected value was found + */ + public function __construct(mixed $value, PropertyPathInterface $path, int $pathIndex) + { + $message = sprintf( + 'PropertyAccessor requires a graph of objects or arrays to operate on, '. + 'but it found type "%s" while trying to traverse path "%s" at property "%s".', + \gettype($value), + (string) $path, + $path->getElement($pathIndex) + ); + + parent::__construct($message); + } +} diff --git a/lib/symfony/property-access/Exception/UninitializedPropertyException.php b/lib/symfony/property-access/Exception/UninitializedPropertyException.php new file mode 100644 index 000000000..c0d69735d --- /dev/null +++ b/lib/symfony/property-access/Exception/UninitializedPropertyException.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\PropertyAccess\Exception; + +/** + * Thrown when a property is not initialized. + * + * @author Jules Pietri + */ +class UninitializedPropertyException extends AccessException +{ +} diff --git a/lib/symfony/property-access/LICENSE b/lib/symfony/property-access/LICENSE new file mode 100644 index 000000000..0138f8f07 --- /dev/null +++ b/lib/symfony/property-access/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2004-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/symfony/property-access/PropertyAccess.php b/lib/symfony/property-access/PropertyAccess.php new file mode 100644 index 000000000..1953ac096 --- /dev/null +++ b/lib/symfony/property-access/PropertyAccess.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyAccess; + +/** + * Entry point of the PropertyAccess component. + * + * @author Bernhard Schussek + */ +final class PropertyAccess +{ + /** + * Creates a property accessor with the default configuration. + */ + public static function createPropertyAccessor(): PropertyAccessor + { + return self::createPropertyAccessorBuilder()->getPropertyAccessor(); + } + + public static function createPropertyAccessorBuilder(): PropertyAccessorBuilder + { + return new PropertyAccessorBuilder(); + } + + /** + * This class cannot be instantiated. + */ + private function __construct() + { + } +} diff --git a/lib/symfony/property-access/PropertyAccessor.php b/lib/symfony/property-access/PropertyAccessor.php new file mode 100644 index 000000000..d4dbaa8bc --- /dev/null +++ b/lib/symfony/property-access/PropertyAccessor.php @@ -0,0 +1,715 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyAccess; + +use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\Adapter\ApcuAdapter; +use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException; +use Symfony\Component\PropertyAccess\Exception\NoSuchIndexException; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; +use Symfony\Component\PropertyAccess\Exception\UnexpectedTypeException; +use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyReadInfo; +use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfo; +use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; + +/** + * Default implementation of {@link PropertyAccessorInterface}. + * + * @author Bernhard Schussek + * @author Kévin Dunglas + * @author Nicolas Grekas + */ +class PropertyAccessor implements PropertyAccessorInterface +{ + /** @var int Allow none of the magic methods */ + public const DISALLOW_MAGIC_METHODS = ReflectionExtractor::DISALLOW_MAGIC_METHODS; + /** @var int Allow magic __get methods */ + public const MAGIC_GET = ReflectionExtractor::ALLOW_MAGIC_GET; + /** @var int Allow magic __set methods */ + public const MAGIC_SET = ReflectionExtractor::ALLOW_MAGIC_SET; + /** @var int Allow magic __call methods */ + public const MAGIC_CALL = ReflectionExtractor::ALLOW_MAGIC_CALL; + + public const DO_NOT_THROW = 0; + public const THROW_ON_INVALID_INDEX = 1; + public const THROW_ON_INVALID_PROPERTY_PATH = 2; + + private const VALUE = 0; + private const REF = 1; + private const IS_REF_CHAINED = 2; + private const CACHE_PREFIX_READ = 'r'; + private const CACHE_PREFIX_WRITE = 'w'; + private const CACHE_PREFIX_PROPERTY_PATH = 'p'; + private const RESULT_PROTO = [self::VALUE => null]; + + private int $magicMethodsFlags; + private bool $ignoreInvalidIndices; + private bool $ignoreInvalidProperty; + private ?CacheItemPoolInterface $cacheItemPool; + private array $propertyPathCache = []; + private PropertyReadInfoExtractorInterface $readInfoExtractor; + private PropertyWriteInfoExtractorInterface $writeInfoExtractor; + private array $readPropertyCache = []; + private array $writePropertyCache = []; + + /** + * Should not be used by application code. Use + * {@link PropertyAccess::createPropertyAccessor()} instead. + * + * @param int $magicMethods A bitwise combination of the MAGIC_* constants + * to specify the allowed magic methods (__get, __set, __call) + * or self::DISALLOW_MAGIC_METHODS for none + * @param int $throw A bitwise combination of the THROW_* constants + * to specify when exceptions should be thrown + */ + public function __construct(int $magicMethods = self::MAGIC_GET | self::MAGIC_SET, int $throw = self::THROW_ON_INVALID_PROPERTY_PATH, ?CacheItemPoolInterface $cacheItemPool = null, ?PropertyReadInfoExtractorInterface $readInfoExtractor = null, ?PropertyWriteInfoExtractorInterface $writeInfoExtractor = null) + { + $this->magicMethodsFlags = $magicMethods; + $this->ignoreInvalidIndices = 0 === ($throw & self::THROW_ON_INVALID_INDEX); + $this->cacheItemPool = $cacheItemPool instanceof NullAdapter ? null : $cacheItemPool; // Replace the NullAdapter by the null value + $this->ignoreInvalidProperty = 0 === ($throw & self::THROW_ON_INVALID_PROPERTY_PATH); + $this->readInfoExtractor = $readInfoExtractor ?? new ReflectionExtractor([], null, null, false); + $this->writeInfoExtractor = $writeInfoExtractor ?? new ReflectionExtractor(['set'], null, null, false); + } + + public function getValue(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): mixed + { + $zval = [ + self::VALUE => $objectOrArray, + ]; + + if (\is_object($objectOrArray) && (false === strpbrk((string) $propertyPath, '.[?') || $objectOrArray instanceof \stdClass && property_exists($objectOrArray, $propertyPath))) { + return $this->readProperty($zval, $propertyPath, $this->ignoreInvalidProperty)[self::VALUE]; + } + + $propertyPath = $this->getPropertyPath($propertyPath); + + $propertyValues = $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength(), $this->ignoreInvalidIndices); + + return $propertyValues[\count($propertyValues) - 1][self::VALUE]; + } + + /** + * @return void + */ + public function setValue(object|array &$objectOrArray, string|PropertyPathInterface $propertyPath, mixed $value) + { + if (\is_object($objectOrArray) && (false === strpbrk((string) $propertyPath, '.[') || $objectOrArray instanceof \stdClass && property_exists($objectOrArray, $propertyPath))) { + $zval = [ + self::VALUE => $objectOrArray, + ]; + + try { + $this->writeProperty($zval, $propertyPath, $value); + + return; + } catch (\TypeError $e) { + self::throwInvalidArgumentException($e->getMessage(), $e->getTrace(), 0, $propertyPath, $e); + // It wasn't thrown in this class so rethrow it + throw $e; + } + } + + $propertyPath = $this->getPropertyPath($propertyPath); + + $zval = [ + self::VALUE => $objectOrArray, + self::REF => &$objectOrArray, + ]; + $propertyValues = $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength() - 1); + $overwrite = true; + + try { + for ($i = \count($propertyValues) - 1; 0 <= $i; --$i) { + $zval = $propertyValues[$i]; + unset($propertyValues[$i]); + + // You only need set value for current element if: + // 1. it's the parent of the last index element + // OR + // 2. its child is not passed by reference + // + // This may avoid unnecessary value setting process for array elements. + // For example: + // '[a][b][c]' => 'old-value' + // If you want to change its value to 'new-value', + // you only need set value for '[a][b][c]' and it's safe to ignore '[a][b]' and '[a]' + if ($overwrite) { + $property = $propertyPath->getElement($i); + + if ($propertyPath->isIndex($i)) { + if ($overwrite = !isset($zval[self::REF])) { + $ref = &$zval[self::REF]; + $ref = $zval[self::VALUE]; + } + $this->writeIndex($zval, $property, $value); + if ($overwrite) { + $zval[self::VALUE] = $zval[self::REF]; + } + } else { + $this->writeProperty($zval, $property, $value); + } + + // if current element is an object + // OR + // if current element's reference chain is not broken - current element + // as well as all its ancients in the property path are all passed by reference, + // then there is no need to continue the value setting process + if (\is_object($zval[self::VALUE]) || isset($zval[self::IS_REF_CHAINED])) { + break; + } + } + + $value = $zval[self::VALUE]; + } + } catch (\TypeError $e) { + self::throwInvalidArgumentException($e->getMessage(), $e->getTrace(), 0, $propertyPath, $e); + + // It wasn't thrown in this class so rethrow it + throw $e; + } + } + + private static function throwInvalidArgumentException(string $message, array $trace, int $i, string $propertyPath, ?\Throwable $previous = null): void + { + if (!isset($trace[$i]['file']) || __FILE__ !== $trace[$i]['file']) { + return; + } + if (preg_match('/^\S+::\S+\(\): Argument #\d+ \(\$\S+\) must be of type (\S+), (\S+) given/', $message, $matches)) { + [, $expectedType, $actualType] = $matches; + + throw new InvalidArgumentException(sprintf('Expected argument of type "%s", "%s" given at property path "%s".', $expectedType, 'NULL' === $actualType ? 'null' : $actualType, $propertyPath), 0, $previous); + } + if (preg_match('/^Cannot assign (\S+) to property \S+::\$\S+ of type (\S+)$/', $message, $matches)) { + [, $actualType, $expectedType] = $matches; + + throw new InvalidArgumentException(sprintf('Expected argument of type "%s", "%s" given at property path "%s".', $expectedType, 'NULL' === $actualType ? 'null' : $actualType, $propertyPath), 0, $previous); + } + } + + public function isReadable(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): bool + { + if (!$propertyPath instanceof PropertyPathInterface) { + $propertyPath = new PropertyPath($propertyPath); + } + + try { + $zval = [ + self::VALUE => $objectOrArray, + ]; + + // handle stdClass with properties with a dot in the name + if ($objectOrArray instanceof \stdClass && str_contains($propertyPath, '.') && property_exists($objectOrArray, $propertyPath)) { + $this->readProperty($zval, $propertyPath, $this->ignoreInvalidProperty); + } else { + $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength(), $this->ignoreInvalidIndices); + } + + return true; + } catch (AccessException) { + return false; + } catch (UnexpectedTypeException) { + return false; + } + } + + public function isWritable(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): bool + { + $propertyPath = $this->getPropertyPath($propertyPath); + + try { + $zval = [ + self::VALUE => $objectOrArray, + ]; + + // handle stdClass with properties with a dot in the name + if ($objectOrArray instanceof \stdClass && str_contains($propertyPath, '.') && property_exists($objectOrArray, $propertyPath)) { + $this->readProperty($zval, $propertyPath, $this->ignoreInvalidProperty); + + return true; + } + + $propertyValues = $this->readPropertiesUntil($zval, $propertyPath, $propertyPath->getLength() - 1); + + for ($i = \count($propertyValues) - 1; 0 <= $i; --$i) { + $zval = $propertyValues[$i]; + unset($propertyValues[$i]); + + if ($propertyPath->isIndex($i)) { + if (!$zval[self::VALUE] instanceof \ArrayAccess && !\is_array($zval[self::VALUE])) { + return false; + } + } elseif (!\is_object($zval[self::VALUE]) || !$this->isPropertyWritable($zval[self::VALUE], $propertyPath->getElement($i))) { + return false; + } + + if (\is_object($zval[self::VALUE])) { + return true; + } + } + + return true; + } catch (AccessException) { + return false; + } catch (UnexpectedTypeException) { + return false; + } + } + + /** + * Reads the path from an object up to a given path index. + * + * @throws UnexpectedTypeException if a value within the path is neither object nor array + * @throws NoSuchIndexException If a non-existing index is accessed + */ + private function readPropertiesUntil(array $zval, PropertyPathInterface $propertyPath, int $lastIndex, bool $ignoreInvalidIndices = true): array + { + if (!\is_object($zval[self::VALUE]) && !\is_array($zval[self::VALUE])) { + throw new UnexpectedTypeException($zval[self::VALUE], $propertyPath, 0); + } + + // Add the root object to the list + $propertyValues = [$zval]; + + for ($i = 0; $i < $lastIndex; ++$i) { + $property = $propertyPath->getElement($i); + $isIndex = $propertyPath->isIndex($i); + + $isNullSafe = false; + if (method_exists($propertyPath, 'isNullSafe')) { + // To be removed in symfony 7 once we are sure isNullSafe is always implemented. + $isNullSafe = $propertyPath->isNullSafe($i); + } else { + trigger_deprecation('symfony/property-access', '6.2', 'The "%s()" method in class "%s" needs to be implemented in version 7.0, not defining it is deprecated.', 'isNullSafe', PropertyPathInterface::class); + } + + if ($isIndex) { + // Create missing nested arrays on demand + if (($zval[self::VALUE] instanceof \ArrayAccess && !$zval[self::VALUE]->offsetExists($property)) + || (\is_array($zval[self::VALUE]) && !isset($zval[self::VALUE][$property]) && !\array_key_exists($property, $zval[self::VALUE])) + ) { + if (!$ignoreInvalidIndices && !$isNullSafe) { + if (!\is_array($zval[self::VALUE])) { + if (!$zval[self::VALUE] instanceof \Traversable) { + throw new NoSuchIndexException(sprintf('Cannot read index "%s" while trying to traverse path "%s".', $property, (string) $propertyPath)); + } + + $zval[self::VALUE] = iterator_to_array($zval[self::VALUE]); + } + + throw new NoSuchIndexException(sprintf('Cannot read index "%s" while trying to traverse path "%s". Available indices are "%s".', $property, (string) $propertyPath, print_r(array_keys($zval[self::VALUE]), true))); + } + + if ($i + 1 < $propertyPath->getLength()) { + if (isset($zval[self::REF])) { + $zval[self::VALUE][$property] = []; + $zval[self::REF] = $zval[self::VALUE]; + } else { + $zval[self::VALUE] = [$property => []]; + } + } + } + + $zval = $this->readIndex($zval, $property); + } elseif ($isNullSafe && !\is_object($zval[self::VALUE])) { + $zval[self::VALUE] = null; + } else { + $zval = $this->readProperty($zval, $property, $this->ignoreInvalidProperty, $isNullSafe); + } + + // the final value of the path must not be validated + if ($i + 1 < $propertyPath->getLength() && !\is_object($zval[self::VALUE]) && !\is_array($zval[self::VALUE]) && !$isNullSafe) { + throw new UnexpectedTypeException($zval[self::VALUE], $propertyPath, $i + 1); + } + + if (isset($zval[self::REF]) && (0 === $i || isset($propertyValues[$i - 1][self::IS_REF_CHAINED]))) { + // Set the IS_REF_CHAINED flag to true if: + // current property is passed by reference and + // it is the first element in the property path or + // the IS_REF_CHAINED flag of its parent element is true + // Basically, this flag is true only when the reference chain from the top element to current element is not broken + $zval[self::IS_REF_CHAINED] = true; + } + + $propertyValues[] = $zval; + + if ($isNullSafe && null === $zval[self::VALUE]) { + break; + } + } + + return $propertyValues; + } + + /** + * Reads a key from an array-like structure. + * + * @throws NoSuchIndexException If the array does not implement \ArrayAccess or it is not an array + */ + private function readIndex(array $zval, string|int $index): array + { + if (!$zval[self::VALUE] instanceof \ArrayAccess && !\is_array($zval[self::VALUE])) { + throw new NoSuchIndexException(sprintf('Cannot read index "%s" from object of type "%s" because it doesn\'t implement \ArrayAccess.', $index, get_debug_type($zval[self::VALUE]))); + } + + $result = self::RESULT_PROTO; + + if (isset($zval[self::VALUE][$index])) { + $result[self::VALUE] = $zval[self::VALUE][$index]; + + if (!isset($zval[self::REF])) { + // Save creating references when doing read-only lookups + } elseif (\is_array($zval[self::VALUE])) { + $result[self::REF] = &$zval[self::REF][$index]; + } elseif (\is_object($result[self::VALUE])) { + $result[self::REF] = $result[self::VALUE]; + } + } + + return $result; + } + + /** + * Reads the value of a property from an object. + * + * @throws NoSuchPropertyException If $ignoreInvalidProperty is false and the property does not exist or is not public + */ + private function readProperty(array $zval, string $property, bool $ignoreInvalidProperty = false, bool $isNullSafe = false): array + { + if (!\is_object($zval[self::VALUE])) { + throw new NoSuchPropertyException(sprintf('Cannot read property "%s" from an array. Maybe you intended to write the property path as "[%1$s]" instead.', $property)); + } + + $result = self::RESULT_PROTO; + $object = $zval[self::VALUE]; + $class = $object::class; + $access = $this->getReadInfo($class, $property); + + if (null !== $access) { + $name = $access->getName(); + $type = $access->getType(); + + try { + if (PropertyReadInfo::TYPE_METHOD === $type) { + try { + $result[self::VALUE] = $object->$name(); + } catch (\TypeError $e) { + [$trace] = $e->getTrace(); + + // handle uninitialized properties in PHP >= 7 + if (__FILE__ === ($trace['file'] ?? null) + && $name === $trace['function'] + && $object instanceof $trace['class'] + && preg_match('/Return value (?:of .*::\w+\(\) )?must be of (?:the )?type (\w+), null returned$/', $e->getMessage(), $matches) + ) { + throw new UninitializedPropertyException(sprintf('The method "%s::%s()" returned "null", but expected type "%3$s". Did you forget to initialize a property or to make the return type nullable using "?%3$s"?', get_debug_type($object), $name, $matches[1]), 0, $e); + } + + throw $e; + } + } elseif (PropertyReadInfo::TYPE_PROPERTY === $type) { + if (!isset($object->$name) && !\array_key_exists($name, (array) $object)) { + try { + $r = new \ReflectionProperty($class, $name); + + if ($r->isPublic() && !$r->hasType()) { + throw new UninitializedPropertyException(sprintf('The property "%s::$%s" is not initialized.', $class, $name)); + } + } catch (\ReflectionException $e) { + if (!$ignoreInvalidProperty) { + throw new NoSuchPropertyException(sprintf('Can\'t get a way to read the property "%s" in class "%s".', $property, $class)); + } + } + } + + $result[self::VALUE] = $object->$name; + + if (isset($zval[self::REF]) && $access->canBeReference()) { + $result[self::REF] = &$object->$name; + } + } + } catch (\Error $e) { + // handle uninitialized properties in PHP >= 7.4 + if (preg_match('/^Typed property ([\w\\\\@]+)::\$(\w+) must not be accessed before initialization$/', $e->getMessage(), $matches) || preg_match('/^Cannot access uninitialized non-nullable property ([\w\\\\@]+)::\$(\w+) by reference$/', $e->getMessage(), $matches)) { + $r = new \ReflectionProperty(str_contains($matches[1], '@anonymous') ? $class : $matches[1], $matches[2]); + $type = ($type = $r->getType()) instanceof \ReflectionNamedType ? $type->getName() : (string) $type; + + throw new UninitializedPropertyException(sprintf('The property "%s::$%s" is not readable because it is typed "%s". You should initialize it or declare a default value instead.', $matches[1], $r->getName(), $type), 0, $e); + } + + throw $e; + } + } elseif (property_exists($object, $property) && \array_key_exists($property, (array) $object)) { + $result[self::VALUE] = $object->$property; + if (isset($zval[self::REF])) { + $result[self::REF] = &$object->$property; + } + } elseif ($isNullSafe) { + $result[self::VALUE] = null; + } elseif (!$ignoreInvalidProperty) { + throw new NoSuchPropertyException(sprintf('Can\'t get a way to read the property "%s" in class "%s".', $property, $class)); + } + + // Objects are always passed around by reference + if (isset($zval[self::REF]) && \is_object($result[self::VALUE])) { + $result[self::REF] = $result[self::VALUE]; + } + + return $result; + } + + /** + * Guesses how to read the property value. + */ + private function getReadInfo(string $class, string $property): ?PropertyReadInfo + { + $key = str_replace('\\', '.', $class).'..'.$property; + + if (isset($this->readPropertyCache[$key])) { + return $this->readPropertyCache[$key]; + } + + if ($this->cacheItemPool) { + $item = $this->cacheItemPool->getItem(self::CACHE_PREFIX_READ.rawurlencode($key)); + if ($item->isHit()) { + return $this->readPropertyCache[$key] = $item->get(); + } + } + + $accessor = $this->readInfoExtractor->getReadInfo($class, $property, [ + 'enable_getter_setter_extraction' => true, + 'enable_magic_methods_extraction' => $this->magicMethodsFlags, + 'enable_constructor_extraction' => false, + ]); + + if (isset($item)) { + $this->cacheItemPool->save($item->set($accessor)); + } + + return $this->readPropertyCache[$key] = $accessor; + } + + /** + * Sets the value of an index in a given array-accessible value. + * + * @throws NoSuchIndexException If the array does not implement \ArrayAccess or it is not an array + */ + private function writeIndex(array $zval, string|int $index, mixed $value): void + { + if (!$zval[self::VALUE] instanceof \ArrayAccess && !\is_array($zval[self::VALUE])) { + throw new NoSuchIndexException(sprintf('Cannot modify index "%s" in object of type "%s" because it doesn\'t implement \ArrayAccess.', $index, get_debug_type($zval[self::VALUE]))); + } + + $zval[self::REF][$index] = $value; + } + + /** + * Sets the value of a property in the given object. + * + * @throws NoSuchPropertyException if the property does not exist or is not public + */ + private function writeProperty(array $zval, string $property, mixed $value, bool $recursive = false): void + { + if (!\is_object($zval[self::VALUE])) { + throw new NoSuchPropertyException(sprintf('Cannot write property "%s" to an array. Maybe you should write the property path as "[%1$s]" instead?', $property)); + } + + $object = $zval[self::VALUE]; + $class = $object::class; + $mutator = $this->getWriteInfo($class, $property, $value); + + try { + if (PropertyWriteInfo::TYPE_NONE !== $mutator->getType()) { + $type = $mutator->getType(); + + if (PropertyWriteInfo::TYPE_METHOD === $type) { + $object->{$mutator->getName()}($value); + } elseif (PropertyWriteInfo::TYPE_PROPERTY === $type) { + $object->{$mutator->getName()} = $value; + } elseif (PropertyWriteInfo::TYPE_ADDER_AND_REMOVER === $type) { + $this->writeCollection($zval, $property, $value, $mutator->getAdderInfo(), $mutator->getRemoverInfo()); + } + } elseif ($object instanceof \stdClass && property_exists($object, $property)) { + $object->$property = $value; + } elseif (!$this->ignoreInvalidProperty) { + if ($mutator->hasErrors()) { + throw new NoSuchPropertyException(implode('. ', $mutator->getErrors()).'.'); + } + + throw new NoSuchPropertyException(sprintf('Could not determine access type for property "%s" in class "%s".', $property, get_debug_type($object))); + } + } catch (\TypeError $e) { + if ($recursive || !$value instanceof \DateTimeInterface || !\in_array($value::class, ['DateTime', 'DateTimeImmutable'], true) || __FILE__ !== ($e->getTrace()[0]['file'] ?? null)) { + throw $e; + } + + $value = $value instanceof \DateTimeImmutable ? \DateTime::createFromImmutable($value) : \DateTimeImmutable::createFromMutable($value); + try { + $this->writeProperty($zval, $property, $value, true); + } catch (\TypeError) { + throw $e; // throw the previous error + } + } + } + + /** + * Adjusts a collection-valued property by calling add*() and remove*() methods. + */ + private function writeCollection(array $zval, string $property, iterable $collection, PropertyWriteInfo $addMethod, PropertyWriteInfo $removeMethod): void + { + // At this point the add and remove methods have been found + $previousValue = $this->readProperty($zval, $property); + $previousValue = $previousValue[self::VALUE]; + + $removeMethodName = $removeMethod->getName(); + $addMethodName = $addMethod->getName(); + + if ($previousValue instanceof \Traversable) { + $previousValue = iterator_to_array($previousValue); + } + if ($previousValue && \is_array($previousValue)) { + if (\is_object($collection)) { + $collection = iterator_to_array($collection); + } + foreach ($previousValue as $key => $item) { + if (!\in_array($item, $collection, true)) { + unset($previousValue[$key]); + $zval[self::VALUE]->$removeMethodName($item); + } + } + } else { + $previousValue = false; + } + + foreach ($collection as $item) { + if (!$previousValue || !\in_array($item, $previousValue, true)) { + $zval[self::VALUE]->$addMethodName($item); + } + } + } + + private function getWriteInfo(string $class, string $property, mixed $value): PropertyWriteInfo + { + $useAdderAndRemover = is_iterable($value); + $key = str_replace('\\', '.', $class).'..'.$property.'..'.(int) $useAdderAndRemover; + + if (isset($this->writePropertyCache[$key])) { + return $this->writePropertyCache[$key]; + } + + if ($this->cacheItemPool) { + $item = $this->cacheItemPool->getItem(self::CACHE_PREFIX_WRITE.rawurlencode($key)); + if ($item->isHit()) { + return $this->writePropertyCache[$key] = $item->get(); + } + } + + $mutator = $this->writeInfoExtractor->getWriteInfo($class, $property, [ + 'enable_getter_setter_extraction' => true, + 'enable_magic_methods_extraction' => $this->magicMethodsFlags, + 'enable_constructor_extraction' => false, + 'enable_adder_remover_extraction' => $useAdderAndRemover, + ]); + + if (isset($item)) { + $this->cacheItemPool->save($item->set($mutator)); + } + + return $this->writePropertyCache[$key] = $mutator; + } + + /** + * Returns whether a property is writable in the given object. + */ + private function isPropertyWritable(object $object, string $property): bool + { + if ($object instanceof \stdClass && property_exists($object, $property)) { + return true; + } + + $mutatorForArray = $this->getWriteInfo($object::class, $property, []); + if (PropertyWriteInfo::TYPE_PROPERTY === $mutatorForArray->getType()) { + return $mutatorForArray->getVisibility() === 'public'; + } + + if (PropertyWriteInfo::TYPE_NONE !== $mutatorForArray->getType()) { + return true; + } + + $mutator = $this->getWriteInfo($object::class, $property, ''); + + return PropertyWriteInfo::TYPE_NONE !== $mutator->getType(); + } + + /** + * Gets a PropertyPath instance and caches it. + */ + private function getPropertyPath(string|PropertyPath $propertyPath): PropertyPath + { + if ($propertyPath instanceof PropertyPathInterface) { + // Don't call the copy constructor has it is not needed here + return $propertyPath; + } + + if (isset($this->propertyPathCache[$propertyPath])) { + return $this->propertyPathCache[$propertyPath]; + } + + if ($this->cacheItemPool) { + $item = $this->cacheItemPool->getItem(self::CACHE_PREFIX_PROPERTY_PATH.rawurlencode($propertyPath)); + if ($item->isHit()) { + return $this->propertyPathCache[$propertyPath] = $item->get(); + } + } + + $propertyPathInstance = new PropertyPath($propertyPath); + if (isset($item)) { + $item->set($propertyPathInstance); + $this->cacheItemPool->save($item); + } + + return $this->propertyPathCache[$propertyPath] = $propertyPathInstance; + } + + /** + * Creates the APCu adapter if applicable. + * + * @throws \LogicException When the Cache Component isn't available + */ + public static function createCache(string $namespace, int $defaultLifetime, string $version, ?LoggerInterface $logger = null): AdapterInterface + { + if (!class_exists(ApcuAdapter::class)) { + throw new \LogicException(sprintf('The Symfony Cache component must be installed to use "%s()".', __METHOD__)); + } + + if (!ApcuAdapter::isSupported()) { + return new NullAdapter(); + } + + $apcu = new ApcuAdapter($namespace, $defaultLifetime / 5, $version); + if ('cli' === \PHP_SAPI && !filter_var(\ini_get('apc.enable_cli'), \FILTER_VALIDATE_BOOL)) { + $apcu->setLogger(new NullLogger()); + } elseif (null !== $logger) { + $apcu->setLogger($logger); + } + + return $apcu; + } +} diff --git a/lib/symfony/property-access/PropertyAccessorBuilder.php b/lib/symfony/property-access/PropertyAccessorBuilder.php new file mode 100644 index 000000000..5dc6e4ff1 --- /dev/null +++ b/lib/symfony/property-access/PropertyAccessorBuilder.php @@ -0,0 +1,294 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyAccess; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; + +/** + * A configurable builder to create a PropertyAccessor. + * + * @author Jérémie Augustin + */ +class PropertyAccessorBuilder +{ + private int $magicMethods = PropertyAccessor::MAGIC_GET | PropertyAccessor::MAGIC_SET; + private bool $throwExceptionOnInvalidIndex = false; + private bool $throwExceptionOnInvalidPropertyPath = true; + private ?CacheItemPoolInterface $cacheItemPool = null; + private ?PropertyReadInfoExtractorInterface $readInfoExtractor = null; + private ?PropertyWriteInfoExtractorInterface $writeInfoExtractor = null; + + /** + * Enables the use of all magic methods by the PropertyAccessor. + * + * @return $this + */ + public function enableMagicMethods(): static + { + $this->magicMethods = PropertyAccessor::MAGIC_GET | PropertyAccessor::MAGIC_SET | PropertyAccessor::MAGIC_CALL; + + return $this; + } + + /** + * Disable the use of all magic methods by the PropertyAccessor. + * + * @return $this + */ + public function disableMagicMethods(): static + { + $this->magicMethods = PropertyAccessor::DISALLOW_MAGIC_METHODS; + + return $this; + } + + /** + * Enables the use of "__call" by the PropertyAccessor. + * + * @return $this + */ + public function enableMagicCall(): static + { + $this->magicMethods |= PropertyAccessor::MAGIC_CALL; + + return $this; + } + + /** + * Enables the use of "__get" by the PropertyAccessor. + */ + public function enableMagicGet(): self + { + $this->magicMethods |= PropertyAccessor::MAGIC_GET; + + return $this; + } + + /** + * Enables the use of "__set" by the PropertyAccessor. + * + * @return $this + */ + public function enableMagicSet(): static + { + $this->magicMethods |= PropertyAccessor::MAGIC_SET; + + return $this; + } + + /** + * Disables the use of "__call" by the PropertyAccessor. + * + * @return $this + */ + public function disableMagicCall(): static + { + $this->magicMethods &= ~PropertyAccessor::MAGIC_CALL; + + return $this; + } + + /** + * Disables the use of "__get" by the PropertyAccessor. + * + * @return $this + */ + public function disableMagicGet(): static + { + $this->magicMethods &= ~PropertyAccessor::MAGIC_GET; + + return $this; + } + + /** + * Disables the use of "__set" by the PropertyAccessor. + * + * @return $this + */ + public function disableMagicSet(): static + { + $this->magicMethods &= ~PropertyAccessor::MAGIC_SET; + + return $this; + } + + /** + * @return bool whether the use of "__call" by the PropertyAccessor is enabled + */ + public function isMagicCallEnabled(): bool + { + return (bool) ($this->magicMethods & PropertyAccessor::MAGIC_CALL); + } + + /** + * @return bool whether the use of "__get" by the PropertyAccessor is enabled + */ + public function isMagicGetEnabled(): bool + { + return $this->magicMethods & PropertyAccessor::MAGIC_GET; + } + + /** + * @return bool whether the use of "__set" by the PropertyAccessor is enabled + */ + public function isMagicSetEnabled(): bool + { + return $this->magicMethods & PropertyAccessor::MAGIC_SET; + } + + /** + * Enables exceptions when reading a non-existing index. + * + * This has no influence on writing non-existing indices with PropertyAccessorInterface::setValue() + * which are always created on-the-fly. + * + * @return $this + */ + public function enableExceptionOnInvalidIndex(): static + { + $this->throwExceptionOnInvalidIndex = true; + + return $this; + } + + /** + * Disables exceptions when reading a non-existing index. + * + * Instead, null is returned when calling PropertyAccessorInterface::getValue() on a non-existing index. + * + * @return $this + */ + public function disableExceptionOnInvalidIndex(): static + { + $this->throwExceptionOnInvalidIndex = false; + + return $this; + } + + /** + * @return bool whether an exception is thrown or null is returned when reading a non-existing index + */ + public function isExceptionOnInvalidIndexEnabled(): bool + { + return $this->throwExceptionOnInvalidIndex; + } + + /** + * Enables exceptions when reading a non-existing property. + * + * This has no influence on writing non-existing indices with PropertyAccessorInterface::setValue() + * which are always created on-the-fly. + * + * @return $this + */ + public function enableExceptionOnInvalidPropertyPath(): static + { + $this->throwExceptionOnInvalidPropertyPath = true; + + return $this; + } + + /** + * Disables exceptions when reading a non-existing index. + * + * Instead, null is returned when calling PropertyAccessorInterface::getValue() on a non-existing index. + * + * @return $this + */ + public function disableExceptionOnInvalidPropertyPath(): static + { + $this->throwExceptionOnInvalidPropertyPath = false; + + return $this; + } + + /** + * @return bool whether an exception is thrown or null is returned when reading a non-existing property + */ + public function isExceptionOnInvalidPropertyPath(): bool + { + return $this->throwExceptionOnInvalidPropertyPath; + } + + /** + * Sets a cache system. + * + * @return $this + */ + public function setCacheItemPool(?CacheItemPoolInterface $cacheItemPool = null): static + { + if (1 > \func_num_args()) { + trigger_deprecation('symfony/property-access', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); + } + $this->cacheItemPool = $cacheItemPool; + + return $this; + } + + /** + * Gets the used cache system. + */ + public function getCacheItemPool(): ?CacheItemPoolInterface + { + return $this->cacheItemPool; + } + + /** + * @return $this + */ + public function setReadInfoExtractor(?PropertyReadInfoExtractorInterface $readInfoExtractor): static + { + $this->readInfoExtractor = $readInfoExtractor; + + return $this; + } + + public function getReadInfoExtractor(): ?PropertyReadInfoExtractorInterface + { + return $this->readInfoExtractor; + } + + /** + * @return $this + */ + public function setWriteInfoExtractor(?PropertyWriteInfoExtractorInterface $writeInfoExtractor): static + { + $this->writeInfoExtractor = $writeInfoExtractor; + + return $this; + } + + public function getWriteInfoExtractor(): ?PropertyWriteInfoExtractorInterface + { + return $this->writeInfoExtractor; + } + + /** + * Builds and returns a new PropertyAccessor object. + */ + public function getPropertyAccessor(): PropertyAccessorInterface + { + $throw = PropertyAccessor::DO_NOT_THROW; + + if ($this->throwExceptionOnInvalidIndex) { + $throw |= PropertyAccessor::THROW_ON_INVALID_INDEX; + } + + if ($this->throwExceptionOnInvalidPropertyPath) { + $throw |= PropertyAccessor::THROW_ON_INVALID_PROPERTY_PATH; + } + + return new PropertyAccessor($this->magicMethods, $throw, $this->cacheItemPool, $this->readInfoExtractor, $this->writeInfoExtractor); + } +} diff --git a/lib/symfony/property-access/PropertyAccessorInterface.php b/lib/symfony/property-access/PropertyAccessorInterface.php new file mode 100644 index 000000000..c1947beea --- /dev/null +++ b/lib/symfony/property-access/PropertyAccessorInterface.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyAccess; + +/** + * Writes and reads values to/from an object/array graph. + * + * @author Bernhard Schussek + */ +interface PropertyAccessorInterface +{ + /** + * Sets the value at the end of the property path of the object graph. + * + * Example: + * + * use Symfony\Component\PropertyAccess\PropertyAccess; + * + * $propertyAccessor = PropertyAccess::createPropertyAccessor(); + * + * echo $propertyAccessor->setValue($object, 'child.name', 'Fabien'); + * // equals echo $object->getChild()->setName('Fabien'); + * + * This method first tries to find a public setter for each property in the + * path. The name of the setter must be the camel-cased property name + * prefixed with "set". + * + * If the setter does not exist, this method tries to find a public + * property. The value of the property is then changed. + * + * If neither is found, an exception is thrown. + * + * @return void + * + * @throws Exception\InvalidArgumentException If the property path is invalid + * @throws Exception\AccessException If a property/index does not exist or is not public + * @throws Exception\UnexpectedTypeException If a value within the path is neither object nor array + */ + public function setValue(object|array &$objectOrArray, string|PropertyPathInterface $propertyPath, mixed $value); + + /** + * Returns the value at the end of the property path of the object graph. + * + * Example: + * + * use Symfony\Component\PropertyAccess\PropertyAccess; + * + * $propertyAccessor = PropertyAccess::createPropertyAccessor(); + * + * echo $propertyAccessor->getValue($object, 'child.name'); + * // equals echo $object->getChild()->getName(); + * + * This method first tries to find a public getter for each property in the + * path. The name of the getter must be the camel-cased property name + * prefixed with "get", "is", or "has". + * + * If the getter does not exist, this method tries to find a public + * property. The value of the property is then returned. + * + * If none of them are found, an exception is thrown. + * + * @throws Exception\InvalidArgumentException If the property path is invalid + * @throws Exception\AccessException If a property/index does not exist or is not public + * @throws Exception\UnexpectedTypeException If a value within the path is neither object + * nor array + */ + public function getValue(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): mixed; + + /** + * Returns whether a value can be written at a given property path. + * + * Whenever this method returns true, {@link setValue()} is guaranteed not + * to throw an exception when called with the same arguments. + * + * @throws Exception\InvalidArgumentException If the property path is invalid + */ + public function isWritable(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): bool; + + /** + * Returns whether a property path can be read from an object graph. + * + * Whenever this method returns true, {@link getValue()} is guaranteed not + * to throw an exception when called with the same arguments. + * + * @throws Exception\InvalidArgumentException If the property path is invalid + */ + public function isReadable(object|array $objectOrArray, string|PropertyPathInterface $propertyPath): bool; +} diff --git a/lib/symfony/property-access/PropertyPath.php b/lib/symfony/property-access/PropertyPath.php new file mode 100644 index 000000000..a94e960ec --- /dev/null +++ b/lib/symfony/property-access/PropertyPath.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyAccess; + +use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException; +use Symfony\Component\PropertyAccess\Exception\InvalidPropertyPathException; +use Symfony\Component\PropertyAccess\Exception\OutOfBoundsException; + +/** + * Default implementation of {@link PropertyPathInterface}. + * + * @author Bernhard Schussek + * + * @implements \IteratorAggregate + */ +class PropertyPath implements \IteratorAggregate, PropertyPathInterface +{ + /** + * Character used for separating between plural and singular of an element. + */ + public const SINGULAR_SEPARATOR = '|'; + + /** + * The elements of the property path. + * + * @var list + */ + private array $elements = []; + + /** + * The number of elements in the property path. + */ + private int $length; + + /** + * Contains a Boolean for each property in $elements denoting whether this + * element is an index. It is a property otherwise. + * + * @var array + */ + private array $isIndex = []; + + /** + * Contains a Boolean for each property in $elements denoting whether this + * element is optional or not. + * + * @var array + */ + private array $isNullSafe = []; + + /** + * String representation of the path. + */ + private string $pathAsString; + + /** + * Constructs a property path from a string. + * + * @throws InvalidArgumentException If the given path is not a string + * @throws InvalidPropertyPathException If the syntax of the property path is not valid + */ + public function __construct(self|string $propertyPath) + { + // Can be used as copy constructor + if ($propertyPath instanceof self) { + /* @var PropertyPath $propertyPath */ + $this->elements = $propertyPath->elements; + $this->length = $propertyPath->length; + $this->isIndex = $propertyPath->isIndex; + $this->isNullSafe = $propertyPath->isNullSafe; + $this->pathAsString = $propertyPath->pathAsString; + + return; + } + + if ('' === $propertyPath) { + throw new InvalidPropertyPathException('The property path should not be empty.'); + } + + $this->pathAsString = $propertyPath; + $position = 0; + $remaining = $propertyPath; + + // first element is evaluated differently - no leading dot for properties + $pattern = '/^(((?:[^\\\\.\[]|\\\\.)++)|\[([^\]]++)\])(.*)/'; + + while (preg_match($pattern, $remaining, $matches)) { + if ('' !== $matches[2]) { + $element = $matches[2]; + $this->isIndex[] = false; + } else { + $element = $matches[3]; + $this->isIndex[] = true; + } + + // Mark as optional when last character is "?". + if (str_ends_with($element, '?')) { + $this->isNullSafe[] = true; + $element = substr($element, 0, -1); + } else { + $this->isNullSafe[] = false; + } + + $element = preg_replace('/\\\([.[])/', '$1', $element); + if (str_ends_with($element, '\\\\')) { + $element = substr($element, 0, -1); + } + $this->elements[] = $element; + + $position += \strlen($matches[1]); + $remaining = $matches[4]; + $pattern = '/^(\.((?:[^\\\\.\[]|\\\\.)++)|\[([^\]]++)\])(.*)/'; + } + + if ('' !== $remaining) { + throw new InvalidPropertyPathException(sprintf('Could not parse property path "%s". Unexpected token "%s" at position %d.', $propertyPath, $remaining[0], $position)); + } + + $this->length = \count($this->elements); + } + + public function __toString(): string + { + return $this->pathAsString; + } + + public function getLength(): int + { + return $this->length; + } + + public function getParent(): ?PropertyPathInterface + { + if ($this->length <= 1) { + return null; + } + + $parent = clone $this; + + --$parent->length; + $parent->pathAsString = substr($parent->pathAsString, 0, max(strrpos($parent->pathAsString, '.'), strrpos($parent->pathAsString, '['))); + array_pop($parent->elements); + array_pop($parent->isIndex); + array_pop($parent->isNullSafe); + + return $parent; + } + + /** + * Returns a new iterator for this path. + */ + public function getIterator(): PropertyPathIteratorInterface + { + return new PropertyPathIterator($this); + } + + public function getElements(): array + { + return $this->elements; + } + + public function getElement(int $index): string + { + if (!isset($this->elements[$index])) { + throw new OutOfBoundsException(sprintf('The index "%s" is not within the property path.', $index)); + } + + return $this->elements[$index]; + } + + public function isProperty(int $index): bool + { + if (!isset($this->isIndex[$index])) { + throw new OutOfBoundsException(sprintf('The index "%s" is not within the property path.', $index)); + } + + return !$this->isIndex[$index]; + } + + public function isIndex(int $index): bool + { + if (!isset($this->isIndex[$index])) { + throw new OutOfBoundsException(sprintf('The index "%s" is not within the property path.', $index)); + } + + return $this->isIndex[$index]; + } + + public function isNullSafe(int $index): bool + { + if (!isset($this->isNullSafe[$index])) { + throw new OutOfBoundsException(sprintf('The index "%s" is not within the property path.', $index)); + } + + return $this->isNullSafe[$index]; + } +} diff --git a/lib/symfony/property-access/PropertyPathBuilder.php b/lib/symfony/property-access/PropertyPathBuilder.php new file mode 100644 index 000000000..e2ac3fe08 --- /dev/null +++ b/lib/symfony/property-access/PropertyPathBuilder.php @@ -0,0 +1,275 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyAccess; + +use Symfony\Component\PropertyAccess\Exception\OutOfBoundsException; + +/** + * @author Bernhard Schussek + */ +class PropertyPathBuilder +{ + private array $elements = []; + private array $isIndex = []; + + public function __construct(PropertyPathInterface|string|null $path = null) + { + if (null !== $path) { + $this->append($path); + } + } + + /** + * Appends a (sub-) path to the current path. + * + * @param int $offset The offset where the appended piece starts in $path + * @param int $length The length of the appended piece; if 0, the full path is appended + * + * @return void + */ + public function append(PropertyPathInterface|string $path, int $offset = 0, int $length = 0) + { + if (\is_string($path)) { + $path = new PropertyPath($path); + } + + if (0 === $length) { + $end = $path->getLength(); + } else { + $end = $offset + $length; + } + + for (; $offset < $end; ++$offset) { + $this->elements[] = $path->getElement($offset); + $this->isIndex[] = $path->isIndex($offset); + } + } + + /** + * Appends an index element to the current path. + * + * @return void + */ + public function appendIndex(string $name) + { + $this->elements[] = $name; + $this->isIndex[] = true; + } + + /** + * Appends a property element to the current path. + * + * @return void + */ + public function appendProperty(string $name) + { + $this->elements[] = $name; + $this->isIndex[] = false; + } + + /** + * Removes elements from the current path. + * + * @return void + * + * @throws OutOfBoundsException if offset is invalid + */ + public function remove(int $offset, int $length = 1) + { + if (!isset($this->elements[$offset])) { + throw new OutOfBoundsException(sprintf('The offset "%s" is not within the property path.', $offset)); + } + + $this->resize($offset, $length, 0); + } + + /** + * Replaces a sub-path by a different (sub-) path. + * + * @param int $pathOffset The offset where the inserted piece starts in $path + * @param int $pathLength The length of the inserted piece; if 0, the full path is inserted + * + * @return void + * + * @throws OutOfBoundsException If the offset is invalid + */ + public function replace(int $offset, int $length, PropertyPathInterface|string $path, int $pathOffset = 0, int $pathLength = 0) + { + if (\is_string($path)) { + $path = new PropertyPath($path); + } + + if ($offset < 0 && abs($offset) <= $this->getLength()) { + $offset = $this->getLength() + $offset; + } elseif (!isset($this->elements[$offset])) { + throw new OutOfBoundsException('The offset '.$offset.' is not within the property path'); + } + + if (0 === $pathLength) { + $pathLength = $path->getLength() - $pathOffset; + } + + $this->resize($offset, $length, $pathLength); + + for ($i = 0; $i < $pathLength; ++$i) { + $this->elements[$offset + $i] = $path->getElement($pathOffset + $i); + $this->isIndex[$offset + $i] = $path->isIndex($pathOffset + $i); + } + ksort($this->elements); + } + + /** + * Replaces a property element by an index element. + * + * @return void + * + * @throws OutOfBoundsException If the offset is invalid + */ + public function replaceByIndex(int $offset, ?string $name = null) + { + if (!isset($this->elements[$offset])) { + throw new OutOfBoundsException(sprintf('The offset "%s" is not within the property path.', $offset)); + } + + if (null !== $name) { + $this->elements[$offset] = $name; + } + + $this->isIndex[$offset] = true; + } + + /** + * Replaces an index element by a property element. + * + * @return void + * + * @throws OutOfBoundsException If the offset is invalid + */ + public function replaceByProperty(int $offset, ?string $name = null) + { + if (!isset($this->elements[$offset])) { + throw new OutOfBoundsException(sprintf('The offset "%s" is not within the property path.', $offset)); + } + + if (null !== $name) { + $this->elements[$offset] = $name; + } + + $this->isIndex[$offset] = false; + } + + /** + * Returns the length of the current path. + */ + public function getLength(): int + { + return \count($this->elements); + } + + /** + * Returns the current property path. + */ + public function getPropertyPath(): ?PropertyPathInterface + { + $pathAsString = $this->__toString(); + + return '' !== $pathAsString ? new PropertyPath($pathAsString) : null; + } + + /** + * Returns the current property path as string. + */ + public function __toString(): string + { + $string = ''; + + foreach ($this->elements as $offset => $element) { + if ($this->isIndex[$offset]) { + $element = '['.$element.']'; + } elseif ('' !== $string) { + $string .= '.'; + } + + $string .= $element; + } + + return $string; + } + + /** + * Resizes the path so that a chunk of length $cutLength is + * removed at $offset and another chunk of length $insertionLength + * can be inserted. + */ + private function resize(int $offset, int $cutLength, int $insertionLength): void + { + // Nothing else to do in this case + if ($insertionLength === $cutLength) { + return; + } + + $length = \count($this->elements); + + if ($cutLength > $insertionLength) { + // More elements should be removed than inserted + $diff = $cutLength - $insertionLength; + $newLength = $length - $diff; + + // Shift elements to the left (left-to-right until the new end) + // Max allowed offset to be shifted is such that + // $offset + $diff < $length (otherwise invalid index access) + // i.e. $offset < $length - $diff = $newLength + for ($i = $offset; $i < $newLength; ++$i) { + $this->elements[$i] = $this->elements[$i + $diff]; + $this->isIndex[$i] = $this->isIndex[$i + $diff]; + } + + // All remaining elements should be removed + $this->elements = \array_slice($this->elements, 0, $i); + $this->isIndex = \array_slice($this->isIndex, 0, $i); + } else { + $diff = $insertionLength - $cutLength; + + $newLength = $length + $diff; + $indexAfterInsertion = $offset + $insertionLength; + + // $diff <= $insertionLength + // $indexAfterInsertion >= $insertionLength + // => $diff <= $indexAfterInsertion + + // In each of the following loops, $i >= $diff must hold, + // otherwise ($i - $diff) becomes negative. + + // Shift old elements to the right to make up space for the + // inserted elements. This needs to be done left-to-right in + // order to preserve an ascending array index order + // Since $i = max($length, $indexAfterInsertion) and $indexAfterInsertion >= $diff, + // $i >= $diff is guaranteed. + for ($i = max($length, $indexAfterInsertion); $i < $newLength; ++$i) { + $this->elements[$i] = $this->elements[$i - $diff]; + $this->isIndex[$i] = $this->isIndex[$i - $diff]; + } + + // Shift remaining elements to the right. Do this right-to-left + // so we don't overwrite elements before copying them + // The last written index is the immediate index after the inserted + // string, because the indices before that will be overwritten + // anyway. + // Since $i >= $indexAfterInsertion and $indexAfterInsertion >= $diff, + // $i >= $diff is guaranteed. + for ($i = $length - 1; $i >= $indexAfterInsertion; --$i) { + $this->elements[$i] = $this->elements[$i - $diff]; + $this->isIndex[$i] = $this->isIndex[$i - $diff]; + } + } + } +} diff --git a/lib/symfony/property-access/PropertyPathInterface.php b/lib/symfony/property-access/PropertyPathInterface.php new file mode 100644 index 000000000..324cbe5c4 --- /dev/null +++ b/lib/symfony/property-access/PropertyPathInterface.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyAccess; + +/** + * A sequence of property names or array indices. + * + * @author Bernhard Schussek + * + * @method bool isNullSafe(int $index) Returns whether the element at the given index is null safe. Not implementing it is deprecated since Symfony 6.2 + * + * @extends \Traversable + */ +interface PropertyPathInterface extends \Traversable, \Stringable +{ + /** + * Returns the string representation of the property path. + */ + public function __toString(): string; + + /** + * Returns the length of the property path, i.e. the number of elements. + * + * @return int + */ + public function getLength(); + + /** + * Returns the parent property path. + * + * The parent property path is the one that contains the same items as + * this one except for the last one. + * + * If this property path only contains one item, null is returned. + * + * @return self|null + */ + public function getParent(); + + /** + * Returns the elements of the property path as array. + * + * @return list + */ + public function getElements(); + + /** + * Returns the element at the given index in the property path. + * + * @param int $index The index key + * + * @return string + * + * @throws Exception\OutOfBoundsException If the offset is invalid + */ + public function getElement(int $index); + + /** + * Returns whether the element at the given index is a property. + * + * @param int $index The index in the property path + * + * @return bool + * + * @throws Exception\OutOfBoundsException If the offset is invalid + */ + public function isProperty(int $index); + + /** + * Returns whether the element at the given index is an array index. + * + * @param int $index The index in the property path + * + * @return bool + * + * @throws Exception\OutOfBoundsException If the offset is invalid + */ + public function isIndex(int $index); +} diff --git a/lib/symfony/property-access/PropertyPathIterator.php b/lib/symfony/property-access/PropertyPathIterator.php new file mode 100644 index 000000000..0312ba156 --- /dev/null +++ b/lib/symfony/property-access/PropertyPathIterator.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyAccess; + +/** + * Traverses a property path and provides additional methods to find out + * information about the current element. + * + * @author Bernhard Schussek + * + * @extends \ArrayIterator + */ +class PropertyPathIterator extends \ArrayIterator implements PropertyPathIteratorInterface +{ + protected $path; + + public function __construct(PropertyPathInterface $path) + { + parent::__construct($path->getElements()); + + $this->path = $path; + } + + public function isIndex(): bool + { + return $this->path->isIndex($this->key()); + } + + public function isProperty(): bool + { + return $this->path->isProperty($this->key()); + } +} diff --git a/lib/symfony/property-access/PropertyPathIteratorInterface.php b/lib/symfony/property-access/PropertyPathIteratorInterface.php new file mode 100644 index 000000000..4704b36ab --- /dev/null +++ b/lib/symfony/property-access/PropertyPathIteratorInterface.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\PropertyAccess; + +/** + * @author Bernhard Schussek + * + * @extends \SeekableIterator + */ +interface PropertyPathIteratorInterface extends \SeekableIterator +{ + /** + * Returns whether the current element in the property path is an array + * index. + */ + public function isIndex(): bool; + + /** + * Returns whether the current element in the property path is a property + * name. + */ + public function isProperty(): bool; +} diff --git a/lib/symfony/property-access/README.md b/lib/symfony/property-access/README.md new file mode 100644 index 000000000..29cb233a0 --- /dev/null +++ b/lib/symfony/property-access/README.md @@ -0,0 +1,14 @@ +PropertyAccess Component +======================== + +The PropertyAccess component provides functions to read and write from/to an +object or array using a simple string notation. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/property_access.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/lib/symfony/property-access/composer.json b/lib/symfony/property-access/composer.json new file mode 100644 index 000000000..ce7710cfe --- /dev/null +++ b/lib/symfony/property-access/composer.json @@ -0,0 +1,33 @@ +{ + "name": "symfony/property-access", + "type": "library", + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "keywords": ["property", "index", "access", "object", "array", "extraction", "injection", "reflection", "property-path"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/property-info": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "symfony/cache": "^5.4|^6.0|^7.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\PropertyAccess\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/lib/symfony/property-info/CHANGELOG.md b/lib/symfony/property-info/CHANGELOG.md new file mode 100644 index 000000000..ce7f220ce --- /dev/null +++ b/lib/symfony/property-info/CHANGELOG.md @@ -0,0 +1,57 @@ +CHANGELOG +========= + +6.4 +--- + + * Make properties writable when a setter in camelCase exists, similar to the camelCase getter + +6.1 +--- + + * Add support for phpDocumentor and PHPStan pseudo-types + * Add PHP 8.0 promoted properties `@param` mutation support to `PhpDocExtractor` + * Add PHP 8.0 promoted properties `@param` mutation support to `PhpStanExtractor` + +6.0 +--- + + * Remove the `Type::getCollectionKeyType()` and `Type::getCollectionValueType()` methods, use `Type::getCollectionKeyTypes()` and `Type::getCollectionValueTypes()` instead + * Remove the `enable_magic_call_extraction` context option in `ReflectionExtractor::getWriteInfo()` and `ReflectionExtractor::getReadInfo()` in favor of `enable_magic_methods_extraction` + +5.4 +--- + + * Add PhpStanExtractor + +5.3 +--- + + * Add support for multiple types for collection keys & values + * Deprecate the `Type::getCollectionKeyType()` and `Type::getCollectionValueType()` methods, use `Type::getCollectionKeyTypes()` and `Type::getCollectionValueTypes()` instead + +5.2.0 +----- + + * deprecated the `enable_magic_call_extraction` context option in `ReflectionExtractor::getWriteInfo()` and `ReflectionExtractor::getReadInfo()` in favor of `enable_magic_methods_extraction` + +5.1.0 +----- + + * Add support for extracting accessor and mutator via PHP Reflection + +4.3.0 +----- + + * Added the ability to extract private and protected properties and methods on `ReflectionExtractor` + * Added the ability to extract property type based on its initial value + +4.2.0 +----- + + * added `PropertyInitializableExtractorInterface` to test if a property can be initialized through the constructor (implemented by `ReflectionExtractor`) + +3.3.0 +----- + + * Added `PropertyInfoPass` diff --git a/lib/symfony/property-info/DependencyInjection/PropertyInfoConstructorPass.php b/lib/symfony/property-info/DependencyInjection/PropertyInfoConstructorPass.php new file mode 100644 index 000000000..6c775384d --- /dev/null +++ b/lib/symfony/property-info/DependencyInjection/PropertyInfoConstructorPass.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\DependencyInjection; + +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Adds extractors to the property_info.constructor_extractor service. + * + * @author Dmitrii Poddubnyi + */ +final class PropertyInfoConstructorPass implements CompilerPassInterface +{ + use PriorityTaggedServiceTrait; + + public function process(ContainerBuilder $container): void + { + if (!$container->hasDefinition('property_info.constructor_extractor')) { + return; + } + $definition = $container->getDefinition('property_info.constructor_extractor'); + + $listExtractors = $this->findAndSortTaggedServices('property_info.constructor_extractor', $container); + $definition->replaceArgument(0, new IteratorArgument($listExtractors)); + } +} diff --git a/lib/symfony/property-info/DependencyInjection/PropertyInfoPass.php b/lib/symfony/property-info/DependencyInjection/PropertyInfoPass.php new file mode 100644 index 000000000..1c240b43d --- /dev/null +++ b/lib/symfony/property-info/DependencyInjection/PropertyInfoPass.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\PropertyInfo\DependencyInjection; + +use Symfony\Component\DependencyInjection\Argument\IteratorArgument; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Adds extractors to the property_info service. + * + * @author Kévin Dunglas + */ +class PropertyInfoPass implements CompilerPassInterface +{ + use PriorityTaggedServiceTrait; + + /** + * @return void + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition('property_info')) { + return; + } + + $definition = $container->getDefinition('property_info'); + + $listExtractors = $this->findAndSortTaggedServices('property_info.list_extractor', $container); + $definition->replaceArgument(0, new IteratorArgument($listExtractors)); + + $typeExtractors = $this->findAndSortTaggedServices('property_info.type_extractor', $container); + $definition->replaceArgument(1, new IteratorArgument($typeExtractors)); + + $descriptionExtractors = $this->findAndSortTaggedServices('property_info.description_extractor', $container); + $definition->replaceArgument(2, new IteratorArgument($descriptionExtractors)); + + $accessExtractors = $this->findAndSortTaggedServices('property_info.access_extractor', $container); + $definition->replaceArgument(3, new IteratorArgument($accessExtractors)); + + $initializableExtractors = $this->findAndSortTaggedServices('property_info.initializable_extractor', $container); + $definition->setArgument(4, new IteratorArgument($initializableExtractors)); + } +} diff --git a/lib/symfony/property-info/Extractor/ConstructorArgumentTypeExtractorInterface.php b/lib/symfony/property-info/Extractor/ConstructorArgumentTypeExtractorInterface.php new file mode 100644 index 000000000..cbde902e9 --- /dev/null +++ b/lib/symfony/property-info/Extractor/ConstructorArgumentTypeExtractorInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Extractor; + +use Symfony\Component\PropertyInfo\Type; + +/** + * Infers the constructor argument type. + * + * @author Dmitrii Poddubnyi + * + * @internal + */ +interface ConstructorArgumentTypeExtractorInterface +{ + /** + * Gets types of an argument from constructor. + * + * @return Type[]|null + * + * @internal + */ + public function getTypesFromConstructor(string $class, string $property): ?array; +} diff --git a/lib/symfony/property-info/Extractor/ConstructorExtractor.php b/lib/symfony/property-info/Extractor/ConstructorExtractor.php new file mode 100644 index 000000000..18e563a71 --- /dev/null +++ b/lib/symfony/property-info/Extractor/ConstructorExtractor.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Extractor; + +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; + +/** + * Extracts the constructor argument type using ConstructorArgumentTypeExtractorInterface implementations. + * + * @author Dmitrii Poddubnyi + */ +final class ConstructorExtractor implements PropertyTypeExtractorInterface +{ + /** + * @param iterable $extractors + */ + public function __construct( + private readonly iterable $extractors = [], + ) { + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + foreach ($this->extractors as $extractor) { + $value = $extractor->getTypesFromConstructor($class, $property); + if (null !== $value) { + return $value; + } + } + + return null; + } +} diff --git a/lib/symfony/property-info/Extractor/PhpDocExtractor.php b/lib/symfony/property-info/Extractor/PhpDocExtractor.php new file mode 100644 index 000000000..c4bacbb88 --- /dev/null +++ b/lib/symfony/property-info/Extractor/PhpDocExtractor.php @@ -0,0 +1,348 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Extractor; + +use phpDocumentor\Reflection\DocBlock; +use phpDocumentor\Reflection\DocBlock\Tags\InvalidTag; +use phpDocumentor\Reflection\DocBlockFactory; +use phpDocumentor\Reflection\DocBlockFactoryInterface; +use phpDocumentor\Reflection\Types\Context; +use phpDocumentor\Reflection\Types\ContextFactory; +use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Util\PhpDocTypeHelper; + +/** + * Extracts data using a PHPDoc parser. + * + * @author Kévin Dunglas + * + * @final + */ +class PhpDocExtractor implements PropertyDescriptionExtractorInterface, PropertyTypeExtractorInterface, ConstructorArgumentTypeExtractorInterface +{ + public const PROPERTY = 0; + public const ACCESSOR = 1; + public const MUTATOR = 2; + + /** + * @var array + */ + private array $docBlocks = []; + + /** + * @var Context[] + */ + private array $contexts = []; + + private DocBlockFactoryInterface $docBlockFactory; + private ContextFactory $contextFactory; + private PhpDocTypeHelper $phpDocTypeHelper; + private array $mutatorPrefixes; + private array $accessorPrefixes; + private array $arrayMutatorPrefixes; + + /** + * @param string[]|null $mutatorPrefixes + * @param string[]|null $accessorPrefixes + * @param string[]|null $arrayMutatorPrefixes + */ + public function __construct(?DocBlockFactoryInterface $docBlockFactory = null, ?array $mutatorPrefixes = null, ?array $accessorPrefixes = null, ?array $arrayMutatorPrefixes = null) + { + if (!class_exists(DocBlockFactory::class)) { + throw new \LogicException(sprintf('Unable to use the "%s" class as the "phpdocumentor/reflection-docblock" package is not installed. Try running composer require "phpdocumentor/reflection-docblock".', __CLASS__)); + } + + $this->docBlockFactory = $docBlockFactory ?: DocBlockFactory::createInstance(); + $this->contextFactory = new ContextFactory(); + $this->phpDocTypeHelper = new PhpDocTypeHelper(); + $this->mutatorPrefixes = $mutatorPrefixes ?? ReflectionExtractor::$defaultMutatorPrefixes; + $this->accessorPrefixes = $accessorPrefixes ?? ReflectionExtractor::$defaultAccessorPrefixes; + $this->arrayMutatorPrefixes = $arrayMutatorPrefixes ?? ReflectionExtractor::$defaultArrayMutatorPrefixes; + } + + public function getShortDescription(string $class, string $property, array $context = []): ?string + { + /** @var $docBlock DocBlock */ + [$docBlock] = $this->getDocBlock($class, $property); + if (!$docBlock) { + return null; + } + + $shortDescription = $docBlock->getSummary(); + + if (!empty($shortDescription)) { + return $shortDescription; + } + + foreach ($docBlock->getTagsByName('var') as $var) { + if ($var && !$var instanceof InvalidTag) { + $varDescription = $var->getDescription()->render(); + + if (!empty($varDescription)) { + return $varDescription; + } + } + } + + return null; + } + + public function getLongDescription(string $class, string $property, array $context = []): ?string + { + /** @var $docBlock DocBlock */ + [$docBlock] = $this->getDocBlock($class, $property); + if (!$docBlock) { + return null; + } + + $contents = $docBlock->getDescription()->render(); + + return '' === $contents ? null : $contents; + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + /** @var $docBlock DocBlock */ + [$docBlock, $source, $prefix] = $this->getDocBlock($class, $property); + if (!$docBlock) { + return null; + } + + $tag = match ($source) { + self::PROPERTY => 'var', + self::ACCESSOR => 'return', + self::MUTATOR => 'param', + }; + + $parentClass = null; + $types = []; + /** @var DocBlock\Tags\Var_|DocBlock\Tags\Return_|DocBlock\Tags\Param $tag */ + foreach ($docBlock->getTagsByName($tag) as $tag) { + if ($tag && !$tag instanceof InvalidTag && null !== $tag->getType()) { + foreach ($this->phpDocTypeHelper->getTypes($tag->getType()) as $type) { + switch ($type->getClassName()) { + case 'self': + case 'static': + $resolvedClass = $class; + break; + + case 'parent': + if (false !== $resolvedClass = $parentClass ??= get_parent_class($class)) { + break; + } + // no break + + default: + $types[] = $type; + continue 2; + } + + $types[] = new Type(Type::BUILTIN_TYPE_OBJECT, $type->isNullable(), $resolvedClass, $type->isCollection(), $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); + } + } + } + + if (!isset($types[0])) { + return null; + } + + if (!\in_array($prefix, $this->arrayMutatorPrefixes)) { + return $types; + } + + return [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), $types[0])]; + } + + public function getTypesFromConstructor(string $class, string $property): ?array + { + $docBlock = $this->getDocBlockFromConstructor($class, $property); + + if (!$docBlock) { + return null; + } + + $types = []; + /** @var DocBlock\Tags\Var_|DocBlock\Tags\Return_|DocBlock\Tags\Param $tag */ + foreach ($docBlock->getTagsByName('param') as $tag) { + if ($tag && null !== $tag->getType()) { + $types[] = $this->phpDocTypeHelper->getTypes($tag->getType()); + } + } + + if (!isset($types[0]) || [] === $types[0]) { + return null; + } + + return array_merge([], ...$types); + } + + private function getDocBlockFromConstructor(string $class, string $property): ?DocBlock + { + try { + $reflectionClass = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + $reflectionConstructor = $reflectionClass->getConstructor(); + if (!$reflectionConstructor) { + return null; + } + + try { + $docBlock = $this->docBlockFactory->create($reflectionConstructor, $this->contextFactory->createFromReflector($reflectionConstructor)); + + return $this->filterDocBlockParams($docBlock, $property); + } catch (\InvalidArgumentException) { + return null; + } + } + + private function filterDocBlockParams(DocBlock $docBlock, string $allowedParam): DocBlock + { + $tags = array_values(array_filter($docBlock->getTagsByName('param'), fn ($tag) => $tag instanceof DocBlock\Tags\Param && $allowedParam === $tag->getVariableName())); + + return new DocBlock($docBlock->getSummary(), $docBlock->getDescription(), $tags, $docBlock->getContext(), + $docBlock->getLocation(), $docBlock->isTemplateStart(), $docBlock->isTemplateEnd()); + } + + /** + * @return array{DocBlock|null, int|null, string|null} + */ + private function getDocBlock(string $class, string $property): array + { + $propertyHash = sprintf('%s::%s', $class, $property); + + if (isset($this->docBlocks[$propertyHash])) { + return $this->docBlocks[$propertyHash]; + } + + try { + $reflectionProperty = new \ReflectionProperty($class, $property); + } catch (\ReflectionException) { + $reflectionProperty = null; + } + + $ucFirstProperty = ucfirst($property); + + switch (true) { + case $reflectionProperty?->isPromoted() && $docBlock = $this->getDocBlockFromConstructor($class, $property): + $data = [$docBlock, self::MUTATOR, null]; + break; + + case $docBlock = $this->getDocBlockFromProperty($class, $property): + $data = [$docBlock, self::PROPERTY, null]; + break; + + case [$docBlock] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::ACCESSOR): + $data = [$docBlock, self::ACCESSOR, null]; + break; + + case [$docBlock, $prefix] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::MUTATOR): + $data = [$docBlock, self::MUTATOR, $prefix]; + break; + + default: + $data = [null, null, null]; + } + + return $this->docBlocks[$propertyHash] = $data; + } + + private function getDocBlockFromProperty(string $class, string $property): ?DocBlock + { + // Use a ReflectionProperty instead of $class to get the parent class if applicable + try { + $reflectionProperty = new \ReflectionProperty($class, $property); + } catch (\ReflectionException) { + return null; + } + + $reflector = $reflectionProperty->getDeclaringClass(); + + foreach ($reflector->getTraits() as $trait) { + if ($trait->hasProperty($property)) { + return $this->getDocBlockFromProperty($trait->getName(), $property); + } + } + + try { + return $this->docBlockFactory->create($reflectionProperty, $this->createFromReflector($reflector)); + } catch (\InvalidArgumentException|\RuntimeException) { + return null; + } + } + + /** + * @return array{DocBlock, string}|null + */ + private function getDocBlockFromMethod(string $class, string $ucFirstProperty, int $type): ?array + { + $prefixes = self::ACCESSOR === $type ? $this->accessorPrefixes : $this->mutatorPrefixes; + $prefix = null; + + foreach ($prefixes as $prefix) { + $methodName = $prefix.$ucFirstProperty; + + try { + $reflectionMethod = new \ReflectionMethod($class, $methodName); + if ($reflectionMethod->isStatic()) { + continue; + } + + if ( + (self::ACCESSOR === $type && 0 === $reflectionMethod->getNumberOfRequiredParameters()) + || (self::MUTATOR === $type && $reflectionMethod->getNumberOfParameters() >= 1) + ) { + break; + } + } catch (\ReflectionException) { + // Try the next prefix if the method doesn't exist + } + } + + if (!isset($reflectionMethod)) { + return null; + } + + $reflector = $reflectionMethod->getDeclaringClass(); + + foreach ($reflector->getTraits() as $trait) { + if ($trait->hasMethod($methodName)) { + return $this->getDocBlockFromMethod($trait->getName(), $ucFirstProperty, $type); + } + } + + try { + return [$this->docBlockFactory->create($reflectionMethod, $this->createFromReflector($reflector)), $prefix]; + } catch (\InvalidArgumentException|\RuntimeException) { + return null; + } + } + + /** + * Prevents a lot of redundant calls to ContextFactory::createForNamespace(). + */ + private function createFromReflector(\ReflectionClass $reflector): Context + { + $cacheKey = $reflector->getNamespaceName().':'.$reflector->getFileName(); + + if (isset($this->contexts[$cacheKey])) { + return $this->contexts[$cacheKey]; + } + + $this->contexts[$cacheKey] = $this->contextFactory->createFromReflector($reflector); + + return $this->contexts[$cacheKey]; + } +} diff --git a/lib/symfony/property-info/Extractor/PhpStanExtractor.php b/lib/symfony/property-info/Extractor/PhpStanExtractor.php new file mode 100644 index 000000000..9c8562da4 --- /dev/null +++ b/lib/symfony/property-info/Extractor/PhpStanExtractor.php @@ -0,0 +1,323 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Extractor; + +use phpDocumentor\Reflection\Types\ContextFactory; +use PHPStan\PhpDocParser\Ast\PhpDoc\InvalidTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; +use PHPStan\PhpDocParser\ParserConfig; +use Symfony\Component\PropertyInfo\PhpStan\NameScopeFactory; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Util\PhpStanTypeHelper; + +/** + * Extracts data using PHPStan parser. + * + * @author Baptiste Leduc + */ +final class PhpStanExtractor implements PropertyTypeExtractorInterface, ConstructorArgumentTypeExtractorInterface +{ + private const PROPERTY = 0; + private const ACCESSOR = 1; + private const MUTATOR = 2; + + private PhpDocParser $phpDocParser; + private Lexer $lexer; + private NameScopeFactory $nameScopeFactory; + + /** @var array */ + private array $docBlocks = []; + private PhpStanTypeHelper $phpStanTypeHelper; + private array $mutatorPrefixes; + private array $accessorPrefixes; + private array $arrayMutatorPrefixes; + + /** + * @param list|null $mutatorPrefixes + * @param list|null $accessorPrefixes + * @param list|null $arrayMutatorPrefixes + */ + public function __construct(?array $mutatorPrefixes = null, ?array $accessorPrefixes = null, ?array $arrayMutatorPrefixes = null) + { + if (!class_exists(ContextFactory::class)) { + throw new \LogicException(sprintf('Unable to use the "%s" class as the "phpdocumentor/type-resolver" package is not installed. Try running composer require "phpdocumentor/type-resolver".', __CLASS__)); + } + + if (!class_exists(PhpDocParser::class)) { + throw new \LogicException(sprintf('Unable to use the "%s" class as the "phpstan/phpdoc-parser" package is not installed. Try running composer require "phpstan/phpdoc-parser".', __CLASS__)); + } + + $this->phpStanTypeHelper = new PhpStanTypeHelper(); + $this->mutatorPrefixes = $mutatorPrefixes ?? ReflectionExtractor::$defaultMutatorPrefixes; + $this->accessorPrefixes = $accessorPrefixes ?? ReflectionExtractor::$defaultAccessorPrefixes; + $this->arrayMutatorPrefixes = $arrayMutatorPrefixes ?? ReflectionExtractor::$defaultArrayMutatorPrefixes; + + if (class_exists(ParserConfig::class)) { + $parserConfig = new ParserConfig([]); + $this->phpDocParser = new PhpDocParser($parserConfig, new TypeParser($parserConfig, new ConstExprParser($parserConfig)), new ConstExprParser($parserConfig)); + $this->lexer = new Lexer($parserConfig); + } else { + $this->phpDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); + $this->lexer = new Lexer(); + } + $this->nameScopeFactory = new NameScopeFactory(); + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + /** @var PhpDocNode|null $docNode */ + [$docNode, $source, $prefix, $declaringClass] = $this->getDocBlock($class, $property); + $nameScope = $this->nameScopeFactory->create($class, $declaringClass); + if (null === $docNode) { + return null; + } + + switch ($source) { + case self::PROPERTY: + $tag = '@var'; + break; + + case self::ACCESSOR: + $tag = '@return'; + break; + + case self::MUTATOR: + $tag = '@param'; + break; + } + + $parentClass = null; + $types = []; + foreach ($docNode->getTagsByName($tag) as $tagDocNode) { + if ($tagDocNode->value instanceof InvalidTagValueNode) { + continue; + } + + if ( + $tagDocNode->value instanceof ParamTagValueNode + && null === $prefix + && $tagDocNode->value->parameterName !== '$'.$property + ) { + continue; + } + + foreach ($this->phpStanTypeHelper->getTypes($tagDocNode->value, $nameScope) as $type) { + switch ($type->getClassName()) { + case 'self': + case 'static': + $resolvedClass = $class; + break; + + case 'parent': + if (false !== $resolvedClass = $parentClass ??= get_parent_class($class)) { + break; + } + // no break + + default: + $types[] = $type; + continue 2; + } + + $types[] = new Type(Type::BUILTIN_TYPE_OBJECT, $type->isNullable(), $resolvedClass, $type->isCollection(), $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); + } + } + + if (!isset($types[0])) { + return null; + } + + if (!\in_array($prefix, $this->arrayMutatorPrefixes, true)) { + return $types; + } + + return [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), $types[0])]; + } + + public function getTypesFromConstructor(string $class, string $property): ?array + { + if (null === $tagDocNode = $this->getDocBlockFromConstructor($class, $property)) { + return null; + } + + $types = []; + foreach ($this->phpStanTypeHelper->getTypes($tagDocNode, $this->nameScopeFactory->create($class)) as $type) { + $types[] = $type; + } + + if (!isset($types[0])) { + return null; + } + + return $types; + } + + private function getDocBlockFromConstructor(string $class, string $property): ?ParamTagValueNode + { + try { + $reflectionClass = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + + if (null === $reflectionConstructor = $reflectionClass->getConstructor()) { + return null; + } + + if (!$rawDocNode = $reflectionConstructor->getDocComment()) { + return null; + } + + $phpDocNode = $this->getPhpDocNode($rawDocNode); + + return $this->filterDocBlockParams($phpDocNode, $property); + } + + private function filterDocBlockParams(PhpDocNode $docNode, string $allowedParam): ?ParamTagValueNode + { + $tags = array_values(array_filter($docNode->getTagsByName('@param'), fn ($tagNode) => $tagNode instanceof PhpDocTagNode && ('$'.$allowedParam) === $tagNode->value->parameterName)); + + if (!$tags) { + return null; + } + + return $tags[0]->value; + } + + /** + * @return array{PhpDocNode|null, int|null, string|null, string|null} + */ + private function getDocBlock(string $class, string $property): array + { + $propertyHash = $class.'::'.$property; + + if (isset($this->docBlocks[$propertyHash])) { + return $this->docBlocks[$propertyHash]; + } + + $ucFirstProperty = ucfirst($property); + + if ([$docBlock, $source, $declaringClass] = $this->getDocBlockFromProperty($class, $property)) { + $data = [$docBlock, $source, null, $declaringClass]; + } elseif ([$docBlock, $_, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::ACCESSOR)) { + $data = [$docBlock, self::ACCESSOR, null, $declaringClass]; + } elseif ([$docBlock, $prefix, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::MUTATOR)) { + $data = [$docBlock, self::MUTATOR, $prefix, $declaringClass]; + } else { + $data = [null, null, null, null]; + } + + return $this->docBlocks[$propertyHash] = $data; + } + + /** + * @return array{PhpDocNode, int, string}|null + */ + private function getDocBlockFromProperty(string $class, string $property): ?array + { + // Use a ReflectionProperty instead of $class to get the parent class if applicable + try { + $reflectionProperty = new \ReflectionProperty($class, $property); + } catch (\ReflectionException) { + return null; + } + + $reflector = $reflectionProperty->getDeclaringClass(); + + foreach ($reflector->getTraits() as $trait) { + if ($trait->hasProperty($property)) { + return $this->getDocBlockFromProperty($trait->getName(), $property); + } + } + + // Type can be inside property docblock as `@var` + $rawDocNode = $reflectionProperty->getDocComment(); + $phpDocNode = $rawDocNode ? $this->getPhpDocNode($rawDocNode) : null; + $source = self::PROPERTY; + + if (!$phpDocNode?->getTagsByName('@var')) { + $phpDocNode = null; + } + + // or in the constructor as `@param` for promoted properties + if (!$phpDocNode && $reflectionProperty->isPromoted()) { + $constructor = new \ReflectionMethod($class, '__construct'); + $rawDocNode = $constructor->getDocComment(); + $phpDocNode = $rawDocNode ? $this->getPhpDocNode($rawDocNode) : null; + $source = self::MUTATOR; + } + + if (!$phpDocNode) { + return null; + } + + return [$phpDocNode, $source, $reflectionProperty->class]; + } + + /** + * @return array{PhpDocNode, string, string}|null + */ + private function getDocBlockFromMethod(string $class, string $ucFirstProperty, int $type): ?array + { + $prefixes = self::ACCESSOR === $type ? $this->accessorPrefixes : $this->mutatorPrefixes; + $prefix = null; + + foreach ($prefixes as $prefix) { + $methodName = $prefix.$ucFirstProperty; + + try { + $reflectionMethod = new \ReflectionMethod($class, $methodName); + if ($reflectionMethod->isStatic()) { + continue; + } + + if ( + (self::ACCESSOR === $type && 0 === $reflectionMethod->getNumberOfRequiredParameters()) + || (self::MUTATOR === $type && $reflectionMethod->getNumberOfParameters() >= 1) + ) { + break; + } + } catch (\ReflectionException) { + // Try the next prefix if the method doesn't exist + } + } + + if (!isset($reflectionMethod)) { + return null; + } + + if (null === $rawDocNode = $reflectionMethod->getDocComment() ?: null) { + return null; + } + + $phpDocNode = $this->getPhpDocNode($rawDocNode); + + return [$phpDocNode, $prefix, $reflectionMethod->class]; + } + + private function getPhpDocNode(string $rawDocNode): PhpDocNode + { + $tokens = new TokenIterator($this->lexer->tokenize($rawDocNode)); + $phpDocNode = $this->phpDocParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); + + return $phpDocNode; + } +} diff --git a/lib/symfony/property-info/Extractor/ReflectionExtractor.php b/lib/symfony/property-info/Extractor/ReflectionExtractor.php new file mode 100644 index 000000000..89239a53f --- /dev/null +++ b/lib/symfony/property-info/Extractor/ReflectionExtractor.php @@ -0,0 +1,872 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Extractor; + +use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyReadInfo; +use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyWriteInfo; +use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\String\Inflector\EnglishInflector; +use Symfony\Component\String\Inflector\InflectorInterface; + +/** + * Extracts data using the reflection API. + * + * @author Kévin Dunglas + * + * @final + */ +class ReflectionExtractor implements PropertyListExtractorInterface, PropertyTypeExtractorInterface, PropertyAccessExtractorInterface, PropertyInitializableExtractorInterface, PropertyReadInfoExtractorInterface, PropertyWriteInfoExtractorInterface, ConstructorArgumentTypeExtractorInterface +{ + /** + * @internal + */ + public static array $defaultMutatorPrefixes = ['add', 'remove', 'set']; + + /** + * @internal + */ + public static array $defaultAccessorPrefixes = ['get', 'is', 'has', 'can']; + + /** + * @internal + */ + public static array $defaultArrayMutatorPrefixes = ['add', 'remove']; + + public const ALLOW_PRIVATE = 1; + public const ALLOW_PROTECTED = 2; + public const ALLOW_PUBLIC = 4; + + /** @var int Allow none of the magic methods */ + public const DISALLOW_MAGIC_METHODS = 0; + /** @var int Allow magic __get methods */ + public const ALLOW_MAGIC_GET = 1 << 0; + /** @var int Allow magic __set methods */ + public const ALLOW_MAGIC_SET = 1 << 1; + /** @var int Allow magic __call methods */ + public const ALLOW_MAGIC_CALL = 1 << 2; + + private const MAP_TYPES = [ + 'integer' => Type::BUILTIN_TYPE_INT, + 'boolean' => Type::BUILTIN_TYPE_BOOL, + 'double' => Type::BUILTIN_TYPE_FLOAT, + ]; + + private array $mutatorPrefixes; + private array $accessorPrefixes; + private array $arrayMutatorPrefixes; + private bool $enableConstructorExtraction; + private int $methodReflectionFlags; + private int $magicMethodsFlags; + private int $propertyReflectionFlags; + private InflectorInterface $inflector; + private array $arrayMutatorPrefixesFirst; + private array $arrayMutatorPrefixesLast; + + /** + * @param string[]|null $mutatorPrefixes + * @param string[]|null $accessorPrefixes + * @param string[]|null $arrayMutatorPrefixes + */ + public function __construct(?array $mutatorPrefixes = null, ?array $accessorPrefixes = null, ?array $arrayMutatorPrefixes = null, bool $enableConstructorExtraction = true, int $accessFlags = self::ALLOW_PUBLIC, ?InflectorInterface $inflector = null, int $magicMethodsFlags = self::ALLOW_MAGIC_GET | self::ALLOW_MAGIC_SET) + { + $this->mutatorPrefixes = $mutatorPrefixes ?? self::$defaultMutatorPrefixes; + $this->accessorPrefixes = $accessorPrefixes ?? self::$defaultAccessorPrefixes; + $this->arrayMutatorPrefixes = $arrayMutatorPrefixes ?? self::$defaultArrayMutatorPrefixes; + $this->enableConstructorExtraction = $enableConstructorExtraction; + $this->methodReflectionFlags = $this->getMethodsFlags($accessFlags); + $this->propertyReflectionFlags = $this->getPropertyFlags($accessFlags); + $this->magicMethodsFlags = $magicMethodsFlags; + $this->inflector = $inflector ?? new EnglishInflector(); + + $this->arrayMutatorPrefixesFirst = array_merge($this->arrayMutatorPrefixes, array_diff($this->mutatorPrefixes, $this->arrayMutatorPrefixes)); + $this->arrayMutatorPrefixesLast = array_reverse($this->arrayMutatorPrefixesFirst); + } + + public function getProperties(string $class, array $context = []): ?array + { + try { + $reflectionClass = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + + $reflectionProperties = $reflectionClass->getProperties(); + + $properties = []; + foreach ($reflectionProperties as $reflectionProperty) { + if ($reflectionProperty->getModifiers() & $this->propertyReflectionFlags) { + $properties[$reflectionProperty->name] = $reflectionProperty->name; + } + } + + foreach ($reflectionClass->getMethods($this->methodReflectionFlags) as $reflectionMethod) { + if ($reflectionMethod->isStatic()) { + continue; + } + + $propertyName = $this->getPropertyName($reflectionMethod->name, $reflectionProperties); + if (!$propertyName || isset($properties[$propertyName])) { + continue; + } + if ($reflectionClass->hasProperty($lowerCasedPropertyName = lcfirst($propertyName)) || (!$reflectionClass->hasProperty($propertyName) && !preg_match('/^[A-Z]{2,}/', $propertyName))) { + $propertyName = $lowerCasedPropertyName; + } + $properties[$propertyName] = $propertyName; + } + + return $properties ? array_values($properties) : null; + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + if ($fromMutator = $this->extractFromMutator($class, $property)) { + return $fromMutator; + } + + if ($fromAccessor = $this->extractFromAccessor($class, $property)) { + return $fromAccessor; + } + + if ( + ($context['enable_constructor_extraction'] ?? $this->enableConstructorExtraction) + && $fromConstructor = $this->extractFromConstructor($class, $property) + ) { + return $fromConstructor; + } + + if ($fromPropertyDeclaration = $this->extractFromPropertyDeclaration($class, $property)) { + return $fromPropertyDeclaration; + } + + return null; + } + + public function getTypesFromConstructor(string $class, string $property): ?array + { + try { + $reflection = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + if (!$reflectionConstructor = $reflection->getConstructor()) { + return null; + } + if (!$reflectionParameter = $this->getReflectionParameterFromConstructor($property, $reflectionConstructor)) { + return null; + } + if (!$reflectionType = $reflectionParameter->getType()) { + return null; + } + if (!$types = $this->extractFromReflectionType($reflectionType, $reflectionConstructor->getDeclaringClass())) { + return null; + } + + return $types; + } + + private function getReflectionParameterFromConstructor(string $property, \ReflectionMethod $reflectionConstructor): ?\ReflectionParameter + { + foreach ($reflectionConstructor->getParameters() as $reflectionParameter) { + if ($reflectionParameter->getName() === $property) { + return $reflectionParameter; + } + } + + return null; + } + + public function isReadable(string $class, string $property, array $context = []): ?bool + { + if ($this->isAllowedProperty($class, $property)) { + return true; + } + + return null !== $this->getReadInfo($class, $property, $context); + } + + public function isWritable(string $class, string $property, array $context = []): ?bool + { + if ($this->isAllowedProperty($class, $property, true)) { + return true; + } + + // First test with the camelized property name + [$reflectionMethod] = $this->getMutatorMethod($class, $this->camelize($property)); + if (null !== $reflectionMethod) { + return true; + } + + // Otherwise check for the old way + [$reflectionMethod] = $this->getMutatorMethod($class, $property); + + return null !== $reflectionMethod; + } + + public function isInitializable(string $class, string $property, array $context = []): ?bool + { + try { + $reflectionClass = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + + if (!$reflectionClass->isInstantiable()) { + return false; + } + + if ($constructor = $reflectionClass->getConstructor()) { + foreach ($constructor->getParameters() as $parameter) { + if ($property === $parameter->name) { + return true; + } + } + } elseif ($parentClass = $reflectionClass->getParentClass()) { + return $this->isInitializable($parentClass->getName(), $property); + } + + return false; + } + + public function getReadInfo(string $class, string $property, array $context = []): ?PropertyReadInfo + { + try { + $reflClass = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + + $allowGetterSetter = $context['enable_getter_setter_extraction'] ?? false; + $magicMethods = $context['enable_magic_methods_extraction'] ?? $this->magicMethodsFlags; + $allowMagicCall = (bool) ($magicMethods & self::ALLOW_MAGIC_CALL); + $allowMagicGet = (bool) ($magicMethods & self::ALLOW_MAGIC_GET); + $hasProperty = $reflClass->hasProperty($property); + $camelProp = $this->camelize($property); + $getsetter = lcfirst($camelProp); // jQuery style, e.g. read: last(), write: last($item) + + foreach ($this->accessorPrefixes as $prefix) { + $methodName = $prefix.$camelProp; + + if ($reflClass->hasMethod($methodName) && $reflClass->getMethod($methodName)->getModifiers() & $this->methodReflectionFlags && !$reflClass->getMethod($methodName)->getNumberOfRequiredParameters()) { + $method = $reflClass->getMethod($methodName); + + return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, $methodName, $this->getReadVisiblityForMethod($method), $method->isStatic(), false); + } + } + + if ($allowGetterSetter && $reflClass->hasMethod($getsetter) && ($reflClass->getMethod($getsetter)->getModifiers() & $this->methodReflectionFlags)) { + $method = $reflClass->getMethod($getsetter); + + return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, $getsetter, $this->getReadVisiblityForMethod($method), $method->isStatic(), false); + } + + if ($allowMagicGet && $reflClass->hasMethod('__get') && (($r = $reflClass->getMethod('__get'))->getModifiers() & $this->methodReflectionFlags)) { + return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, PropertyReadInfo::VISIBILITY_PUBLIC, false, $r->returnsReference()); + } + + if ($hasProperty && (($r = $reflClass->getProperty($property))->getModifiers() & $this->propertyReflectionFlags)) { + return new PropertyReadInfo(PropertyReadInfo::TYPE_PROPERTY, $property, $this->getReadVisiblityForProperty($r), $r->isStatic(), true); + } + + if ($allowMagicCall && $reflClass->hasMethod('__call') && ($reflClass->getMethod('__call')->getModifiers() & $this->methodReflectionFlags)) { + return new PropertyReadInfo(PropertyReadInfo::TYPE_METHOD, 'get'.$camelProp, PropertyReadInfo::VISIBILITY_PUBLIC, false, false); + } + + return null; + } + + public function getWriteInfo(string $class, string $property, array $context = []): ?PropertyWriteInfo + { + try { + $reflClass = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + + $allowGetterSetter = $context['enable_getter_setter_extraction'] ?? false; + $magicMethods = $context['enable_magic_methods_extraction'] ?? $this->magicMethodsFlags; + $allowMagicCall = (bool) ($magicMethods & self::ALLOW_MAGIC_CALL); + $allowMagicSet = (bool) ($magicMethods & self::ALLOW_MAGIC_SET); + $allowConstruct = $context['enable_constructor_extraction'] ?? $this->enableConstructorExtraction; + $allowAdderRemover = $context['enable_adder_remover_extraction'] ?? true; + + $camelized = $this->camelize($property); + $constructor = $reflClass->getConstructor(); + $singulars = $this->inflector->singularize($camelized); + $errors = []; + + if (null !== $constructor && $allowConstruct) { + foreach ($constructor->getParameters() as $parameter) { + if ($parameter->getName() === $property) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_CONSTRUCTOR, $property); + } + } + } + + [$adderAccessName, $removerAccessName, $adderAndRemoverErrors] = $this->findAdderAndRemover($reflClass, $singulars); + if ($allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) { + $adderMethod = $reflClass->getMethod($adderAccessName); + $removerMethod = $reflClass->getMethod($removerAccessName); + + $mutator = new PropertyWriteInfo(PropertyWriteInfo::TYPE_ADDER_AND_REMOVER); + $mutator->setAdderInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $adderAccessName, $this->getWriteVisiblityForMethod($adderMethod), $adderMethod->isStatic())); + $mutator->setRemoverInfo(new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $removerAccessName, $this->getWriteVisiblityForMethod($removerMethod), $removerMethod->isStatic())); + + return $mutator; + } + + $errors[] = $adderAndRemoverErrors; + + foreach ($this->mutatorPrefixes as $mutatorPrefix) { + $methodName = $mutatorPrefix.$camelized; + + [$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, $methodName, 1); + if (!$accessible) { + $errors[] = $methodAccessibleErrors; + continue; + } + + $method = $reflClass->getMethod($methodName); + + if (!\in_array($mutatorPrefix, $this->arrayMutatorPrefixes, true)) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $methodName, $this->getWriteVisiblityForMethod($method), $method->isStatic()); + } + } + + $getsetter = lcfirst($camelized); + + if ($allowGetterSetter) { + [$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, $getsetter, 1); + if ($accessible) { + $method = $reflClass->getMethod($getsetter); + + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, $getsetter, $this->getWriteVisiblityForMethod($method), $method->isStatic()); + } + + $errors[] = $methodAccessibleErrors; + } + + if ($reflClass->hasProperty($property) && ($reflClass->getProperty($property)->getModifiers() & $this->propertyReflectionFlags)) { + $reflProperty = $reflClass->getProperty($property); + if (!$reflProperty->isReadOnly()) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, $this->getWriteVisiblityForProperty($reflProperty), $reflProperty->isStatic()); + } + + $errors[] = [sprintf('The property "%s" in class "%s" is a promoted readonly property.', $property, $reflClass->getName())]; + $allowMagicSet = $allowMagicCall = false; + } + + if ($allowMagicSet) { + [$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, '__set', 2); + if ($accessible) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_PROPERTY, $property, PropertyWriteInfo::VISIBILITY_PUBLIC, false); + } + + $errors[] = $methodAccessibleErrors; + } + + if ($allowMagicCall) { + [$accessible, $methodAccessibleErrors] = $this->isMethodAccessible($reflClass, '__call', 2); + if ($accessible) { + return new PropertyWriteInfo(PropertyWriteInfo::TYPE_METHOD, 'set'.$camelized, PropertyWriteInfo::VISIBILITY_PUBLIC, false); + } + + $errors[] = $methodAccessibleErrors; + } + + if (!$allowAdderRemover && null !== $adderAccessName && null !== $removerAccessName) { + $errors[] = [sprintf( + 'The property "%s" in class "%s" can be defined with the methods "%s()" but '. + 'the new value must be an array or an instance of \Traversable', + $property, + $reflClass->getName(), + implode('()", "', [$adderAccessName, $removerAccessName]) + )]; + } + + $noneProperty = new PropertyWriteInfo(); + $noneProperty->setErrors(array_merge([], ...$errors)); + + return $noneProperty; + } + + /** + * @return Type[]|null + */ + private function extractFromMutator(string $class, string $property): ?array + { + [$reflectionMethod, $prefix] = $this->getMutatorMethod($class, $property); + if (null === $reflectionMethod) { + return null; + } + + $reflectionParameters = $reflectionMethod->getParameters(); + $reflectionParameter = $reflectionParameters[0]; + + if (!$reflectionType = $reflectionParameter->getType()) { + return null; + } + $type = $this->extractFromReflectionType($reflectionType, $reflectionMethod->getDeclaringClass()); + + if (1 === \count($type) && \in_array($prefix, $this->arrayMutatorPrefixes)) { + $type = [new Type(Type::BUILTIN_TYPE_ARRAY, $this->isNullableProperty($class, $property), null, true, new Type(Type::BUILTIN_TYPE_INT), $type[0])]; + } + + return $type; + } + + /** + * Tries to extract type information from accessors. + * + * @return Type[]|null + */ + private function extractFromAccessor(string $class, string $property): ?array + { + [$reflectionMethod, $prefix] = $this->getAccessorMethod($class, $property); + if (null === $reflectionMethod) { + return null; + } + + if ($reflectionType = $reflectionMethod->getReturnType()) { + return $this->extractFromReflectionType($reflectionType, $reflectionMethod->getDeclaringClass()); + } + + if (\in_array($prefix, ['is', 'can', 'has'])) { + return [new Type(Type::BUILTIN_TYPE_BOOL)]; + } + + return null; + } + + /** + * Tries to extract type information from constructor. + * + * @return Type[]|null + */ + private function extractFromConstructor(string $class, string $property): ?array + { + try { + $reflectionClass = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + + $constructor = $reflectionClass->getConstructor(); + + if (!$constructor) { + return null; + } + + foreach ($constructor->getParameters() as $parameter) { + if ($property !== $parameter->name) { + continue; + } + $reflectionType = $parameter->getType(); + + return $reflectionType ? $this->extractFromReflectionType($reflectionType, $constructor->getDeclaringClass()) : null; + } + + if ($parentClass = $reflectionClass->getParentClass()) { + return $this->extractFromConstructor($parentClass->getName(), $property); + } + + return null; + } + + private function extractFromPropertyDeclaration(string $class, string $property): ?array + { + try { + $reflectionClass = new \ReflectionClass($class); + + $reflectionProperty = $reflectionClass->getProperty($property); + $reflectionPropertyType = $reflectionProperty->getType(); + + if (null !== $reflectionPropertyType && $types = $this->extractFromReflectionType($reflectionPropertyType, $reflectionProperty->getDeclaringClass())) { + return $types; + } + } catch (\ReflectionException) { + return null; + } + + $defaultValue = $reflectionClass->getDefaultProperties()[$property] ?? null; + + if (null === $defaultValue) { + return null; + } + + $type = \gettype($defaultValue); + $type = static::MAP_TYPES[$type] ?? $type; + + return [new Type($type, $this->isNullableProperty($class, $property), null, Type::BUILTIN_TYPE_ARRAY === $type)]; + } + + private function extractFromReflectionType(\ReflectionType $reflectionType, \ReflectionClass $declaringClass): array + { + $types = []; + $nullable = $reflectionType->allowsNull(); + + foreach (($reflectionType instanceof \ReflectionUnionType || $reflectionType instanceof \ReflectionIntersectionType) ? $reflectionType->getTypes() : [$reflectionType] as $type) { + if (!$type instanceof \ReflectionNamedType) { + // Nested composite types are not supported yet. + return []; + } + + $phpTypeOrClass = $type->getName(); + if ('null' === $phpTypeOrClass || 'mixed' === $phpTypeOrClass || 'never' === $phpTypeOrClass) { + continue; + } + + if (Type::BUILTIN_TYPE_ARRAY === $phpTypeOrClass) { + $types[] = new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true); + } elseif ('void' === $phpTypeOrClass) { + $types[] = new Type(Type::BUILTIN_TYPE_NULL, $nullable); + } elseif ($type->isBuiltin()) { + $types[] = new Type($phpTypeOrClass, $nullable); + } else { + $types[] = new Type(Type::BUILTIN_TYPE_OBJECT, $nullable, $this->resolveTypeName($phpTypeOrClass, $declaringClass)); + } + } + + return $types; + } + + private function resolveTypeName(string $name, \ReflectionClass $declaringClass): string + { + if ('self' === $lcName = strtolower($name)) { + return $declaringClass->name; + } + if ('parent' === $lcName && $parent = $declaringClass->getParentClass()) { + return $parent->name; + } + + return $name; + } + + private function isNullableProperty(string $class, string $property): bool + { + try { + $reflectionProperty = new \ReflectionProperty($class, $property); + + $reflectionPropertyType = $reflectionProperty->getType(); + + return null !== $reflectionPropertyType && $reflectionPropertyType->allowsNull(); + } catch (\ReflectionException) { + // Return false if the property doesn't exist + } + + return false; + } + + private function isAllowedProperty(string $class, string $property, bool $writeAccessRequired = false): bool + { + try { + $reflectionProperty = new \ReflectionProperty($class, $property); + + if ($writeAccessRequired) { + if ($reflectionProperty->isReadOnly()) { + return false; + } + + if (\PHP_VERSION_ID >= 80400 && $reflectionProperty->isProtectedSet()) { + return (bool) ($this->propertyReflectionFlags & \ReflectionProperty::IS_PROTECTED); + } + + if (\PHP_VERSION_ID >= 80400 && $reflectionProperty->isPrivateSet()) { + return (bool) ($this->propertyReflectionFlags & \ReflectionProperty::IS_PRIVATE); + } + + if (\PHP_VERSION_ID >= 80400 &&$reflectionProperty->isVirtual() && !$reflectionProperty->hasHook(\PropertyHookType::Set)) { + return false; + } + } + + return (bool) ($reflectionProperty->getModifiers() & $this->propertyReflectionFlags); + } catch (\ReflectionException) { + // Return false if the property doesn't exist + } + + return false; + } + + /** + * Gets the accessor method. + * + * Returns an array with a the instance of \ReflectionMethod as first key + * and the prefix of the method as second or null if not found. + */ + private function getAccessorMethod(string $class, string $property): ?array + { + $ucProperty = ucfirst($property); + + foreach ($this->accessorPrefixes as $prefix) { + try { + $reflectionMethod = new \ReflectionMethod($class, $prefix.$ucProperty); + if ($reflectionMethod->isStatic()) { + continue; + } + + if (0 === $reflectionMethod->getNumberOfRequiredParameters()) { + return [$reflectionMethod, $prefix]; + } + } catch (\ReflectionException) { + // Return null if the property doesn't exist + } + } + + return null; + } + + /** + * Returns an array with a the instance of \ReflectionMethod as first key + * and the prefix of the method as second or null if not found. + */ + private function getMutatorMethod(string $class, string $property): ?array + { + $ucProperty = ucfirst($property); + $ucSingulars = $this->inflector->singularize($ucProperty); + + $mutatorPrefixes = \in_array($ucProperty, $ucSingulars, true) ? $this->arrayMutatorPrefixesLast : $this->arrayMutatorPrefixesFirst; + + foreach ($mutatorPrefixes as $prefix) { + $names = [$ucProperty]; + if (\in_array($prefix, $this->arrayMutatorPrefixes)) { + $names = array_merge($names, $ucSingulars); + } + + foreach ($names as $name) { + try { + $reflectionMethod = new \ReflectionMethod($class, $prefix.$name); + if ($reflectionMethod->isStatic()) { + continue; + } + + // Parameter can be optional to allow things like: method(?array $foo = null) + if ($reflectionMethod->getNumberOfParameters() >= 1) { + return [$reflectionMethod, $prefix]; + } + } catch (\ReflectionException) { + // Try the next prefix if the method doesn't exist + } + } + } + + return null; + } + + private function getPropertyName(string $methodName, array $reflectionProperties): ?string + { + $pattern = implode('|', array_merge($this->accessorPrefixes, $this->mutatorPrefixes)); + + if ('' !== $pattern && preg_match('/^('.$pattern.')(.+)$/i', $methodName, $matches)) { + if (!\in_array($matches[1], $this->arrayMutatorPrefixes)) { + return $matches[2]; + } + + foreach ($reflectionProperties as $reflectionProperty) { + foreach ($this->inflector->singularize($reflectionProperty->name) as $name) { + if (strtolower($name) === strtolower($matches[2])) { + return $reflectionProperty->name; + } + } + } + + return $matches[2]; + } + + return null; + } + + /** + * Searches for add and remove methods. + * + * @param \ReflectionClass $reflClass The reflection class for the given object + * @param array $singulars The singular form of the property name or null + * + * @return array An array containing the adder and remover when found and errors + */ + private function findAdderAndRemover(\ReflectionClass $reflClass, array $singulars): array + { + if (!\is_array($this->arrayMutatorPrefixes) && 2 !== \count($this->arrayMutatorPrefixes)) { + return [null, null, []]; + } + + [$addPrefix, $removePrefix] = $this->arrayMutatorPrefixes; + $errors = []; + + foreach ($singulars as $singular) { + $addMethod = $addPrefix.$singular; + $removeMethod = $removePrefix.$singular; + + [$addMethodFound, $addMethodAccessibleErrors] = $this->isMethodAccessible($reflClass, $addMethod, 1); + [$removeMethodFound, $removeMethodAccessibleErrors] = $this->isMethodAccessible($reflClass, $removeMethod, 1); + $errors[] = $addMethodAccessibleErrors; + $errors[] = $removeMethodAccessibleErrors; + + if ($addMethodFound && $removeMethodFound) { + return [$addMethod, $removeMethod, []]; + } + + if ($addMethodFound && !$removeMethodFound) { + $errors[] = [sprintf('The add method "%s" in class "%s" was found, but the corresponding remove method "%s" was not found', $addMethod, $reflClass->getName(), $removeMethod)]; + } elseif (!$addMethodFound && $removeMethodFound) { + $errors[] = [sprintf('The remove method "%s" in class "%s" was found, but the corresponding add method "%s" was not found', $removeMethod, $reflClass->getName(), $addMethod)]; + } + } + + return [null, null, array_merge([], ...$errors)]; + } + + /** + * Returns whether a method is public and has the number of required parameters and errors. + */ + private function isMethodAccessible(\ReflectionClass $class, string $methodName, int $parameters): array + { + $errors = []; + + if ($class->hasMethod($methodName)) { + $method = $class->getMethod($methodName); + + if (\ReflectionMethod::IS_PUBLIC === $this->methodReflectionFlags && !$method->isPublic()) { + $errors[] = sprintf('The method "%s" in class "%s" was found but does not have public access.', $methodName, $class->getName()); + } elseif ($method->getNumberOfRequiredParameters() > $parameters || $method->getNumberOfParameters() < $parameters) { + $errors[] = sprintf('The method "%s" in class "%s" requires %d arguments, but should accept only %d.', $methodName, $class->getName(), $method->getNumberOfRequiredParameters(), $parameters); + } else { + return [true, $errors]; + } + } + + return [false, $errors]; + } + + /** + * Camelizes a given string. + */ + private function camelize(string $string): string + { + return str_replace(' ', '', ucwords(str_replace('_', ' ', $string))); + } + + /** + * Return allowed reflection method flags. + */ + private function getMethodsFlags(int $accessFlags): int + { + $methodFlags = 0; + + if ($accessFlags & self::ALLOW_PUBLIC) { + $methodFlags |= \ReflectionMethod::IS_PUBLIC; + } + + if ($accessFlags & self::ALLOW_PRIVATE) { + $methodFlags |= \ReflectionMethod::IS_PRIVATE; + } + + if ($accessFlags & self::ALLOW_PROTECTED) { + $methodFlags |= \ReflectionMethod::IS_PROTECTED; + } + + return $methodFlags; + } + + /** + * Return allowed reflection property flags. + */ + private function getPropertyFlags(int $accessFlags): int + { + $propertyFlags = 0; + + if ($accessFlags & self::ALLOW_PUBLIC) { + $propertyFlags |= \ReflectionProperty::IS_PUBLIC; + } + + if ($accessFlags & self::ALLOW_PRIVATE) { + $propertyFlags |= \ReflectionProperty::IS_PRIVATE; + } + + if ($accessFlags & self::ALLOW_PROTECTED) { + $propertyFlags |= \ReflectionProperty::IS_PROTECTED; + } + + return $propertyFlags; + } + + private function getReadVisiblityForProperty(\ReflectionProperty $reflectionProperty): string + { + if ($reflectionProperty->isPrivate()) { + return PropertyReadInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionProperty->isProtected()) { + return PropertyReadInfo::VISIBILITY_PROTECTED; + } + + return PropertyReadInfo::VISIBILITY_PUBLIC; + } + + private function getReadVisiblityForMethod(\ReflectionMethod $reflectionMethod): string + { + if ($reflectionMethod->isPrivate()) { + return PropertyReadInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionMethod->isProtected()) { + return PropertyReadInfo::VISIBILITY_PROTECTED; + } + + return PropertyReadInfo::VISIBILITY_PUBLIC; + } + + private function getWriteVisiblityForProperty(\ReflectionProperty $reflectionProperty): string + { + if (\PHP_VERSION_ID >= 80400) { + if ($reflectionProperty->isVirtual() && !$reflectionProperty->hasHook(\PropertyHookType::Set)) { + return PropertyWriteInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionProperty->isPrivateSet()) { + return PropertyWriteInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionProperty->isProtectedSet()) { + return PropertyWriteInfo::VISIBILITY_PROTECTED; + } + } + + if ($reflectionProperty->isPrivate()) { + return PropertyWriteInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionProperty->isProtected()) { + return PropertyWriteInfo::VISIBILITY_PROTECTED; + } + + return PropertyWriteInfo::VISIBILITY_PUBLIC; + } + + private function getWriteVisiblityForMethod(\ReflectionMethod $reflectionMethod): string + { + if ($reflectionMethod->isPrivate()) { + return PropertyWriteInfo::VISIBILITY_PRIVATE; + } + + if ($reflectionMethod->isProtected()) { + return PropertyWriteInfo::VISIBILITY_PROTECTED; + } + + return PropertyWriteInfo::VISIBILITY_PUBLIC; + } +} diff --git a/lib/symfony/property-info/Extractor/SerializerExtractor.php b/lib/symfony/property-info/Extractor/SerializerExtractor.php new file mode 100644 index 000000000..0445b0be9 --- /dev/null +++ b/lib/symfony/property-info/Extractor/SerializerExtractor.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\PropertyInfo\Extractor; + +use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; + +/** + * Lists available properties using Symfony Serializer Component metadata. + * + * @author Kévin Dunglas + * + * @final + */ +class SerializerExtractor implements PropertyListExtractorInterface +{ + public function __construct( + private readonly ClassMetadataFactoryInterface $classMetadataFactory, + ) { + } + + public function getProperties(string $class, array $context = []): ?array + { + if (!\array_key_exists('serializer_groups', $context) || (null !== $context['serializer_groups'] && !\is_array($context['serializer_groups']))) { + return null; + } + + if (!$this->classMetadataFactory->hasMetadataFor($class)) { + return null; + } + + $properties = []; + $serializerClassMetadata = $this->classMetadataFactory->getMetadataFor($class); + + foreach ($serializerClassMetadata->getAttributesMetadata() as $serializerAttributeMetadata) { + if (!$serializerAttributeMetadata->isIgnored() && (null === $context['serializer_groups'] || array_intersect($context['serializer_groups'], $serializerAttributeMetadata->getGroups()))) { + $properties[] = $serializerAttributeMetadata->getName(); + } + } + + return $properties; + } +} diff --git a/lib/symfony/property-info/LICENSE b/lib/symfony/property-info/LICENSE new file mode 100644 index 000000000..6e3afce69 --- /dev/null +++ b/lib/symfony/property-info/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2015-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/symfony/property-info/PhpStan/NameScope.php b/lib/symfony/property-info/PhpStan/NameScope.php new file mode 100644 index 000000000..91d91fa8e --- /dev/null +++ b/lib/symfony/property-info/PhpStan/NameScope.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\PhpStan; + +/** + * NameScope class adapted from PHPStan code. + * + * @copyright Copyright (c) 2016, PHPStan https://github.com/phpstan/phpstan-src + * @copyright Copyright (c) 2016, Ondřej Mirtes + * @author Baptiste Leduc + * + * @internal + */ +final class NameScope +{ + private string $calledClassName; + private string $namespace; + /** @var array alias(string) => fullName(string) */ + private array $uses; + + public function __construct(string $calledClassName, string $namespace, array $uses = []) + { + $this->calledClassName = $calledClassName; + $this->namespace = $namespace; + $this->uses = $uses; + } + + public function resolveStringName(string $name): string + { + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $nameParts = explode('\\', $name); + $firstNamePart = $nameParts[0]; + if (isset($this->uses[$firstNamePart])) { + if (1 === \count($nameParts)) { + return $this->uses[$firstNamePart]; + } + array_shift($nameParts); + + return sprintf('%s\\%s', $this->uses[$firstNamePart], implode('\\', $nameParts)); + } + + if (null !== $this->namespace) { + return sprintf('%s\\%s', $this->namespace, $name); + } + + return $name; + } + + public function resolveRootClass(): string + { + return $this->resolveStringName($this->calledClassName); + } +} diff --git a/lib/symfony/property-info/PhpStan/NameScopeFactory.php b/lib/symfony/property-info/PhpStan/NameScopeFactory.php new file mode 100644 index 000000000..162a72a9e --- /dev/null +++ b/lib/symfony/property-info/PhpStan/NameScopeFactory.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\PhpStan; + +use phpDocumentor\Reflection\Types\ContextFactory; + +/** + * @author Baptiste Leduc + * + * @internal + */ +final class NameScopeFactory +{ + public function create(string $calledClassName, ?string $declaringClassName = null): NameScope + { + $declaringClassName ??= $calledClassName; + + $path = explode('\\', $calledClassName); + $calledClassName = array_pop($path); + + $declaringReflection = new \ReflectionClass($declaringClassName); + [$declaringNamespace, $declaringUses] = $this->extractFromFullClassName($declaringReflection); + $declaringUses = array_merge($declaringUses, $this->collectUses($declaringReflection)); + + return new NameScope($calledClassName, $declaringNamespace, $declaringUses); + } + + private function collectUses(\ReflectionClass $reflection): array + { + $uses = [$this->extractFromFullClassName($reflection)[1]]; + + foreach ($reflection->getTraits() as $traitReflection) { + $uses[] = $this->extractFromFullClassName($traitReflection)[1]; + } + + if (false !== $parentClass = $reflection->getParentClass()) { + $uses[] = $this->collectUses($parentClass); + } + + return $uses ? array_merge(...$uses) : []; + } + + private function extractFromFullClassName(\ReflectionClass $reflection): array + { + $namespace = trim($reflection->getNamespaceName(), '\\'); + $fileName = $reflection->getFileName(); + + if (\is_string($fileName) && is_file($fileName)) { + if (false === $contents = file_get_contents($fileName)) { + throw new \RuntimeException(sprintf('Unable to read file "%s".', $fileName)); + } + + $factory = new ContextFactory(); + $context = $factory->createForNamespace($namespace, $contents); + + return [$namespace, $context->getNamespaceAliases()]; + } + + return [$namespace, []]; + } +} diff --git a/lib/symfony/property-info/PropertyAccessExtractorInterface.php b/lib/symfony/property-info/PropertyAccessExtractorInterface.php new file mode 100644 index 000000000..f9ee78713 --- /dev/null +++ b/lib/symfony/property-info/PropertyAccessExtractorInterface.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\PropertyInfo; + +/** + * Guesses if the property can be accessed or mutated. + * + * @author Kévin Dunglas + */ +interface PropertyAccessExtractorInterface +{ + /** + * Is the property readable? + * + * @return bool|null + */ + public function isReadable(string $class, string $property, array $context = []); + + /** + * Is the property writable? + * + * @return bool|null + */ + public function isWritable(string $class, string $property, array $context = []); +} diff --git a/lib/symfony/property-info/PropertyDescriptionExtractorInterface.php b/lib/symfony/property-info/PropertyDescriptionExtractorInterface.php new file mode 100644 index 000000000..a779d159c --- /dev/null +++ b/lib/symfony/property-info/PropertyDescriptionExtractorInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Guesses the property's human readable description. + * + * @author Kévin Dunglas + */ +interface PropertyDescriptionExtractorInterface +{ + /** + * Gets the short description of the property. + */ + public function getShortDescription(string $class, string $property, array $context = []): ?string; + + /** + * Gets the long description of the property. + */ + public function getLongDescription(string $class, string $property, array $context = []): ?string; +} diff --git a/lib/symfony/property-info/PropertyInfoCacheExtractor.php b/lib/symfony/property-info/PropertyInfoCacheExtractor.php new file mode 100644 index 000000000..b4543eace --- /dev/null +++ b/lib/symfony/property-info/PropertyInfoCacheExtractor.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +use Psr\Cache\CacheItemPoolInterface; + +/** + * Adds a PSR-6 cache layer on top of an extractor. + * + * @author Kévin Dunglas + * + * @final + */ +class PropertyInfoCacheExtractor implements PropertyInfoExtractorInterface, PropertyInitializableExtractorInterface +{ + private array $arrayCache = []; + + public function __construct( + private readonly PropertyInfoExtractorInterface $propertyInfoExtractor, + private readonly CacheItemPoolInterface $cacheItemPool, + ) { + } + + public function isReadable(string $class, string $property, array $context = []): ?bool + { + return $this->extract('isReadable', [$class, $property, $context]); + } + + public function isWritable(string $class, string $property, array $context = []): ?bool + { + return $this->extract('isWritable', [$class, $property, $context]); + } + + public function getShortDescription(string $class, string $property, array $context = []): ?string + { + return $this->extract('getShortDescription', [$class, $property, $context]); + } + + public function getLongDescription(string $class, string $property, array $context = []): ?string + { + return $this->extract('getLongDescription', [$class, $property, $context]); + } + + public function getProperties(string $class, array $context = []): ?array + { + return $this->extract('getProperties', [$class, $context]); + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + return $this->extract('getTypes', [$class, $property, $context]); + } + + public function isInitializable(string $class, string $property, array $context = []): ?bool + { + return $this->extract('isInitializable', [$class, $property, $context]); + } + + /** + * Retrieves the cached data if applicable or delegates to the decorated extractor. + */ + private function extract(string $method, array $arguments): mixed + { + try { + $serializedArguments = serialize($arguments); + } catch (\Exception) { + // If arguments are not serializable, skip the cache + return $this->propertyInfoExtractor->{$method}(...$arguments); + } + + // Calling rawurlencode escapes special characters not allowed in PSR-6's keys + $key = rawurlencode($method.'.'.$serializedArguments); + + if (\array_key_exists($key, $this->arrayCache)) { + return $this->arrayCache[$key]; + } + + $item = $this->cacheItemPool->getItem($key); + + if ($item->isHit()) { + return $this->arrayCache[$key] = $item->get(); + } + + $value = $this->propertyInfoExtractor->{$method}(...$arguments); + $item->set($value); + $this->cacheItemPool->save($item); + + return $this->arrayCache[$key] = $value; + } +} diff --git a/lib/symfony/property-info/PropertyInfoExtractor.php b/lib/symfony/property-info/PropertyInfoExtractor.php new file mode 100644 index 000000000..7416849a0 --- /dev/null +++ b/lib/symfony/property-info/PropertyInfoExtractor.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Default {@see PropertyInfoExtractorInterface} implementation. + * + * @author Kévin Dunglas + * + * @final + */ +class PropertyInfoExtractor implements PropertyInfoExtractorInterface, PropertyInitializableExtractorInterface +{ + /** + * @param iterable $listExtractors + * @param iterable $typeExtractors + * @param iterable $descriptionExtractors + * @param iterable $accessExtractors + * @param iterable $initializableExtractors + */ + public function __construct( + private readonly iterable $listExtractors = [], + private readonly iterable $typeExtractors = [], + private readonly iterable $descriptionExtractors = [], + private readonly iterable $accessExtractors = [], + private readonly iterable $initializableExtractors = [], + ) { + } + + public function getProperties(string $class, array $context = []): ?array + { + return $this->extract($this->listExtractors, 'getProperties', [$class, $context]); + } + + public function getShortDescription(string $class, string $property, array $context = []): ?string + { + return $this->extract($this->descriptionExtractors, 'getShortDescription', [$class, $property, $context]); + } + + public function getLongDescription(string $class, string $property, array $context = []): ?string + { + return $this->extract($this->descriptionExtractors, 'getLongDescription', [$class, $property, $context]); + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + return $this->extract($this->typeExtractors, 'getTypes', [$class, $property, $context]); + } + + public function isReadable(string $class, string $property, array $context = []): ?bool + { + return $this->extract($this->accessExtractors, 'isReadable', [$class, $property, $context]); + } + + public function isWritable(string $class, string $property, array $context = []): ?bool + { + return $this->extract($this->accessExtractors, 'isWritable', [$class, $property, $context]); + } + + public function isInitializable(string $class, string $property, array $context = []): ?bool + { + return $this->extract($this->initializableExtractors, 'isInitializable', [$class, $property, $context]); + } + + /** + * Iterates over registered extractors and return the first value found. + * + * @param iterable $extractors + * @param list $arguments + */ + private function extract(iterable $extractors, string $method, array $arguments): mixed + { + foreach ($extractors as $extractor) { + if (null !== $value = $extractor->{$method}(...$arguments)) { + return $value; + } + } + + return null; + } +} diff --git a/lib/symfony/property-info/PropertyInfoExtractorInterface.php b/lib/symfony/property-info/PropertyInfoExtractorInterface.php new file mode 100644 index 000000000..889301865 --- /dev/null +++ b/lib/symfony/property-info/PropertyInfoExtractorInterface.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\PropertyInfo; + +/** + * Gets info about PHP class properties. + * + * A convenient interface inheriting all specific info interfaces. + * + * @author Kévin Dunglas + */ +interface PropertyInfoExtractorInterface extends PropertyTypeExtractorInterface, PropertyDescriptionExtractorInterface, PropertyAccessExtractorInterface, PropertyListExtractorInterface +{ +} diff --git a/lib/symfony/property-info/PropertyInitializableExtractorInterface.php b/lib/symfony/property-info/PropertyInitializableExtractorInterface.php new file mode 100644 index 000000000..13248fc19 --- /dev/null +++ b/lib/symfony/property-info/PropertyInitializableExtractorInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Guesses if the property can be initialized through the constructor. + * + * @author Kévin Dunglas + */ +interface PropertyInitializableExtractorInterface +{ + /** + * Is the property initializable? Returns true if a constructor's parameter matches the given property name. + */ + public function isInitializable(string $class, string $property, array $context = []): ?bool; +} diff --git a/lib/symfony/property-info/PropertyListExtractorInterface.php b/lib/symfony/property-info/PropertyListExtractorInterface.php new file mode 100644 index 000000000..326e6cccb --- /dev/null +++ b/lib/symfony/property-info/PropertyListExtractorInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Extracts the list of properties available for the given class. + * + * @author Kévin Dunglas + */ +interface PropertyListExtractorInterface +{ + /** + * Gets the list of properties available for the given class. + * + * @return string[]|null + */ + public function getProperties(string $class, array $context = []); +} diff --git a/lib/symfony/property-info/PropertyReadInfo.php b/lib/symfony/property-info/PropertyReadInfo.php new file mode 100644 index 000000000..d006e3248 --- /dev/null +++ b/lib/symfony/property-info/PropertyReadInfo.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * The property read info tells how a property can be read. + * + * @author Joel Wurtz + */ +final class PropertyReadInfo +{ + public const TYPE_METHOD = 'method'; + public const TYPE_PROPERTY = 'property'; + + public const VISIBILITY_PUBLIC = 'public'; + public const VISIBILITY_PROTECTED = 'protected'; + public const VISIBILITY_PRIVATE = 'private'; + + public function __construct( + private readonly string $type, + private readonly string $name, + private readonly string $visibility, + private readonly bool $static, + private readonly bool $byRef, + ) { + } + + /** + * Get type of access. + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get name of the access, which can be a method name or a property name, depending on the type. + */ + public function getName(): string + { + return $this->name; + } + + public function getVisibility(): string + { + return $this->visibility; + } + + public function isStatic(): bool + { + return $this->static; + } + + /** + * Whether this accessor can be accessed by reference. + */ + public function canBeReference(): bool + { + return $this->byRef; + } +} diff --git a/lib/symfony/property-info/PropertyReadInfoExtractorInterface.php b/lib/symfony/property-info/PropertyReadInfoExtractorInterface.php new file mode 100644 index 000000000..816b2825d --- /dev/null +++ b/lib/symfony/property-info/PropertyReadInfoExtractorInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Extract read information for the property of a class. + * + * @author Joel Wurtz + */ +interface PropertyReadInfoExtractorInterface +{ + /** + * Get read information object for a given property of a class. + */ + public function getReadInfo(string $class, string $property, array $context = []): ?PropertyReadInfo; +} diff --git a/lib/symfony/property-info/PropertyTypeExtractorInterface.php b/lib/symfony/property-info/PropertyTypeExtractorInterface.php new file mode 100644 index 000000000..6da0bcb4c --- /dev/null +++ b/lib/symfony/property-info/PropertyTypeExtractorInterface.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Type Extractor Interface. + * + * @author Kévin Dunglas + */ +interface PropertyTypeExtractorInterface +{ + /** + * Gets types of a property. + * + * @return Type[]|null + */ + public function getTypes(string $class, string $property, array $context = []); +} diff --git a/lib/symfony/property-info/PropertyWriteInfo.php b/lib/symfony/property-info/PropertyWriteInfo.php new file mode 100644 index 000000000..81ce7eda6 --- /dev/null +++ b/lib/symfony/property-info/PropertyWriteInfo.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * The write mutator defines how a property can be written. + * + * @author Joel Wurtz + */ +final class PropertyWriteInfo +{ + public const TYPE_NONE = 'none'; + public const TYPE_METHOD = 'method'; + public const TYPE_PROPERTY = 'property'; + public const TYPE_ADDER_AND_REMOVER = 'adder_and_remover'; + public const TYPE_CONSTRUCTOR = 'constructor'; + + public const VISIBILITY_PUBLIC = 'public'; + public const VISIBILITY_PROTECTED = 'protected'; + public const VISIBILITY_PRIVATE = 'private'; + + private ?self $adderInfo = null; + private ?self $removerInfo = null; + private array $errors = []; + + public function __construct( + private readonly string $type = self::TYPE_NONE, + private readonly ?string $name = null, + private readonly ?string $visibility = null, + private readonly ?bool $static = null, + ) { + } + + public function getType(): string + { + return $this->type; + } + + public function getName(): string + { + if (null === $this->name) { + throw new \LogicException("Calling getName() when having a mutator of type {$this->type} is not tolerated."); + } + + return $this->name; + } + + public function setAdderInfo(self $adderInfo): void + { + $this->adderInfo = $adderInfo; + } + + public function getAdderInfo(): self + { + if (null === $this->adderInfo) { + throw new \LogicException("Calling getAdderInfo() when having a mutator of type {$this->type} is not tolerated."); + } + + return $this->adderInfo; + } + + public function setRemoverInfo(self $removerInfo): void + { + $this->removerInfo = $removerInfo; + } + + public function getRemoverInfo(): self + { + if (null === $this->removerInfo) { + throw new \LogicException("Calling getRemoverInfo() when having a mutator of type {$this->type} is not tolerated."); + } + + return $this->removerInfo; + } + + public function getVisibility(): string + { + if (null === $this->visibility) { + throw new \LogicException("Calling getVisibility() when having a mutator of type {$this->type} is not tolerated."); + } + + return $this->visibility; + } + + public function isStatic(): bool + { + if (null === $this->static) { + throw new \LogicException("Calling isStatic() when having a mutator of type {$this->type} is not tolerated."); + } + + return $this->static; + } + + public function setErrors(array $errors): void + { + $this->errors = $errors; + } + + public function getErrors(): array + { + return $this->errors; + } + + public function hasErrors(): bool + { + return (bool) \count($this->errors); + } +} diff --git a/lib/symfony/property-info/PropertyWriteInfoExtractorInterface.php b/lib/symfony/property-info/PropertyWriteInfoExtractorInterface.php new file mode 100644 index 000000000..f11346381 --- /dev/null +++ b/lib/symfony/property-info/PropertyWriteInfoExtractorInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Extract write information for the property of a class. + * + * @author Joel Wurtz + */ +interface PropertyWriteInfoExtractorInterface +{ + /** + * Get write information object for a given property of a class. + */ + public function getWriteInfo(string $class, string $property, array $context = []): ?PropertyWriteInfo; +} diff --git a/lib/symfony/property-info/README.md b/lib/symfony/property-info/README.md new file mode 100644 index 000000000..da3514fc9 --- /dev/null +++ b/lib/symfony/property-info/README.md @@ -0,0 +1,14 @@ +PropertyInfo Component +====================== + +The PropertyInfo component extracts information about PHP class' properties +using metadata of popular sources. + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/property_info.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/lib/symfony/property-info/Type.php b/lib/symfony/property-info/Type.php new file mode 100644 index 000000000..ec4221756 --- /dev/null +++ b/lib/symfony/property-info/Type.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo; + +/** + * Type value object (immutable). + * + * @author Kévin Dunglas + * + * @final + */ +class Type +{ + public const BUILTIN_TYPE_INT = 'int'; + public const BUILTIN_TYPE_FLOAT = 'float'; + public const BUILTIN_TYPE_STRING = 'string'; + public const BUILTIN_TYPE_BOOL = 'bool'; + public const BUILTIN_TYPE_RESOURCE = 'resource'; + public const BUILTIN_TYPE_OBJECT = 'object'; + public const BUILTIN_TYPE_ARRAY = 'array'; + public const BUILTIN_TYPE_NULL = 'null'; + public const BUILTIN_TYPE_FALSE = 'false'; + public const BUILTIN_TYPE_TRUE = 'true'; + public const BUILTIN_TYPE_CALLABLE = 'callable'; + public const BUILTIN_TYPE_ITERABLE = 'iterable'; + + /** + * List of PHP builtin types. + * + * @var string[] + */ + public static $builtinTypes = [ + self::BUILTIN_TYPE_INT, + self::BUILTIN_TYPE_FLOAT, + self::BUILTIN_TYPE_STRING, + self::BUILTIN_TYPE_BOOL, + self::BUILTIN_TYPE_RESOURCE, + self::BUILTIN_TYPE_OBJECT, + self::BUILTIN_TYPE_ARRAY, + self::BUILTIN_TYPE_CALLABLE, + self::BUILTIN_TYPE_FALSE, + self::BUILTIN_TYPE_TRUE, + self::BUILTIN_TYPE_NULL, + self::BUILTIN_TYPE_ITERABLE, + ]; + + /** + * List of PHP builtin collection types. + * + * @var string[] + */ + public static $builtinCollectionTypes = [ + self::BUILTIN_TYPE_ARRAY, + self::BUILTIN_TYPE_ITERABLE, + ]; + + private string $builtinType; + private bool $nullable; + private ?string $class; + private bool $collection; + private array $collectionKeyType; + private array $collectionValueType; + + /** + * @param Type[]|Type|null $collectionKeyType + * @param Type[]|Type|null $collectionValueType + * + * @throws \InvalidArgumentException + */ + public function __construct(string $builtinType, bool $nullable = false, ?string $class = null, bool $collection = false, array|self|null $collectionKeyType = null, array|self|null $collectionValueType = null) + { + if (!\in_array($builtinType, self::$builtinTypes)) { + throw new \InvalidArgumentException(sprintf('"%s" is not a valid PHP type.', $builtinType)); + } + + $this->builtinType = $builtinType; + $this->nullable = $nullable; + $this->class = $class; + $this->collection = $collection; + $this->collectionKeyType = $this->validateCollectionArgument($collectionKeyType, 5, '$collectionKeyType') ?? []; + $this->collectionValueType = $this->validateCollectionArgument($collectionValueType, 6, '$collectionValueType') ?? []; + } + + private function validateCollectionArgument(array|self|null $collectionArgument, int $argumentIndex, string $argumentName): ?array + { + if (null === $collectionArgument) { + return null; + } + + if (\is_array($collectionArgument)) { + foreach ($collectionArgument as $type) { + if (!$type instanceof self) { + throw new \TypeError(sprintf('"%s()": Argument #%d (%s) must be of type "%s[]", "%s" or "null", array value "%s" given.', __METHOD__, $argumentIndex, $argumentName, self::class, self::class, get_debug_type($collectionArgument))); + } + } + + return $collectionArgument; + } + + return [$collectionArgument]; + } + + /** + * Gets built-in type. + * + * Can be bool, int, float, string, array, object, resource, null, callback or iterable. + */ + public function getBuiltinType(): string + { + return $this->builtinType; + } + + public function isNullable(): bool + { + return $this->nullable; + } + + /** + * Gets the class name. + * + * Only applicable if the built-in type is object. + */ + public function getClassName(): ?string + { + return $this->class; + } + + public function isCollection(): bool + { + return $this->collection; + } + + /** + * Gets collection key types. + * + * Only applicable for a collection type. + * + * @return Type[] + */ + public function getCollectionKeyTypes(): array + { + return $this->collectionKeyType; + } + + /** + * Gets collection value types. + * + * Only applicable for a collection type. + * + * @return Type[] + */ + public function getCollectionValueTypes(): array + { + return $this->collectionValueType; + } +} diff --git a/lib/symfony/property-info/Util/PhpDocTypeHelper.php b/lib/symfony/property-info/Util/PhpDocTypeHelper.php new file mode 100644 index 000000000..bde63b89f --- /dev/null +++ b/lib/symfony/property-info/Util/PhpDocTypeHelper.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Util; + +use phpDocumentor\Reflection\PseudoType; +use phpDocumentor\Reflection\PseudoTypes\ConstExpression; +use phpDocumentor\Reflection\PseudoTypes\List_; +use phpDocumentor\Reflection\Type as DocType; +use phpDocumentor\Reflection\Types\Array_; +use phpDocumentor\Reflection\Types\Collection; +use phpDocumentor\Reflection\Types\Compound; +use phpDocumentor\Reflection\Types\Integer; +use phpDocumentor\Reflection\Types\Null_; +use phpDocumentor\Reflection\Types\Nullable; +use phpDocumentor\Reflection\Types\String_; +use Symfony\Component\PropertyInfo\Type; + +// Workaround for phpdocumentor/type-resolver < 1.6 +// We trigger the autoloader here, so we don't need to trigger it inside the loop later. +class_exists(List_::class); + +/** + * Transforms a php doc type to a {@link Type} instance. + * + * @author Kévin Dunglas + * @author Guilhem N. + */ +final class PhpDocTypeHelper +{ + /** + * Creates a {@see Type} from a PHPDoc type. + * + * @return Type[] + */ + public function getTypes(DocType $varType): array + { + if ($varType instanceof ConstExpression) { + // It's safer to fall back to other extractors here, as resolving const types correctly is not easy at the moment + return []; + } + + $types = []; + $nullable = false; + + if ($varType instanceof Nullable) { + $nullable = true; + $varType = $varType->getActualType(); + } + + if (!$varType instanceof Compound) { + if ($varType instanceof Null_) { + $nullable = true; + } + + $type = $this->createType($varType, $nullable); + if (null !== $type) { + $types[] = $type; + } + + return $types; + } + + $varTypes = []; + for ($typeIndex = 0; $varType->has($typeIndex); ++$typeIndex) { + $type = $varType->get($typeIndex); + + if ($type instanceof ConstExpression) { + // It's safer to fall back to other extractors here, as resolving const types correctly is not easy at the moment + return []; + } + + // If null is present, all types are nullable + if ($type instanceof Null_) { + $nullable = true; + continue; + } + + if ($type instanceof Nullable) { + $nullable = true; + $type = $type->getActualType(); + } + + $varTypes[] = $type; + } + + foreach ($varTypes as $varType) { + $type = $this->createType($varType, $nullable); + if (null !== $type) { + $types[] = $type; + } + } + + return $types; + } + + /** + * Creates a {@see Type} from a PHPDoc type. + */ + private function createType(DocType $type, bool $nullable): ?Type + { + $docType = (string) $type; + + if ($type instanceof Collection) { + $fqsen = $type->getFqsen(); + if ($fqsen && 'list' === $fqsen->getName() && !class_exists(List_::class, false) && !class_exists((string) $fqsen)) { + // Workaround for phpdocumentor/type-resolver < 1.6 + return new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true, new Type(Type::BUILTIN_TYPE_INT), $this->getTypes($type->getValueType())); + } + + [$phpType, $class] = $this->getPhpTypeAndClass((string) $fqsen); + + $collection = \is_a($class, \Traversable::class, true) || \is_a($class, \ArrayAccess::class, true); + + // it's safer to fall back to other extractors if the generic type is too abstract + if (!$collection && !class_exists($class)) { + return null; + } + + $keys = $this->getTypes($type->getKeyType()); + $values = $this->getTypes($type->getValueType()); + + return new Type($phpType, $nullable, $class, $collection, $keys, $values); + } + + // Cannot guess + if (!$docType || 'mixed' === $docType) { + return null; + } + + if (str_ends_with($docType, '[]') && $type instanceof Array_) { + $collectionKeyTypes = new Type(Type::BUILTIN_TYPE_INT); + $collectionValueTypes = $this->getTypes($type->getValueType()); + + return new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true, $collectionKeyTypes, $collectionValueTypes); + } + + if ((str_starts_with($docType, 'list<') || str_starts_with($docType, 'array<')) && $type instanceof Array_) { + // array is converted to x[] which is handled above + // so it's only necessary to handle array here + $collectionKeyTypes = $this->getTypes($type->getKeyType()); + $collectionValueTypes = $this->getTypes($type->getValueType()); + + return new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true, $collectionKeyTypes, $collectionValueTypes); + } + + if ($type instanceof PseudoType) { + if ($type->underlyingType() instanceof Integer) { + return new Type(Type::BUILTIN_TYPE_INT, $nullable, null); + } elseif ($type->underlyingType() instanceof String_) { + return new Type(Type::BUILTIN_TYPE_STRING, $nullable, null); + } + } + + $docType = $this->normalizeType($docType); + [$phpType, $class] = $this->getPhpTypeAndClass($docType); + + if ('array' === $docType) { + return new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true, null, null); + } + + return new Type($phpType, $nullable, $class); + } + + private function normalizeType(string $docType): string + { + return match ($docType) { + 'integer' => 'int', + 'boolean' => 'bool', + // real is not part of the PHPDoc standard, so we ignore it + 'double' => 'float', + 'callback' => 'callable', + 'void' => 'null', + default => $docType, + }; + } + + private function getPhpTypeAndClass(string $docType): array + { + if (\in_array($docType, Type::$builtinTypes)) { + return [$docType, null]; + } + + if (\in_array($docType, ['parent', 'self', 'static'], true)) { + return ['object', $docType]; + } + + return ['object', ltrim($docType, '\\')]; + } +} diff --git a/lib/symfony/property-info/Util/PhpStanTypeHelper.php b/lib/symfony/property-info/Util/PhpStanTypeHelper.php new file mode 100644 index 000000000..56a6b5091 --- /dev/null +++ b/lib/symfony/property-info/Util/PhpStanTypeHelper.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\PropertyInfo\Util; + +use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; +use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; +use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode; +use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode; +use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode; +use Symfony\Component\PropertyInfo\PhpStan\NameScope; +use Symfony\Component\PropertyInfo\Type; + +/** + * Transforms a php doc tag value to a {@link Type} instance. + * + * @author Baptiste Leduc + * + * @internal + */ +final class PhpStanTypeHelper +{ + /** + * Creates a {@see Type} from a PhpDocTagValueNode type. + * + * @return Type[] + */ + public function getTypes(PhpDocTagValueNode $node, NameScope $nameScope): array + { + if ($node instanceof ParamTagValueNode || $node instanceof ReturnTagValueNode || $node instanceof VarTagValueNode) { + return $this->compressNullableType($this->extractTypes($node->type, $nameScope)); + } + + return []; + } + + /** + * Because PhpStan extract null as a separated type when Symfony / PHP compress it in the first available type we + * need this method to mimic how Symfony want null types. + * + * @param Type[] $types + * + * @return Type[] + */ + private function compressNullableType(array $types): array + { + $firstTypeIndex = null; + $nullableTypeIndex = null; + + foreach ($types as $k => $type) { + if (null === $firstTypeIndex && Type::BUILTIN_TYPE_NULL !== $type->getBuiltinType() && !$type->isNullable()) { + $firstTypeIndex = $k; + } + + if (null === $nullableTypeIndex && Type::BUILTIN_TYPE_NULL === $type->getBuiltinType()) { + $nullableTypeIndex = $k; + } + + if (null !== $firstTypeIndex && null !== $nullableTypeIndex) { + break; + } + } + + if (null !== $firstTypeIndex && null !== $nullableTypeIndex) { + $firstType = $types[$firstTypeIndex]; + $types[$firstTypeIndex] = new Type( + $firstType->getBuiltinType(), + true, + $firstType->getClassName(), + $firstType->isCollection(), + $firstType->getCollectionKeyTypes(), + $firstType->getCollectionValueTypes() + ); + unset($types[$nullableTypeIndex]); + } + + return array_values($types); + } + + /** + * @return Type[] + */ + private function extractTypes(TypeNode $node, NameScope $nameScope): array + { + if ($node instanceof UnionTypeNode) { + $types = []; + foreach ($node->types as $type) { + if ($type instanceof ConstTypeNode) { + // It's safer to fall back to other extractors here, as resolving const types correctly is not easy at the moment + return []; + } + foreach ($this->extractTypes($type, $nameScope) as $subType) { + $types[] = $subType; + } + } + + return $this->compressNullableType($types); + } + if ($node instanceof GenericTypeNode) { + if ('class-string' === $node->type->name) { + return [new Type(Type::BUILTIN_TYPE_STRING)]; + } + + [$mainType] = $this->extractTypes($node->type, $nameScope); + + if (Type::BUILTIN_TYPE_INT === $mainType->getBuiltinType()) { + return [$mainType]; + } + + $collection = $mainType->isCollection() || \is_a($mainType->getClassName(), \Traversable::class, true) || \is_a($mainType->getClassName(), \ArrayAccess::class, true); + + // it's safer to fall back to other extractors if the generic type is too abstract + if (!$collection && !class_exists($mainType->getClassName()) && !interface_exists($mainType->getClassName(), false)) { + return []; + } + + $collectionKeyTypes = $mainType->getCollectionKeyTypes(); + $collectionKeyValues = []; + if (1 === \count($node->genericTypes)) { + foreach ($this->extractTypes($node->genericTypes[0], $nameScope) as $subType) { + $collectionKeyValues[] = $subType; + } + } elseif (2 === \count($node->genericTypes)) { + foreach ($this->extractTypes($node->genericTypes[0], $nameScope) as $keySubType) { + $collectionKeyTypes[] = $keySubType; + } + foreach ($this->extractTypes($node->genericTypes[1], $nameScope) as $valueSubType) { + $collectionKeyValues[] = $valueSubType; + } + } + + return [new Type($mainType->getBuiltinType(), $mainType->isNullable(), $mainType->getClassName(), $collection, $collectionKeyTypes, $collectionKeyValues)]; + } + if ($node instanceof ArrayShapeNode) { + return [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true)]; + } + if ($node instanceof ArrayTypeNode) { + return [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], $this->extractTypes($node->type, $nameScope))]; + } + if ($node instanceof CallableTypeNode || $node instanceof CallableTypeParameterNode) { + return [new Type(Type::BUILTIN_TYPE_CALLABLE)]; + } + if ($node instanceof NullableTypeNode) { + $subTypes = $this->extractTypes($node->type, $nameScope); + if (\count($subTypes) > 1) { + $subTypes[] = new Type(Type::BUILTIN_TYPE_NULL); + + return $subTypes; + } + + return [new Type($subTypes[0]->getBuiltinType(), true, $subTypes[0]->getClassName(), $subTypes[0]->isCollection(), $subTypes[0]->getCollectionKeyTypes(), $subTypes[0]->getCollectionValueTypes())]; + } + if ($node instanceof ThisTypeNode) { + return [new Type(Type::BUILTIN_TYPE_OBJECT, false, $nameScope->resolveRootClass())]; + } + if ($node instanceof IdentifierTypeNode) { + if (\in_array($node->name, Type::$builtinTypes)) { + return [new Type($node->name, false, null, \in_array($node->name, Type::$builtinCollectionTypes))]; + } + + return match ($node->name) { + 'integer', + 'positive-int', + 'negative-int' => [new Type(Type::BUILTIN_TYPE_INT)], + 'double' => [new Type(Type::BUILTIN_TYPE_FLOAT)], + 'list', + 'non-empty-list' => [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT))], + 'non-empty-array' => [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true)], + 'mixed' => [], // mixed seems to be ignored in all other extractors + 'parent' => [new Type(Type::BUILTIN_TYPE_OBJECT, false, $node->name)], + 'static', + 'self' => [new Type(Type::BUILTIN_TYPE_OBJECT, false, $nameScope->resolveRootClass())], + 'class-string', + 'html-escaped-string', + 'lowercase-string', + 'non-empty-lowercase-string', + 'non-empty-string', + 'numeric-string', + 'trait-string', + 'interface-string', + 'literal-string' => [new Type(Type::BUILTIN_TYPE_STRING)], + 'void' => [new Type(Type::BUILTIN_TYPE_NULL)], + 'scalar' => [new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT), new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_BOOL)], + 'number' => [new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT)], + 'numeric' => [new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_FLOAT), new Type(Type::BUILTIN_TYPE_STRING)], + 'array-key' => [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)], + default => [new Type(Type::BUILTIN_TYPE_OBJECT, false, $nameScope->resolveStringName($node->name))], + }; + } + + return []; + } +} diff --git a/lib/symfony/property-info/composer.json b/lib/symfony/property-info/composer.json new file mode 100644 index 000000000..495b51dc5 --- /dev/null +++ b/lib/symfony/property-info/composer.json @@ -0,0 +1,52 @@ +{ + "name": "symfony/property-info", + "type": "library", + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "keywords": [ + "property", + "type", + "phpdoc", + "symfony", + "validator", + "doctrine" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "symfony/serializer": "^5.4|^6.4|^7.0", + "symfony/cache": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/dependency-injection": "<5.4|>=6.0,<6.4", + "symfony/cache": "<5.4", + "symfony/serializer": "<5.4" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\PropertyInfo\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/lib/symfonycasts/dynamic-forms/LICENSE b/lib/symfonycasts/dynamic-forms/LICENSE new file mode 100644 index 000000000..dc94e2542 --- /dev/null +++ b/lib/symfonycasts/dynamic-forms/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) SymfonyCasts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/lib/symfonycasts/dynamic-forms/README.md b/lib/symfonycasts/dynamic-forms/README.md new file mode 100644 index 000000000..51405be37 --- /dev/null +++ b/lib/symfonycasts/dynamic-forms/README.md @@ -0,0 +1,275 @@ +# Dynamic / Dependent Symfony Form Fields + +[![CI](https://github.com/SymfonyCasts/dynamic-forms/actions/workflows/ci.yaml/badge.svg)](https://github.com/SymfonyCasts/dynamic-forms/actions/workflows/ci.yaml) + +**NOTE**: This package is currently experimental. It seems to work great - but +forms are complex! If you find a bug, please open an issue! + +Ever have a form field that depends on another? + +You can find a [Demo with LiveComponent on Symfony UX](https://ux.symfony.com/demos/live-component/dependent-form-fields). + +* Show a field only if another field is set to a specific value; +* Change the options of a field based on the value of another field; +* Have multiple-level dependencies (e.g. field A depends on field B + which depends on field C). + +```php +public function buildForm(FormBuilderInterface $builder, array $options): void +{ + $builder = new DynamicFormBuilder($builder); + + $builder->add('meal', ChoiceType::class, [ + 'choices' => [ + 'Breakfast' => 'breakfast', + 'Lunch' => 'lunch', + 'Dinner' => 'dinner', + ], + ]); + + $builder->addDependent('mainFood', ['meal'], function(DependentField $field, string $meal) { + // dynamically add choices based on the meal! + $choices = ['...']; + + $field->add(ChoiceType::class, [ + 'placeholder' => null === $meal ? 'Select a meal first' : sprintf('What is for %s?', $meal->getReadable()), + 'choices' => $choices, + 'disabled' => null === $meal, + ]); + }); +``` + +## Installation + +Install the package with: + +```bash +composer require symfonycasts/dynamic-forms +``` + +Done - you're ready to build dynamic forms! + +## Usage + +Setting up a dependent field is two parts: + +1. [Usage in PHP](#usage-in-php) - set up your Symfony form to handle + the dynamic fields; +2. [Updating the Frontend](#updating-the-frontend) - adding code to your + frontend so that when one field changes, part of the form is re-rendered. + +## Usage in PHP + +Start by wrapping your `FormBuilderInterface` with a `DynamicFormBuilder`: + +```php +use Symfonycasts\DynamicForms\DynamicFormBuilder; +// ... + +public function buildForm(FormBuilderInterface $builder, array $options): void +{ + $builder = new DynamicFormBuilder($builder); + + // ... +} +``` + +`DynamicFormBuilder` has all the same methods as `FormBuilderInterface` plus +one extra: `addDependent()`. If a field depends on another, use this method +instead of `add()` + +```php +// src/Form/FeedbackForm.php + +// ... +use Symfonycasts\DynamicForms\DependentField; +use Symfonycasts\DynamicForms\DynamicFormBuilder; + +class FeedbackForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder = new DynamicFormBuilder($builder); + + $builder->add('rating', ChoiceType::class, [ + 'choices' => [ + 'Select a rating' => null, + 'Great' => 5, + 'Good' => 4, + 'Okay' => 3, + 'Bad' => 2, + 'Terrible' => 1 + ], + ]); + + $builder->addDependent('badRatingNotes', 'rating', function(DependentField $field, ?int $rating) { + if (null === $rating || $rating >= 3) { + return; // field not needed + } + + $field->add(TextareaType::class, [ + 'label' => 'What went wrong?', + 'attr' => ['rows' => 3], + 'help' => sprintf('Because you gave a %d rating, we\'d love to know what went wrong.', $rating), + ]); + }); + } +} +``` + +The `addDependent()` method takes 3 arguments: + +1. The name of the field to add; +2. The name (or names) of the field that this field depends on; +3. A callback that will be called when the form is submitted. This callback + receives a `DependentField` object as the first argument then the + value of each dependent field as the next arguments. + +Behind the scenes, this works by registering several form event listeners. +The callback be executed when the form is first created (using the initial +data) and then again when the form is submitted. This means that the callback +may be called multiple times. + +Rendering the field is the same - just be sure to make sure the field exists +if it's conditionally added: + +```twig +{{ form_start(form) }} + {{ form_row(form.rating) }} + + {% if form.badRatingNotes is defined %} + {{ form_row(form.badRatingNotes) }} + {% endif %} + + +{{ form_end(form) }} +``` + +## Updating the Frontend + +In the previous example, when the `rating` field changes, the form (or part of +the form) needs to be re-rendered so the `badRatingNotes` field can be added. + +This library doesn't handle this for you, but here are the 2 main options: + +### A) Use [Live Components](https://symfony.com/bundles/ux-live-component/current/index.html) + +This is the easiest method: by rendering your form inside a live component, +it will automatically re-render when the form changes. + +### B) Use [Symfony UX Turbo](https://symfony.com/bundles/ux-turbo/current/index.html#decomposing-complex-pages-with-turbo-frames) + +If you are already using Symfony UX Turbo on your website, you can have a dynamic form running quickly without any JavaScript. + +Or you may want to install Symfony UX Turbo, [check out the documentation](https://symfony.com/bundles/ux-turbo/current/index.html#installation). + +> [!NOTE] +> You only need to have Turbo Frame, you can disable Turbo Drive if you do not use it, or do not want to use it. +> ie: `Turbo.session.drive = false;` + +Simply add a `` around your form: + +```twig + + {{ form(form) }} + +``` + +From here you need two small changes: + +First, in your form type: + - You need to add an attribute on the choice field, so it auto-submits the form when changed (may need to be adapted to your own form if more complex) + - Add a submit button, so in the controller you can differenciate from an auto-submit versus a user action + + +```diff +// src/Form/FeedbackForm.php + +// ... + +class FeedbackForm extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder = new DynamicFormBuilder($builder); + + $builder->add('rating', ChoiceType::class, [ + 'choices' => [ + 'Select a rating' => null, + 'Great' => 5, + 'Good' => 4, + 'Okay' => 3, + 'Bad' => 2, + 'Terrible' => 1 + ], ++ // This will allow the form to auto-submit on value change ++ 'attr' => ['onchange' => 'this.form.requestSubmit()'], + ]); ++ // This will allow to differenciate between a user submition and an auto-submit ++ $builder->add('submit', SubmitType::class, [ ++ 'attr' => ['value' => 'submit'], // Needed for Turbo ++ ]); + + $builder->addDependent('badRatingNotes', 'rating', function(DependentField $field, ?int $rating) { + if (null === $rating || $rating >= 3) { + return; // field not needed + } + + $field->add(TextareaType::class, [ + 'label' => 'What went wrong?', + 'attr' => ['rows' => 3], + 'help' => sprintf('Because you gave a %d rating, we\'d love to know what went wrong.', $rating), + ]); + }); + } +} +``` + +Second, in your controller: + - Specify the action on your form, [this is needed for Turbo Frame](https://symfony.com/bundles/ux-turbo/current/index.html#3-form-response-code-changes) + - Handle the auto-submit by checking if the button has been clicked + +```diff +// src/Controller/FeedbackController.php + + #[Route('/feedback', name: 'feedback')] + public function feedback(Request $request): Response + { + //... + +- $feedbackForm = $this->createForm(FeedbackForm::class); ++ $feedbackForm = $this->createForm(FeedbackForm::class, options: [ ++ // This is needed by Turbo Frame, it is not specific to Dependent Symfony Form Fields ++ 'action' => $this->generateUrl('feedback'), ++ ]); + $feedbackForm->handleRequest($request); + if ($feedbackForm->isSubmitted() && $feedbackForm->isValid()) { + ++ /** @var SubmitButton $submitButton */ ++ $submitButton = $feedbackForm->get('submit'); ++ if (!$submitButton->isClicked()) { ++ return $this->render('feedback.html.twig', ['feedbackForm' => $feedbackForm]); ++ } + + // Your code here + // ... + + return $this->redirectToRoute('home'); + } + + return $this->render('feedback.html.twig', ['feedbackForm' => $feedbackForm]); + } + +``` + +### C) Write custom JavaScript + +If you're not using Live Components, nor Turbo Frames, you'll need to write some custom +JavaScript to listen to the `change` event on the `rating` field and then +make an AJAX call to re-render the form. The AJAX call should submit the +form to its usual endpoint (or any endpoint that will submit the form), take +the HTML response, extract the parts that need to be re-rendered and then replace +the HTML on the page. + +This is a non-trivial task and there may be room for improvement in this +library to make this easier. If you have ideas, please open an issue! diff --git a/lib/symfonycasts/dynamic-forms/composer.json b/lib/symfonycasts/dynamic-forms/composer.json new file mode 100644 index 000000000..c59cfe98a --- /dev/null +++ b/lib/symfonycasts/dynamic-forms/composer.json @@ -0,0 +1,51 @@ +{ + "name": "symfonycasts/dynamic-forms", + "description": "Add dynamic/dependent fields to Symfony forms", + "license": "MIT", + "type": "library", + "keywords": ["symfony", "forms"], + "authors": [ + { + "name": "Ryan Weaver", + "homepage": "https://symfonycasts.com" + } + ], + "require": { + "php": ">=8.1", + "symfony/form": "^5.4|^6.3|^7.0" + }, + "require-dev": { + "symfony/framework-bundle": "^6.3|^7.0", + "symfony/phpunit-bridge": "^5.4.32|^6.3.9|^7.0", + "zenstruck/browser": "^1.4", + "symfony/twig-bundle": "^5.4|^6.3|^7.0", + "twig/twig": "^2.15|^3.0", + "symfony/options-resolver": "^5.4|^6.3|^7.0", + "phpunit/phpunit": "^9.6" + }, + "minimum-stability": "dev", + "autoload": { + "psr-4": { + "Symfonycasts\\DynamicForms\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Symfonycasts\\DynamicForms\\Tests\\": "tests/" + } + }, + "scripts": { + "tools:upgrade": [ + "@tools:upgrade:php-cs-fixer", + "@tools:upgrade:phpstan" + ], + "tools:upgrade:php-cs-fixer": "composer upgrade -W -d tools/php-cs-fixer", + "tools:upgrade:phpstan": "composer upgrade -W -d tools/phpstan", + "tools:run": [ + "@tools:run:php-cs-fixer", + "@tools:run:phpstan" + ], + "tools:run:php-cs-fixer": "tools/php-cs-fixer/vendor/bin/php-cs-fixer fix", + "tools:run:phpstan": "tools/phpstan/vendor/bin/phpstan --memory-limit=1G" + } +} diff --git a/lib/symfonycasts/dynamic-forms/src/DependentField.php b/lib/symfonycasts/dynamic-forms/src/DependentField.php new file mode 100644 index 000000000..0db90585d --- /dev/null +++ b/lib/symfonycasts/dynamic-forms/src/DependentField.php @@ -0,0 +1,46 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfonycasts\DynamicForms; + +/** + * Used to configure a dependent/dynamic field. + * + * If ->add() is not called, the field won't be included. + */ +class DependentField +{ + private ?string $type = null; + private array $options = []; + private bool $shouldBeAdded = false; + + public function add(?string $type = null, array $options = []): static + { + $this->type = $type; + $this->options = $options; + $this->shouldBeAdded = true; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function getOptions(): array + { + return $this->options; + } + + public function shouldBeAdded(): bool + { + return $this->shouldBeAdded; + } +} diff --git a/lib/symfonycasts/dynamic-forms/src/DependentFieldConfig.php b/lib/symfonycasts/dynamic-forms/src/DependentFieldConfig.php new file mode 100644 index 000000000..bddd7d0ec --- /dev/null +++ b/lib/symfonycasts/dynamic-forms/src/DependentFieldConfig.php @@ -0,0 +1,60 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfonycasts\DynamicForms; + +use Symfony\Component\Form\FormEvents; + +/** + * Holds the configuration for a dynamic field & what listeners have been executed. + */ +class DependentFieldConfig +{ + public array $callbackExecuted = [ + FormEvents::PRE_SET_DATA => false, + FormEvents::POST_SUBMIT => false, + ]; + + public function __construct( + public string $name, + public array $dependencies, + public \Closure $callback, + ) { + } + + public function isReady(array $availableDependencyData, string $eventName): bool + { + if (!\array_key_exists($eventName, $this->callbackExecuted)) { + throw new \InvalidArgumentException(\sprintf('Invalid event name "%s"', $eventName)); + } + + if ($this->callbackExecuted[$eventName]) { + return false; + } + + foreach ($this->dependencies as $dependency) { + if (!\array_key_exists($dependency, $availableDependencyData)) { + return false; + } + } + + return true; + } + + public function execute(array $availableDependencyData, string $eventName): DependentField + { + $configurableFormBuilder = new DependentField(); + + $this->callbackExecuted[$eventName] = true; + $dependencyData = array_map(fn (string $dependency) => $availableDependencyData[$dependency], $this->dependencies); + $this->callback->__invoke($configurableFormBuilder, ...$dependencyData); + + return $configurableFormBuilder; + } +} diff --git a/lib/symfonycasts/dynamic-forms/src/DynamicFormBuilder.php b/lib/symfonycasts/dynamic-forms/src/DynamicFormBuilder.php new file mode 100644 index 000000000..adf2a7323 --- /dev/null +++ b/lib/symfonycasts/dynamic-forms/src/DynamicFormBuilder.php @@ -0,0 +1,587 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfonycasts\DynamicForms; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Form\ClearableErrorsInterface; +use Symfony\Component\Form\DataMapperInterface; +use Symfony\Component\Form\DataTransformerInterface; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormConfigInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\RequestHandlerInterface; +use Symfony\Component\Form\ResolvedFormTypeInterface; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * Wraps the normal form builder & to add addDynamic() to it. + * + * @author Ryan Weaver + */ +class DynamicFormBuilder implements FormBuilderInterface, \IteratorAggregate +{ + /** + * @var DependentFieldConfig[] + */ + private array $dependentFieldConfigs = []; + + /** + * The actual form that this builder is turned into. + */ + private FormInterface $form; + + private array $preSetDataDependencyData = []; + private array $postSubmitDependencyData = []; + + public function __construct(private FormBuilderInterface $builder) + { + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { + $this->form = $event->getForm(); + $this->preSetDataDependencyData = []; + $this->initializeListeners(); + + // A fake hidden field where we can "store" an error if a dependent form + // field is suddenly invalid because its previous data was invalid + // and a field it depends on just changed (e.g. user selected "Michigan" + // as a state, then the user changed "Country" from "USA" to "Mexico" + // and so now "Michigan" is invalid). In this case, we clear the error + // on the actual field, but store a "fake" error here, which won't be + // rendered, but will prevent the form from being valid. + if (!$this->form->has('__dynamic_error')) { + $this->form->add('__dynamic_error', HiddenType::class, [ + 'mapped' => false, + 'error_bubbling' => false, + ]); + } + }, 100); + + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) { + $this->postSubmitDependencyData = []; + }); + // guarantee later than core ValidationListener + $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) { + $this->clearDataOnTransformationError($event); + }, -1); + } + + public function addDependent(string $name, string|array $dependencies, callable $callback): self + { + $dependencies = (array) $dependencies; + + $this->dependentFieldConfigs[] = new DependentFieldConfig($name, $dependencies, $callback); + + return $this; + } + + public function storePreSetDataDependencyData(FormEvent $event): void + { + $dependency = $event->getForm()->getName(); + $this->preSetDataDependencyData[$dependency] = $event->getData(); + + $this->executeReadyCallbacks($this->preSetDataDependencyData, FormEvents::PRE_SET_DATA); + } + + public function storePostSubmitDependencyData(FormEvent $event): void + { + $dependency = $event->getForm()->getName(); + $this->postSubmitDependencyData[$dependency] = $event->getForm()->getData(); + + $this->executeReadyCallbacks($this->postSubmitDependencyData, FormEvents::POST_SUBMIT); + } + + public function clearDataOnTransformationError(FormEvent $event): void + { + $form = $event->getForm(); + $transformationErrorsCleared = false; + foreach ($this->dependentFieldConfigs as $dependentFieldConfig) { + if (!$form->has($dependentFieldConfig->name)) { + continue; + } + + $subForm = $form->get($dependentFieldConfig->name); + if ($subForm->getTransformationFailure() && $subForm instanceof ClearableErrorsInterface) { + $subForm->clearErrors(); + $transformationErrorsCleared = true; + } + } + + if ($transformationErrorsCleared) { + // We've cleared the error, but the bad data remains on the field. + // We need to make sure that the form doesn't submit successfully, + // but we also don't want to render a validation error on any field. + // So, we jam the error into a hidden field, which doesn't render errors. + if ($form->get('__dynamic_error')->isValid()) { + $form->get('__dynamic_error')->addError(new FormError('Some dynamic fields have errors.')); + } + } + } + + private function executeReadyCallbacks(array $availableDependencyData, string $eventName): void + { + foreach ($this->dependentFieldConfigs as $dependentFieldConfig) { + if ($dependentFieldConfig->isReady($availableDependencyData, $eventName)) { + $dynamicField = $dependentFieldConfig->execute($availableDependencyData, $eventName); + $name = $dependentFieldConfig->name; + + if (!$dynamicField->shouldBeAdded()) { + $this->form->remove($name); + + continue; + } + + $this->builder->add($name, $dynamicField->getType(), $dynamicField->getOptions()); + + $this->initializeListeners([$name]); + // auto initialize mimics FormBuilder::getForm() behavior + $field = $this->builder->get($name)->setAutoInitialize(false)->getForm(); + $this->form->add($field); + } + } + } + + private function initializeListeners(?array $fieldsToConsider = null): void + { + $registeredFields = []; + foreach ($this->dependentFieldConfigs as $dynamicField) { + foreach ($dynamicField->dependencies as $dependency) { + if ($fieldsToConsider && !\in_array($dependency, $fieldsToConsider)) { + continue; + } + + // skip dependencies that are possibly not *yet* part of the form + if (!$this->builder->has($dependency)) { + continue; + } + + if (\in_array($dependency, $registeredFields)) { + continue; + } + + $registeredFields[] = $dependency; + + $this->builder->get($dependency)->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'storePreSetDataDependencyData']); + $this->builder->get($dependency)->addEventListener(FormEvents::POST_SUBMIT, [$this, 'storePostSubmitDependencyData']); + } + } + } + + /* + * ---------------------------------------- + * + * Pure decoration methods below. + * + * ---------------------------------------- + */ + + public function count(): int + { + return $this->builder->count(); + } + + /** + * @param string|FormBuilderInterface $child + */ + public function add($child, ?string $type = null, array $options = []): static + { + $this->builder->add($child, $type, $options); + + return $this; + } + + public function create(string $name, ?string $type = null, array $options = []): FormBuilderInterface + { + return $this->builder->create($name, $type, $options); + } + + public function get(string $name): FormBuilderInterface + { + return $this->builder->get($name); + } + + public function remove(string $name): static + { + $this->builder->remove($name); + + return $this; + } + + public function has(string $name): bool + { + return $this->builder->has($name); + } + + public function all(): array + { + return $this->builder->all(); + } + + public function getForm(): FormInterface + { + return $this->builder->getForm(); + } + + public function addEventListener(string $eventName, callable $listener, int $priority = 0): static + { + $this->builder->addEventListener($eventName, $listener, $priority); + + return $this; + } + + public function addEventSubscriber(EventSubscriberInterface $subscriber): static + { + $this->builder->addEventSubscriber($subscriber); + + return $this; + } + + public function addViewTransformer(DataTransformerInterface $viewTransformer, bool $forcePrepend = false): static + { + $this->builder->addViewTransformer($viewTransformer, $forcePrepend); + + return $this; + } + + public function resetViewTransformers(): static + { + $this->builder->resetViewTransformers(); + + return $this; + } + + public function addModelTransformer(DataTransformerInterface $modelTransformer, bool $forceAppend = false): static + { + $this->builder->addModelTransformer($modelTransformer, $forceAppend); + + return $this; + } + + public function resetModelTransformers(): static + { + $this->builder->resetModelTransformers(); + + return $this; + } + + public function setAttribute(string $name, mixed $value): static + { + $this->builder->setAttribute($name, $value); + + return $this; + } + + public function setAttributes(array $attributes): static + { + $this->builder->setAttributes($attributes); + + return $this; + } + + public function setDataMapper(?DataMapperInterface $dataMapper = null): static + { + $this->builder->setDataMapper($dataMapper); + + return $this; + } + + public function setDisabled(bool $disabled): static + { + $this->builder->setDisabled($disabled); + + return $this; + } + + public function setEmptyData(mixed $emptyData): static + { + $this->builder->setEmptyData($emptyData); + + return $this; + } + + public function setErrorBubbling(bool $errorBubbling): static + { + $this->builder->setErrorBubbling($errorBubbling); + + return $this; + } + + public function setInheritData(bool $inheritData): static + { + $this->builder->setInheritData($inheritData); + + return $this; + } + + public function setMapped(bool $mapped): static + { + $this->builder->setMapped($mapped); + + return $this; + } + + public function setMethod(string $method): static + { + $this->builder->setMethod($method); + + return $this; + } + + /** + * @param string|PropertyPathInterface|null $propertyPath + */ + public function setPropertyPath($propertyPath): static + { + $this->builder->setPropertyPath($propertyPath); + + return $this; + } + + public function setRequired(bool $required): static + { + $this->builder->setRequired($required); + + return $this; + } + + public function setAction(?string $action): static + { + $this->builder->setAction($action); + + return $this; + } + + public function setCompound(bool $compound): static + { + $this->builder->setCompound($compound); + + return $this; + } + + public function setDataLocked(bool $locked): static + { + $this->builder->setDataLocked($locked); + + return $this; + } + + public function setFormFactory(FormFactoryInterface $formFactory): static + { + $this->builder->setFormFactory($formFactory); + + return $this; + } + + public function setType(?ResolvedFormTypeInterface $type): static + { + $this->builder->setType($type); + + return $this; + } + + public function setRequestHandler(?RequestHandlerInterface $requestHandler): static + { + $this->builder->setRequestHandler($requestHandler); + + return $this; + } + + public function getAttribute(string $name, mixed $default = null): mixed + { + return $this->builder->getAttribute($name, $default); + } + + public function hasAttribute(string $name): bool + { + return $this->builder->hasAttribute($name); + } + + public function getAttributes(): array + { + return $this->builder->getAttributes(); + } + + public function getDataMapper(): ?DataMapperInterface + { + return $this->builder->getDataMapper(); + } + + public function getEventDispatcher(): EventDispatcherInterface + { + return $this->builder->getEventDispatcher(); + } + + public function getName(): string + { + return $this->builder->getName(); + } + + public function getPropertyPath(): ?PropertyPathInterface + { + return $this->builder->getPropertyPath(); + } + + public function getRequestHandler(): RequestHandlerInterface + { + return $this->builder->getRequestHandler(); + } + + public function getType(): ResolvedFormTypeInterface + { + return $this->builder->getType(); + } + + public function setByReference(bool $byReference): static + { + $this->builder->setByReference($byReference); + + return $this; + } + + public function setData(mixed $data): static + { + $this->builder->setData($data); + + return $this; + } + + public function setAutoInitialize(bool $initialize): static + { + $this->builder->setAutoInitialize($initialize); + + return $this; + } + + public function getFormConfig(): FormConfigInterface + { + return $this->builder->getFormConfig(); + } + + public function setIsEmptyCallback(?callable $isEmptyCallback): static + { + $this->builder->setIsEmptyCallback($isEmptyCallback); + + return $this; + } + + public function getMapped(): bool + { + return $this->builder->getMapped(); + } + + public function getByReference(): bool + { + return $this->builder->getByReference(); + } + + public function getInheritData(): bool + { + return $this->builder->getInheritData(); + } + + public function getCompound(): bool + { + return $this->builder->getCompound(); + } + + public function getViewTransformers(): array + { + return $this->builder->getViewTransformers(); + } + + public function getModelTransformers(): array + { + return $this->builder->getModelTransformers(); + } + + public function getRequired(): bool + { + return $this->builder->getRequired(); + } + + public function getDisabled(): bool + { + return $this->builder->getDisabled(); + } + + public function getErrorBubbling(): bool + { + return $this->builder->getErrorBubbling(); + } + + public function getEmptyData(): mixed + { + return $this->builder->getEmptyData(); + } + + public function getData(): mixed + { + return $this->builder->getData(); + } + + public function getDataClass(): ?string + { + return $this->builder->getDataClass(); + } + + public function getDataLocked(): bool + { + return $this->builder->getDataLocked(); + } + + public function getFormFactory(): FormFactoryInterface + { + return $this->builder->getFormFactory(); + } + + public function getAction(): string + { + return $this->builder->getAction(); + } + + public function getMethod(): string + { + return $this->builder->getMethod(); + } + + public function getAutoInitialize(): bool + { + return $this->builder->getAutoInitialize(); + } + + public function getOptions(): array + { + return $this->builder->getOptions(); + } + + public function hasOption(string $name): bool + { + return $this->builder->hasOption($name); + } + + public function getOption(string $name, mixed $default = null): mixed + { + return $this->builder->getOption($name, $default); + } + + public function getIsEmptyCallback(): ?callable + { + return $this->builder->getIsEmptyCallback(); + } + + public function getIterator(): \Traversable + { + return $this->builder; + } +} diff --git a/sources/FormType/Orm/AttCodeGroupByType.php b/sources/FormType/Orm/AttCodeGroupByType.php index 1b2421cb0..ef705f66f 100644 --- a/sources/FormType/Orm/AttCodeGroupByType.php +++ b/sources/FormType/Orm/AttCodeGroupByType.php @@ -6,84 +6,51 @@ namespace Combodo\iTop\FormType\Orm; -use Combodo\iTop\FormType\Base\HiddenType; use Dict; use Exception; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType as SymfonyChoiceType; -use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; -use Symfony\Component\Form\FormInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfonycasts\DynamicForms\DependentField; class AttCodeGroupByType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) + public function getParent() { - $builder->add('hidden', HiddenType::class, ['mapped' => false]); - $sRelatedNode = $options['query_source']; - $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($sRelatedNode): void { - $oForm = $event->getForm(); - $sCurrentValue = $oForm->getParent()->get($sRelatedNode)->getData(); - $this->BuildSubField($oForm, $sCurrentValue); - }); - - $builder->get('hidden')->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($sRelatedNode): void { - $oForm = $event->getForm()->getParent(); - $sCurrentValue = $oForm->getParent()->get($sRelatedNode)->getData(); - $this->BuildSubField($oForm, $sCurrentValue); - }); + return SymfonyChoiceType::class; // TODO: Change the autogenerated stub } - public function configureOptions(OptionsResolver $resolver) + public static function BuildSubField(DependentField $oDependentField, string $sQuery, array $aFormOptions = []): void { - parent::configureOptions($resolver); - $resolver->setRequired('query_source'); - $resolver->setAllowedTypes('query_source', 'string'); - } - - public function BuildSubField(FormInterface $oForm, string $sQuery): void - { - $aData = $oForm->getParent()->getData(); - \IssueLog::Info('Form Data: '.var_export($aData, true)); - - //$aFormOptions['inherit_data'] = true; - $aFormOptions['choices'] = $this->GetGroupByOptions($sQuery); + $aFormOptions['choices'] = self::GetGroupByOptions($sQuery); $aFormOptions['multiple'] = false; - - // create the field, this is similar the $builder->add() - // field name, field type, field options - $oForm->add('selected', SymfonyChoiceType::class, $aFormOptions); + $oDependentField->add(AttCodeGroupByType::class, $aFormOptions); } - protected $oModelReflection; - - protected function GetGroupByOptions($sOql) + protected static function GetGroupByOptions($sOql) { - $this->oModelReflection = new \ModelReflectionRuntime(); + $oModelReflection = new \ModelReflectionRuntime(); $aGroupBy = array(); try { - $oQuery = $this->oModelReflection->GetQuery($sOql); + $oQuery = $oModelReflection->GetQuery($sOql); $sClass = $oQuery->GetClass(); - foreach($this->oModelReflection->ListAttributes($sClass) as $sAttCode => $sAttType) + foreach($oModelReflection->ListAttributes($sClass) as $sAttCode => $sAttType) { // For external fields, find the real type of the target $sExtFieldAttCode = $sAttCode; $sTargetClass = $sClass; while (is_a($sAttType, 'AttributeExternalField', true)) { - $sExtKeyAttCode = $this->oModelReflection->GetAttributeProperty($sTargetClass, $sExtFieldAttCode, 'extkey_attcode'); - $sTargetAttCode = $this->oModelReflection->GetAttributeProperty($sTargetClass, $sExtFieldAttCode, 'target_attcode'); - $sTargetClass = $this->oModelReflection->GetAttributeProperty($sTargetClass, $sExtKeyAttCode, 'targetclass'); - $aTargetAttCodes = $this->oModelReflection->ListAttributes($sTargetClass); + $sExtKeyAttCode = $oModelReflection->GetAttributeProperty($sTargetClass, $sExtFieldAttCode, 'extkey_attcode'); + $sTargetAttCode = $oModelReflection->GetAttributeProperty($sTargetClass, $sExtFieldAttCode, 'target_attcode'); + $sTargetClass = $oModelReflection->GetAttributeProperty($sTargetClass, $sExtKeyAttCode, 'targetclass'); + $aTargetAttCodes = $oModelReflection->ListAttributes($sTargetClass); $sAttType = $aTargetAttCodes[$sTargetAttCode]; $sExtFieldAttCode = $sTargetAttCode; } - $aForbidenAttType = [ + $aForbiddenAttType = [ 'AttributeLinkedSet', 'AttributeFriendlyName', @@ -92,14 +59,14 @@ class AttCodeGroupByType extends AbstractType 'AttributeEncryptedString', 'AttributePassword', ]; - foreach ($aForbidenAttType as $sForbidenAttType) { - if (is_a($sAttType, $sForbidenAttType, true)) + foreach ($aForbiddenAttType as $sForbiddenAttType) { + if (is_a($sAttType, $sForbiddenAttType, true)) { continue 2; } } - $sLabel = $this->oModelReflection->GetLabel($sClass, $sAttCode); + $sLabel = $oModelReflection->GetLabel($sClass, $sAttCode); if (!in_array($sLabel, $aGroupBy)) { $aGroupBy[$sAttCode] = $sLabel; diff --git a/sources/FormType/Orm/ValuesFromAttcodeType.php b/sources/FormType/Orm/ValuesFromAttcodeType.php index 6881360e9..a04096802 100644 --- a/sources/FormType/Orm/ValuesFromAttcodeType.php +++ b/sources/FormType/Orm/ValuesFromAttcodeType.php @@ -6,57 +6,29 @@ namespace Combodo\iTop\FormType\Orm; -use Combodo\iTop\FormType\Base\HiddenType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType as SymfonyChoiceType; -use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; -use Symfony\Component\Form\FormInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfonycasts\DynamicForms\DependentField; class ValuesFromAttcodeType extends AbstractType { - public function buildForm(FormBuilderInterface $builder, array $options) + public function getParent() { - $builder->add('hidden', HiddenType::class, ['mapped' => false]); - $sAttCodeType = $options['attcode_source']; - $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($sAttCodeType): void { - $oForm = $event->getForm(); - $sAttCode = $oForm->getParent()->get($sAttCodeType)->get('selected')->getData(); - $this->BuildSubField($oForm, $sAttCode); - }); - - $builder->get('hidden')->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($sAttCodeType): void { - $oForm = $event->getForm()->getParent(); - $sAttCode = $oForm->getParent()->get($sAttCodeType)->get('selected')->getData(); - $this->BuildSubField($oForm, $sAttCode); - }); + return SymfonyChoiceType::class; } - public function configureOptions(OptionsResolver $resolver) + public static function BuildSubField(DependentField $oDependentField, string $sQuery, string $sGroupByAttCode, array $aFormOptions = []): void { - parent::configureOptions($resolver); - $resolver->setRequired('attcode_source'); - $resolver->setAllowedTypes('attcode_source', 'string'); - } - - public function BuildSubField(FormInterface $oForm, string $sAttCode): void - { - $aData = $oForm->getParent()->getData(); - \IssueLog::Info('Form Data: '.var_export($aData, true)); - - $sQuery = $aData['query']; - $sClass = \DBSearch::FromOQL($sQuery)->GetClass(); - $oAttDef = \MetaModel::GetAttributeDef($sClass, $sAttCode); + $oModelReflection = new \ModelReflectionRuntime(); + $oQuery = $oModelReflection->GetQuery($sQuery); + $sClass = $oQuery->GetClass(); + $oAttDef = \MetaModel::GetAttributeDef($sClass, $sGroupByAttCode); //$aFormOptions['inherit_data'] = true; - $aFormOptions['choices'] = array_flip($oAttDef->GetAllowedValues()); + $aFormOptions['choices'] = array_flip($oAttDef->GetAllowedValues() ?? []); $aFormOptions['multiple'] = true; - // create the field, this is similar the $builder->add() - // field name, field type, field options - $oForm->add('selected', SymfonyChoiceType::class, $aFormOptions); + $oDependentField->add(ValuesFromAttcodeType::class, $aFormOptions); } } \ No newline at end of file