From cbcf327ee8be67e3e660ee45affd075bd4c703c5 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:56:43 +0000 Subject: [PATCH 01/35] Progress --- .devcontainer/Dockerfile | 21 ++ .devcontainer/devcontainer.json | 12 + .gitignore | 6 +- .php-cs-fixer.dist.php | 85 ++++-- README.md | 53 +--- composer.json | 37 +-- config/services.php | 32 +++ phpunit.xml.dist | 53 ++-- psalm.xml | 46 +--- rector.php | 55 ++-- src/A2lixAutoFormBundle.php | 33 ++- .../A2lixAutoFormExtension.php | 38 --- src/DependencyInjection/Configuration.php | 42 --- src/Form/Attribute/AutoTypeCustom.php | 50 ++++ src/Form/Builder/AutoTypeBuilder.php | 255 ++++++++++++++++++ src/Form/EventListener/AutoFormListener.php | 46 ---- .../Manipulator/DoctrineORMManipulator.php | 117 -------- .../Manipulator/FormManipulatorInterface.php | 21 -- src/Form/Type/AutoFormType.php | 48 ---- src/Form/Type/AutoType.php | 73 +++++ src/ObjectInfo/DoctrineORMInfo.php | 93 ------- src/Resources/config/a2lix_form.xml | 28 -- src/Resources/config/object_info.xml | 18 -- tests/Form/Type/AutoFormTypeAdvancedTest.php | 150 ----------- tests/Form/Type/AutoFormTypeSimpleTest.php | 132 --------- tests/Form/Type/AutoTypeDtoTest.php | 30 +++ tests/Form/Type/AutoTypeEntityTest.php | 29 ++ tests/Form/TypeTestCase.php | 91 ++++--- 28 files changed, 730 insertions(+), 964 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 config/services.php delete mode 100644 src/DependencyInjection/A2lixAutoFormExtension.php delete mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/Form/Attribute/AutoTypeCustom.php create mode 100644 src/Form/Builder/AutoTypeBuilder.php delete mode 100644 src/Form/EventListener/AutoFormListener.php delete mode 100644 src/Form/Manipulator/DoctrineORMManipulator.php delete mode 100644 src/Form/Manipulator/FormManipulatorInterface.php delete mode 100644 src/Form/Type/AutoFormType.php create mode 100644 src/Form/Type/AutoType.php delete mode 100644 src/ObjectInfo/DoctrineORMInfo.php delete mode 100644 src/Resources/config/a2lix_form.xml delete mode 100644 src/Resources/config/object_info.xml delete mode 100755 tests/Form/Type/AutoFormTypeAdvancedTest.php delete mode 100755 tests/Form/Type/AutoFormTypeSimpleTest.php create mode 100755 tests/Form/Type/AutoTypeDtoTest.php create mode 100755 tests/Form/Type/AutoTypeEntityTest.php diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..f92a375 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,21 @@ +FROM php:8.4-cli-trixie + +RUN apt-get update && apt-get install -y --no-install-recommends \ + file \ + git \ + && rm -rf /var/lib/apt/lists/* + +ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ + +RUN set -eux; \ + install-php-extensions \ + @composer \ + # apcu \ + # opcache \ + xdebug \ + ; + +RUN useradd -m vscode + +USER vscode +WORKDIR /workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b553784 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,12 @@ +{ + "name": "PHP CLI", + "build": { + "dockerfile": "Dockerfile" + }, + "customizations": { + "vscode": { + "extensions": [ "bmewburn.vscode-intelephense-client", "xdebug.php-debug", "getpsalm.psalm-vscode-plugin" ] + } + }, + "remoteUser": "vscode" +} diff --git a/.gitignore b/.gitignore index 83edab6..e7c4bef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,4 @@ -.php_cs.cache .php-cs-fixer.cache -psalm-phpqa.xml -.phpunit.result.cache +.phpunit.cache composer.lock vendor/* - -.DS_Store diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 7b8eb43..12cd6ec 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -18,34 +18,73 @@ ->setRiskyAllowed(true) ->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers()) ->setRules([ - '@PHP82Migration' => true, + '@DoctrineAnnotation' => true, + '@PHP82Migration:risky' => true, + '@PHP84Migration' => true, + '@PHPUnit100Migration:risky' => true, '@PhpCsFixer' => true, '@PhpCsFixer:risky' => true, + '@Symfony' => true, + '@Symfony:risky' => true, - // From https://github.com/symfony/demo/blob/main/.php-cs-fixer.dist.php - 'linebreak_after_opening_tag' => true, - // 'mb_str_functions' => true, - 'no_php4_constructor' => true, - 'no_unreachable_default_argument_value' => true, - 'no_useless_else' => true, - 'no_useless_return' => true, - 'php_unit_strict' => false, - 'php_unit_internal_class' => false, - 'php_unit_test_class_requires_covers' => false, - 'phpdoc_order' => true, - 'strict_comparison' => true, - 'strict_param' => true, - 'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arrays', 'parameters']], - 'statement_indentation' => true, - 'method_chaining_indentation' => true, + 'header_comment' => ['header' => $header], + 'class_attributes_separation' => ['elements' => ['method' => 'one']], + 'class_definition' => ['inline_constructor_arguments' => true], + 'date_time_immutable' => true, + 'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false], 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline', 'attribute_placement' => 'ignore'], + 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], + 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'allow_unused_params' => true], + 'nullable_type_declaration_for_default_null_value' => ['use_nullable_type_declaration' => true], + 'numeric_literal_separator' => true, + 'operator_linebreak' => ['only_booleans' => true, 'position' => 'beginning'], + 'ordered_imports' => ['sort_algorithm' => 'alpha', 'imports_order' => ['class', 'function', 'const']], + 'php_unit_data_provider_name' => true, + 'php_unit_data_provider_return_type' => true, + 'php_unit_data_provider_static' => true, + 'php_unit_dedicate_assert' => ['target' => 'newest'], + 'php_unit_method_casing' => ['case' => 'camel_case'], + 'phpdoc_array_type' => true, + 'phpdoc_list_type' => true, + 'phpdoc_param_order' => true, + 'phpdoc_to_property_type' => ['scalar_types' => true], + 'phpdoc_to_return_type' => ['scalar_types' => true], + 'phpdoc_var_without_name' => true, + 'single_line_throw' => true, + 'statement_indentation' => true, + 'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arrays', 'parameters']], + 'use_arrow_functions' => true, + 'void_return' => true, - PhpCsFixerCustomFixers\Fixer\ConstructorEmptyBracesFixer::name() => true, - PhpCsFixerCustomFixers\Fixer\MultilineCommentOpeningClosingAloneFixer::name() => true, - PhpCsFixerCustomFixers\Fixer\MultilinePromotedPropertiesFixer::name() => true, - PhpCsFixerCustomFixers\Fixer\NoDuplicatedImportsFixer::name() => true, - PhpCsFixerCustomFixers\Fixer\NoImportFromGlobalNamespaceFixer::name() => true, - PhpCsFixerCustomFixers\Fixer\PhpdocSingleLineVarFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\ClassConstantUsageFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\ConstructorEmptyBracesFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\CommentSurroundedBySpacesFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\DeclareAfterOpeningTagFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\EmptyFunctionBodyFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\MultilineCommentOpeningClosingAloneFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\MultilinePromotedPropertiesFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\NoDoctrineMigrationsGeneratedCommentFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\NoDuplicatedArrayKeyFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\NoUselessCommentFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\NoUselessDirnameCallFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\NoUselessDoctrineRepositoryCommentFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\NoUselessParenthesisFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\NoUselessStrlenFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\NoUselessWriteVisibilityFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PhpUnitAssertArgumentsOrderFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PhpUnitNoUselessReturnFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PhpUnitRequiresConstraintFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PhpdocNoSuperfluousParamFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PhpdocSelfAccessorFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PhpdocTypesCommaSpacesFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PhpdocTypesTrimFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PromotedConstructorPropertyFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\ReadonlyPromotedPropertiesFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\SingleSpaceAfterStatementFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\SingleSpaceBeforeStatementFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\StringableInterfaceFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\TrimKeyFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\TypedClassConstantFixer::name() => true, ]) ->setFinder($finder) ; diff --git a/README.md b/README.md index 3ef8110..cf6fa2e 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,6 @@ Use composer: composer require a2lix/auto-form-bundle ``` -After the successful installation, add/check the bundle registration: - -```php -// bundles.php is automatically updated if flex is installed. -// ... -A2lix\AutoFormBundle\A2lixAutoFormBundle::class => ['all' => true], -// ... -``` - ## Configuration There is no minimal configuration, so this part is optional. Full list: @@ -39,46 +30,14 @@ There is no minimal configuration, so this part is optional. Full list: # Create a dedicated a2lix.yaml in config/packages with: a2lix_auto_form: - excluded_fields: [id, locale, translatable] # [1] + children_excluded: [id] # [1] ``` 1. Optional. ## Usage -### In a classic formType - -```php -use A2lix\AutoFormBundle\Form\Type\AutoFormType; -... -$builder->add('medias', AutoFormType::class); -``` - -### Advanced examples - -```php -use A2lix\AutoFormBundle\Form\Type\AutoFormType; -... -$builder->add('medias', AutoFormType::class, [ - 'fields' => [ // [2] - 'description' => [ // [3.a] - 'field_type' => 'textarea', // [4] - 'label' => 'descript.', // [4] - 'locale_options' => [ // [3.b] - 'es' => ['label' => 'descripción'] // [4] - 'fr' => ['display' => false] // [4] - ] - ] - ], - 'excluded_fields' => ['details'] // [2] -]); -``` - -2. Optional. If set, override the default value from config.yml -3. Optional. If set, override the auto configuration of fields - - [3.a] Optional. - For a field, applied to all locales - - [3.b] Optional. - For a specific locale of a field -4. Optional. Common options of symfony forms (max_length, required, trim, read_only, constraints, ...), which was added 'field_type' and 'display' +TODO ## Additional @@ -86,14 +45,6 @@ $builder->add('medias', AutoFormType::class, [ See [Demo Bundle](https://github.com/a2lix/Demo) for more examples. -## Contribution help - -``` -docker run --rm --interactive --tty --volume $PWD:/app --user $(id -u):$(id -g) composer install --ignore-platform-reqs -docker run --rm --interactive --tty --volume $PWD:/app --user $(id -u):$(id -g) composer run-script phpunit -docker run --rm --interactive --tty --volume $PWD:/app --user $(id -u):$(id -g) composer run-script cs-fixer -``` - ## License This package is available under the [MIT license](LICENSE). diff --git a/composer.json b/composer.json index 8c55b73..c7f903e 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "authors": [ { "name": "David ALLIX", - "homepage": "http://a2lix.fr" + "homepage": "https://a2lix.fr" }, { "name": "Contributors", @@ -16,24 +16,25 @@ } ], "require": { - "php": "^8.1", - "doctrine/persistence": "^2.0|^3.0|^4.0", - "symfony/config": "^5.4.30|^6.3|^7.0", - "symfony/dependency-injection": "^5.4.30|^6.3|^7.0", - "symfony/doctrine-bridge": "^5.4.30|^6.3|^7.0", - "symfony/form": "^5.4.30|^6.3|^7.0", - "symfony/http-kernel": "^5.4.30|^6.3|^7.0" + "php": ">=8.2", + "symfony/config": "^6.4.20|7.3", + "symfony/dependency-injection": "^6.4.20|7.3", + "symfony/doctrine-bridge": "^6.4.20|7.3", + "symfony/form": "^6.4.20|7.3", + "symfony/http-kernel": "^6.4.20|7.3", + "symfony/property-info": "^6.4.20|7.3", + "phpdocumentor/reflection-docblock": "^5.6" }, "require-dev": { - "doctrine/orm": "^2.15|^3.0", - "friendsofphp/php-cs-fixer": "^3.45", - "kubawerlos/php-cs-fixer-custom-fixers": "^3.18", - "phpstan/phpstan": "^1.10", - "rector/rector": "^0.18", - "symfony/cache": "^5.4.30|^6.3|^7.0", - "symfony/phpunit-bridge": "^5.4.30|^6.3|^7.0", - "symfony/validator": "^5.4.30|^6.3|^7.0", - "vimeo/psalm": "^5.18" + "doctrine/orm": "^3.5.2", + "friendsofphp/php-cs-fixer": "^3.87.2", + "kubawerlos/php-cs-fixer-custom-fixers": "^3.34", + "phpunit/phpunit": "^12.3.13", + "rector/rector": "^2.1.7", + "symfony/cache": "^6.4.20|7.3", + "symfony/validator": "^6.4.20|7.3", + "symfony/var-dumper": "^7.3", + "vimeo/psalm": "^6.13.1" }, "suggest": { "a2lix/translation-form-bundle": "For translation form" @@ -46,7 +47,7 @@ "psalm" ], "phpunit": [ - "SYMFONY_DEPRECATIONS_HELPER=max[self]=0 simple-phpunit" + "phpunit" ] }, "config": { diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..c2daa46 --- /dev/null +++ b/config/services.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\DependencyInjection\Loader\Configurator; + +use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; +use A2lix\AutoFormBundle\Form\Type\AutoType; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('a2lix_auto_form.form.builder.auto_type_builder', AutoTypeBuilder::class) + ->args([ + service('property_info'), + ]) + ->set('a2lix_auto_form.form.type.auto_type', AutoType::class) + ->args([ + service('a2lix_auto_form.form.builder.auto_type_builder'), + null, // children_excluded config option + ]) + ->tag('form.type') + ; +}; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9afc01d..541b739 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,35 +1,30 @@ - - - - - src/ - - - src/Resources - - - - - - - + cacheDirectory=".phpunit.cache" + executionOrder="depends,defects" + requireCoverageMetadata="true" + beStrictAboutCoverageMetadata="true" + beStrictAboutOutputDuringTests="true" + displayDetailsOnPhpunitDeprecations="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true"> + + + tests + + - - - + + + src + + - - - tests/ - tests/Fixtures - tests/tmp - - + + + + diff --git a/psalm.xml b/psalm.xml index c385516..bb222f1 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,52 +1,16 @@ + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/rector.php b/rector.php index e6244a4..7db008f 100644 --- a/rector.php +++ b/rector.php @@ -3,35 +3,44 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Core\ValueObject\PhpVersion; use Rector\Doctrine\Set\DoctrineSetList; -use Rector\PHPUnit\Set\PHPUnitLevelSetList; +use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector; use Rector\PHPUnit\Set\PHPUnitSetList; -use Rector\Set\ValueObject\LevelSetList; -use Rector\Symfony\Set\SymfonyLevelSetList; use Rector\Symfony\Set\SymfonySetList; -use Rector\Symfony\Set\TwigSetList; +use Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector; -return static function (RectorConfig $rectorConfig): void { - $rectorConfig->parallel(); - $rectorConfig->paths([ - __DIR__.'/src', - __DIR__.'/tests', - ]); - $rectorConfig->importNames(); - $rectorConfig->importShortClasses(false); - - $rectorConfig->phpVersion(PhpVersion::PHP_82); - $rectorConfig->sets([ +return RectorConfig::configure() + ->withParallel() + ->withPaths([ + __DIR__ . '/config', + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withRootFiles() + ->withImportNames(importShortClasses: false) + ->withTypeCoverageLevel(0) + ->withDeadCodeLevel(0) + ->withCodeQualityLevel(0) + ->withRules([ + AddVoidReturnTypeWhereNoReturnRector::class, + ]) + ->withPhpSets() + ->withSets([ LevelSetList::UP_TO_PHP_82, - DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES, - // DoctrineSetList::DOCTRINE_CODE_QUALITY, - DoctrineSetList::DOCTRINE_ORM_214, - DoctrineSetList::DOCTRINE_DBAL_30, + SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES, + DoctrineSetList::GEDMO_ANNOTATIONS_TO_ATTRIBUTES, + PHPUnitSetList::PHPUNIT_110, PHPUnitLevelSetList::UP_TO_PHPUNIT_91, - // PHPUnitSetList::PHPUNIT_CODE_QUALITY, - // PHPUnitSetList::PHPUNIT_YIELD_DATA_PROVIDER, + ]) + ->withAttributesSets(all: true) + ->withComposerBased(doctrine: true, phpunit: true, symfony: true) + ->withConfiguredRule(ClassPropertyAssignToConstructorPromotionRector::class, [ + 'inline_public' => true, + ]) + ->withSkip([ + ClassPropertyAssignToConstructorPromotionRector::class => [ + __DIR__ . '/src/Entity/*', + ], ]); -}; diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php index 9a1c442..c3ad0e7 100644 --- a/src/A2lixAutoFormBundle.php +++ b/src/A2lixAutoFormBundle.php @@ -13,6 +13,35 @@ namespace A2lix\AutoFormBundle; -use Symfony\Component\HttpKernel\Bundle\Bundle; +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; -class A2lixAutoFormBundle extends Bundle {} +final class A2lixAutoFormBundle extends AbstractBundle +{ + #[\Override] + public function configure(DefinitionConfigurator $definition): void + { + $definition->rootNode() + ->children() + ->arrayNode('children_excluded') + ->scalarPrototype()->end() + ->defaultValue(['id']) + ->info('Class properties to exclude from autoType children. (Default: id)') + ->end() + ->end() + ; + } + + #[\Override] + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('../config/services.php'); + + $container->services() + ->get('a2lix_auto_form.form.type.auto_type') + ->arg(1, $config['children_excluded']) + ; + } +} diff --git a/src/DependencyInjection/A2lixAutoFormExtension.php b/src/DependencyInjection/A2lixAutoFormExtension.php deleted file mode 100644 index 4dddffd..0000000 --- a/src/DependencyInjection/A2lixAutoFormExtension.php +++ /dev/null @@ -1,38 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\DependencyInjection; - -use Symfony\Component\Config\Definition\Processor; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; -use Symfony\Component\HttpKernel\DependencyInjection\Extension; - -class A2lixAutoFormExtension extends Extension -{ - public function load(array $configs, ContainerBuilder $container): void - { - $processor = new Processor(); - $config = $processor->processConfiguration(new Configuration(), $configs); - - $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('a2lix_form.xml'); - $loader->load('object_info.xml'); - - $definition = $container->getDefinition('a2lix_auto_form.form.manipulator.doctrine_orm_manipulator'); - $definition->replaceArgument(1, $config['excluded_fields']); - - $container->setAlias('a2lix_auto_form.manipulator.default', 'a2lix_auto_form.form.manipulator.doctrine_orm_manipulator'); - } -} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php deleted file mode 100644 index ef33d2d..0000000 --- a/src/DependencyInjection/Configuration.php +++ /dev/null @@ -1,42 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\DependencyInjection; - -use Symfony\Component\Config\Definition\Builder\TreeBuilder; -use Symfony\Component\Config\Definition\ConfigurationInterface; - -class Configuration implements ConfigurationInterface -{ - public function getConfigTreeBuilder(): TreeBuilder - { - $treeBuilder = new TreeBuilder('a2lix_auto_form'); - $rootNode = method_exists(TreeBuilder::class, 'getRootNode') ? $treeBuilder->getRootNode() : $treeBuilder->root('a2lix_auto_form'); - - $rootNode - ->children() - ->arrayNode('excluded_fields') - ->defaultValue(['id', 'locale', 'translatable']) - ->beforeNormalization() - ->ifString() - ->then(static fn ($v) => preg_split('/\s*,\s*/', (string) $v)) - ->end() - ->prototype('scalar') - ->info('Global list of fields to exclude from form generation. (Default: id, locale, translatable)')->end() - ->end() - ->end() - ; - - return $treeBuilder; - } -} diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php new file mode 100644 index 0000000..bdf3816 --- /dev/null +++ b/src/Form/Attribute/AutoTypeCustom.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 A2lix\AutoFormBundle\Form\Attribute; + +use A2lix\AutoFormBundle\Form\Type\AutoType; + +/** + * @psalm-import-type childOptions from AutoType + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final readonly class AutoTypeCustom +{ + /** + * @param array $options + * @param class-string|null $type + */ + public function __construct( + private array $options = [], + private ?string $type = null, + private ?string $name = null, + private ?bool $excluded = null, + private ?bool $embedded = null, + ) { + } + + /** + * @return childOptions + */ + public function getOptions(): array + { + return [ + ...$this->options, + ...(null !== $this->type ? ['child_type' => $this->type] : []), + ...(null !== $this->name ? ['child_name' => $this->name] : []), + ...(null !== $this->excluded ? ['child_excluded' => $this->excluded] : []), + ...(null !== $this->embedded ? ['child_embedded' => $this->embedded] : []), + ]; + } +} \ No newline at end of file diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php new file mode 100644 index 0000000..ce938e4 --- /dev/null +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -0,0 +1,255 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\AutoFormBundle\Form\Builder; + +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; +use Symfony\Component\Form\FormInterface; +use A2lix\AutoFormBundle\Form\Type\AutoType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\TypeInfo\Type as TypeInfo; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * @psalm-import-type formOptionsDefaults from AutoType + * @psalm-import-type childOptions from AutoType + */ +class AutoTypeBuilder +{ + public function __construct( + private readonly PropertyInfoExtractorInterface $propertyInfoExtractor, + ) {} + + /** + * @param formOptionsDefaults $formOptions + */ + public function buildChildren(FormBuilderInterface $builder, array $formOptions): void + { + $dataClass = $this->getDataClass($builder->getForm()); + + if (null === $classProperties = $this->propertyInfoExtractor->getProperties($dataClass)) { + throw new \RuntimeException(sprintf('Unable to extract properties of "%s".', $dataClass)); + } + + $refClass = new \ReflectionClass($dataClass); + $allChildrenExcluded = '*' === $formOptions['children_excluded']; + $allChildrenEmbedded = '*' === $formOptions['children_embedded']; + + foreach ($classProperties as $classProperty) { + // Issue: DateTimeImmutable PHP8.4 + if (!$refClass->hasProperty($classProperty)) { + continue; + } + + // if (!$this->propertyInfoExtractor->isWritable($dataClass, $classProperty)) { + // continue; + // } + + $propFormOptions = $formOptions['children'][$classProperty] ?? null; + + $refProperty = $refClass->getProperty($classProperty); + $propAttributeOptions = ($refProperty->getAttributes(AutoTypeCustom::class)[0] ?? null) + ?->newInstance()?->getOptions() ?? []; + + // FORM.children[PROP] callable? Add early + if (is_callable($propFormOptions)) { + /** @var FormBuilderInterface */ + $childBuilder = ($propFormOptions)($builder, $propAttributeOptions); + $this->addChild($builder, $childBuilder); + unset($formOptions['children'][$classProperty]); + continue; + } + + // FORM.children[PROP].child_excluded? Continue early + /** @psalm-suppress RiskyTruthyFalsyComparison */ + if ($propFormOptions['child_excluded'] ?? false) { + unset($formOptions['children'][$classProperty]); + continue; + } + + if (null === $propFormOptions) { + /** @var list $formOptions['children_excluded'] */ + $formChildExcluded = $allChildrenExcluded || in_array($classProperty, $formOptions['children_excluded'], true) + || ($propAttributeOptions['child_excluded'] ?? false); + + // Excluded at form or attribute level? Continue early + if ($formChildExcluded) { + unset($formOptions['children'][$classProperty]); + continue; + } + } + + $childOptions = [ + ...($propFormOptions ?? []), + ...$propAttributeOptions, + ]; + + // classProperty.propertyInfo? Enrich childOptions + if (null !== $propertyTypeInfo = $this->propertyInfoExtractor->getType($dataClass, $classProperty)) { + /** @psalm-suppress RiskyTruthyFalsyComparison */ + /** @var list $formOptions['children_embedded'] */ + $formChildEmbedded = $allChildrenEmbedded || in_array($classProperty, $formOptions['children_embedded'], true) + || ($propAttributeOptions['child_embedded'] ?? false); + $childOptions = $this->updateChildOptions($childOptions, $propertyTypeInfo, $formChildEmbedded); + } + + $this->addChild($builder, $classProperty, $childOptions); + unset($formOptions['children'][$classProperty]); + } + + // Remaining FORM.children[PROP] unrelated to dataClass? E.g: mapped:false OR inherit_data:true + foreach ($formOptions['children'] as $childProperty => $childOptions) { + // FORM.children[PROP] callable? Continue early + if (is_callable($childOptions)) { + /** @var FormBuilderInterface */ + $childBuilder = ($childOptions)($builder); + $this->addChild($builder, $childBuilder); + continue; + } + + /** @var string $childProperty */ + $this->addChild($builder, $childProperty, $childOptions); + } + + // FORM.builder callable? Final modifications + if (null !== $builderFn = $formOptions['builder']) { + ($builderFn)($builder, $classProperties); + } + } + + private function addChild(FormBuilderInterface $builder, string|FormBuilderInterface $child, array $options = []): void + { + if ($child instanceof FormBuilderInterface) { + $builder->add($child); + return; + } + + [ + 'child_name' => $name, + 'child_type' => $type + ] = $options + [ + 'child_name' => $child, + 'child_type' => null, + ]; + unset($options['child_name'], $options['child_type'], $options['child_excluded'], $options['child_embedded']); + + /** @var string $name */ + /** @var class-string|null $type */ + /** @var array $options */ + $builder->add($name, $type, $options); + } + + /** + * @return class-string + */ + private function getDataClass(FormInterface $form): string + { + // Form data_class config? (With old proxy handling) + if (null !== $dataClass = $form->getConfig()->getDataClass()) { + if (false !== $pos = strrpos($dataClass, '\\__CG__\\')) { + /** @var class-string */ + return substr($dataClass, $pos + 8); + } + + /** @var class-string */ + return $dataClass; + } + + // Loop parent form to get closest data_class config + while (null !== $formParent = $form->getParent()) { + if (null === $dataClass = $formParent->getConfig()->getDataClass()) { + $form = $formParent; + + continue; + } + + return $this->getAssociationTargetClass($dataClass, (string) $form->getPropertyPath()); + } + + throw new \RuntimeException('Unable to get dataClass'); + } + + /** + * @return class-string + */ + private function getAssociationTargetClass(string $class, string $childName): string + { + if (null === $propertyTypeInfo = $this->propertyInfoExtractor->getType($class, $childName)) { + throw new \RuntimeException(sprintf('Unable to find the association target class of "%s" in %s.', $childName, $class)); + } + + $innerType = $propertyTypeInfo instanceof TypeInfo\CollectionType ? $propertyTypeInfo->getCollectionValueType() : $propertyTypeInfo; + if (!$innerType instanceof TypeInfo\ObjectType) { + throw new \RuntimeException(sprintf('Unable to find the association target class of "%s" in %s.', $childName, $class)); + } + + return $innerType->getClassName(); + } + + private function updateChildOptions(array $baseChildOptions, TypeInfo $propertyTypeInfo, bool $formChildEmbedded): array + { + $isObject = $propertyTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT); + + if (!$isObject && !$propertyTypeInfo instanceof TypeInfo\CollectionType) { + // TODO Enrich child_type & required? + return $baseChildOptions; + } + + if (!$formChildEmbedded) { + return $baseChildOptions; + } + + // Embeddable collection? + if ($propertyTypeInfo instanceof TypeInfo\CollectionType) { + $baseCollOptions = [ + 'child_type' => CollectionType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + ...$baseChildOptions, + ]; + + $collValueType = $propertyTypeInfo->getCollectionValueType(); + + // Object? + if ($collValueType instanceof TypeInfo\ObjectType) { + /** @psalm-suppress InvalidOperand */ + return [ + 'entry_type' => AutoType::class, + ...$baseCollOptions, + 'entry_options' => [ + 'data_class' => $collValueType->getClassName(), + ...($baseCollOptions['entry_options'] ?? []), + ], + ]; + } + + // Builtin + // TODO Enrich entry_type? + return $baseCollOptions; + } + + // Embeddable object + /** @var TypeInfo\ObjectType */ + $innerType = $propertyTypeInfo instanceof TypeInfo\NullableType ? $propertyTypeInfo->getWrappedType() : $propertyTypeInfo; + + return [ + 'child_type' => AutoType::class, + 'data_class' => $innerType->getClassName(), + 'required' => $propertyTypeInfo->isNullable(), + ...$baseChildOptions + ]; + } +} diff --git a/src/Form/EventListener/AutoFormListener.php b/src/Form/EventListener/AutoFormListener.php deleted file mode 100644 index 9ca6134..0000000 --- a/src/Form/EventListener/AutoFormListener.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Form\EventListener; - -use A2lix\AutoFormBundle\Form\Manipulator\FormManipulatorInterface; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; - -class AutoFormListener implements EventSubscriberInterface -{ - public function __construct( - private readonly FormManipulatorInterface $formManipulator, - ) {} - - public static function getSubscribedEvents(): array - { - return [ - FormEvents::PRE_SET_DATA => 'preSetData', - ]; - } - - public function preSetData(FormEvent $event): void - { - $form = $event->getForm(); - - $fieldsOptions = $this->formManipulator->getFieldsConfig($form); - foreach ($fieldsOptions as $fieldName => $fieldConfig) { - $fieldType = $fieldConfig['field_type'] ?? null; - unset($fieldConfig['field_type']); - - $form->add($fieldName, $fieldType, $fieldConfig); - } - } -} diff --git a/src/Form/Manipulator/DoctrineORMManipulator.php b/src/Form/Manipulator/DoctrineORMManipulator.php deleted file mode 100644 index aa37e90..0000000 --- a/src/Form/Manipulator/DoctrineORMManipulator.php +++ /dev/null @@ -1,117 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Form\Manipulator; - -use A2lix\AutoFormBundle\ObjectInfo\DoctrineORMInfo; -use Symfony\Component\Form\FormInterface; - -class DoctrineORMManipulator implements FormManipulatorInterface -{ - public function __construct( - private readonly DoctrineORMInfo $doctrineORMInfo, - private readonly array $globalExcludedFields = [], - ) {} - - public function getFieldsConfig(FormInterface $form): array - { - $class = $this->getDataClass($form); - $formOptions = $form->getConfig()->getOptions(); - - // Filtering to remove excludedFields - $objectFieldsConfig = $this->doctrineORMInfo->getFieldsConfig($class); - $validObjectFieldsConfig = $this->filteringValidObjectFields($objectFieldsConfig, $formOptions['excluded_fields']); - - if (empty($formOptions['fields'])) { - return $validObjectFieldsConfig; - } - - $fields = []; - - foreach ($formOptions['fields'] as $formFieldName => $formFieldConfig) { - $this->checkFieldIsValid($formFieldName, $formFieldConfig, $validObjectFieldsConfig, $class); - - if (null === $formFieldConfig) { - continue; - } - - // If display undesired, remove - if (false === ($formFieldConfig['display'] ?? true)) { - continue; - } - - // Override with formFieldsConfig priority - $fields[$formFieldName] = $formFieldConfig; - - if (isset($validObjectFieldsConfig[$formFieldName])) { - $fields[$formFieldName] += $validObjectFieldsConfig[$formFieldName]; - } - } - - return $fields + $validObjectFieldsConfig; - } - - private function getDataClass(FormInterface $form): string - { - // Simple case, data_class from current form (with ORM Proxy management) - if (null !== $dataClass = $form->getConfig()->getDataClass()) { - if (false === $pos = strrpos((string) $dataClass, '\\__CG__\\')) { - return $dataClass; - } - - return substr((string) $dataClass, $pos + 8); - } - - // Advanced case, loop parent form to get closest fill data_class - while (null !== $formParent = $form->getParent()) { - if (null === $dataClass = $formParent->getConfig()->getDataClass()) { - $form = $formParent; - - continue; - } - - return $this->doctrineORMInfo->getAssociationTargetClass($dataClass, (string) $form->getPropertyPath()); - } - - throw new \RuntimeException('Unable to get dataClass'); - } - - private function filteringValidObjectFields(array $objectFieldsConfig, array $formExcludedFields): array - { - $excludedFields = array_merge($this->globalExcludedFields, $formExcludedFields); - - $validFields = []; - foreach ($objectFieldsConfig as $fieldName => $fieldConfig) { - if (\in_array($fieldName, $excludedFields, true)) { - continue; - } - - $validFields[$fieldName] = $fieldConfig; - } - - return $validFields; - } - - private function checkFieldIsValid($formFieldName, $formFieldConfig, $validObjectFieldsConfig, $class): void - { - if (isset($validObjectFieldsConfig[$formFieldName])) { - return; - } - - if (false === ($formFieldConfig['mapped'] ?? true)) { - return; - } - - throw new \RuntimeException(sprintf("Field '%s' doesn't exist in %s", $formFieldName, $class)); - } -} diff --git a/src/Form/Manipulator/FormManipulatorInterface.php b/src/Form/Manipulator/FormManipulatorInterface.php deleted file mode 100644 index ba16a71..0000000 --- a/src/Form/Manipulator/FormManipulatorInterface.php +++ /dev/null @@ -1,21 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Form\Manipulator; - -use Symfony\Component\Form\FormInterface; - -interface FormManipulatorInterface -{ - public function getFieldsConfig(FormInterface $form): array; -} diff --git a/src/Form/Type/AutoFormType.php b/src/Form/Type/AutoFormType.php deleted file mode 100644 index c565f95..0000000 --- a/src/Form/Type/AutoFormType.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Form\Type; - -use A2lix\AutoFormBundle\Form\EventListener\AutoFormListener; -use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\OptionsResolver\Options; -use Symfony\Component\OptionsResolver\OptionsResolver; - -class AutoFormType extends AbstractType -{ - public function __construct( - private readonly AutoFormListener $autoFormListener, - ) {} - - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $builder->addEventSubscriber($this->autoFormListener); - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'fields' => [], - 'excluded_fields' => [], - ]); - - $resolver->setNormalizer('data_class', static function (Options $options, $value): string { - if (empty($value)) { - throw new \RuntimeException('Missing "data_class" option of "AutoFormType".'); - } - - return $value; - }); - } -} diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php new file mode 100644 index 0000000..58bb343 --- /dev/null +++ b/src/Form/Type/AutoType.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 A2lix\AutoFormBundle\Form\Type; + +use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @psalm-type childOptions = array{ + * child_type?: class-string, + * child_name?: string, + * child_excluded?: bool, + * child_embedded?: bool, + * ... + * } + * @psalm-type childBuilderCallable = callable(FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface + * @psalm-type formBuilderCallable = callable(FormBuilderInterface $builder, string[] $classProperties): void + * @psalm-type formOptionsDefaults = array{ + * children: array|[], + * children_excluded: list|"*", + * children_embedded: list|"*", + * builder: formBuilderCallable|null, + * } + */ +class AutoType extends AbstractType +{ + public function __construct( + private readonly AutoTypeBuilder $autoTypeBuilder, + private readonly array $globalExcludedChildren = [], + ) {} + + #[\Override] + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /** @psalm-suppress MixedArgumentTypeCoercion */ + $this->autoTypeBuilder->buildChildren($builder, $options); + } + + #[\Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'children' => [], + 'children_excluded' => $this->globalExcludedChildren, + 'children_embedded' => [], + 'builder' => null, + ]); + $resolver->setAllowedTypes('builder', ['null', 'callable']); + $resolver->setInfo('builder', 'A callable that accepts two arguments (FormBuilderInterface $builder, string[] $classProperties). It should not return anything.'); + + $resolver->setNormalizer('data_class', static function (Options $options, string $value): string { + if (empty($value)) { + throw new \RuntimeException('Missing "data_class" option of "AutoType".'); + } + + return $value; + }); + } +} diff --git a/src/ObjectInfo/DoctrineORMInfo.php b/src/ObjectInfo/DoctrineORMInfo.php deleted file mode 100644 index cb24946..0000000 --- a/src/ObjectInfo/DoctrineORMInfo.php +++ /dev/null @@ -1,93 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\ObjectInfo; - -use A2lix\AutoFormBundle\Form\Type\AutoFormType; -use Doctrine\Persistence\Mapping\ClassMetadata; -use Doctrine\Persistence\Mapping\ClassMetadataFactory; -use Symfony\Component\Form\Extension\Core\Type\CollectionType; - -class DoctrineORMInfo -{ - public function __construct( - private readonly ClassMetadataFactory $classMetadataFactory, - ) {} - - public function getFieldsConfig(string $class): array - { - $fieldsConfig = []; - - $metadata = $this->classMetadataFactory->getMetadataFor($class); - - if (!empty($fields = $metadata->getFieldNames())) { - $fieldsConfig = array_fill_keys($fields, []); - } - - if (!empty($assocNames = $metadata->getAssociationNames())) { - $fieldsConfig += $this->getAssocsConfig($metadata, $assocNames); - } - - return $fieldsConfig; - } - - public function getAssociationTargetClass(string $class, string $fieldName): string - { - $metadata = $this->classMetadataFactory->getMetadataFor($class); - - if (!$metadata->hasAssociation($fieldName)) { - throw new \RuntimeException(sprintf('Unable to find the association target class of "%s" in %s.', $fieldName, $class)); - } - - return $metadata->getAssociationTargetClass($fieldName); - } - - private function getAssocsConfig(ClassMetadata $metadata, array $assocNames): array - { - $assocsConfigs = []; - - foreach ($assocNames as $assocName) { - $associationMapping = $metadata->getAssociationMapping($assocName); - - if (isset($associationMapping['inversedBy'])) { - $assocsConfigs[$assocName] = []; - - continue; - } - - $class = $metadata->getAssociationTargetClass($assocName); - - if ($metadata->isSingleValuedAssociation($assocName)) { - $assocsConfigs[$assocName] = [ - 'field_type' => AutoFormType::class, - 'data_class' => $class, - 'required' => false, - ]; - - continue; - } - - $assocsConfigs[$assocName] = [ - 'field_type' => CollectionType::class, - 'entry_type' => AutoFormType::class, - 'entry_options' => [ - 'data_class' => $class, - ], - 'allow_add' => true, - 'by_reference' => false, - ]; - } - - return $assocsConfigs; - } -} diff --git a/src/Resources/config/a2lix_form.xml b/src/Resources/config/a2lix_form.xml deleted file mode 100644 index 3328706..0000000 --- a/src/Resources/config/a2lix_form.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/config/object_info.xml b/src/Resources/config/object_info.xml deleted file mode 100644 index 044bc91..0000000 --- a/src/Resources/config/object_info.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/tests/Form/Type/AutoFormTypeAdvancedTest.php b/tests/Form/Type/AutoFormTypeAdvancedTest.php deleted file mode 100755 index 2685e59..0000000 --- a/tests/Form/Type/AutoFormTypeAdvancedTest.php +++ /dev/null @@ -1,150 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Tests\Form\Type; - -use A2lix\AutoFormBundle\Form\Type\AutoFormType; -use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media; -use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product; -use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\PreloadedExtension; - -/** - * @internal - */ -final class AutoFormTypeAdvancedTest extends TypeTestCase -{ - public function testCreationFormWithOverriddenFieldsLabel(): Product - { - $form = $this->factory->createBuilder(AutoFormType::class, new Product(), [ - 'fields' => [ - 'mainMedia' => [ - 'label' => 'Main Media', - ], - 'url' => [ - 'label' => 'URL/URI', - ], - ], - ]) - ->add('create', SubmitType::class) - ->getForm() - ; - - $media1 = new Media(); - $media1->setUrl('http://example.org/media1') - ->setDescription('media1 desc') - ; - $media2 = new Media(); - $media2->setUrl('http://example.org/media2') - ->setDescription('media2 desc') - ; - $media3 = new Media(); - $media3->setUrl('http://example.org/media3') - ->setDescription('media3 desc') - ; - - $product = new Product(); - $product - ->setUrl('a2lix.fr') - ->setMainMedia($media3) - ->addMedia($media1) - ->addMedia($media2) - ; - - $formData = [ - 'url' => 'a2lix.fr', - 'mainMedia' => [ - 'url' => 'http://example.org/media3', - 'description' => 'media3 desc', - ], - 'medias' => [ - [ - 'url' => 'http://example.org/media1', - 'description' => 'media1 desc', - ], - [ - 'url' => 'http://example.org/media2', - 'description' => 'media2 desc', - ], - ], - ]; - - $form->submit($formData); - self::assertTrue($form->isSynchronized()); - self::assertEquals($product, $form->getData()); - self::assertEquals('URL/URI', $form->get('url')->getConfig()->getOptions()['label']); - - return $product; - } - - public function testCreationFormWithOverriddenFieldsMappedFalse(): Product - { - $form = $this->factory->createBuilder(AutoFormType::class, new Product(), [ - 'fields' => [ - 'color' => [ - 'mapped' => false, - ], - ], - ]) - ->add('create', SubmitType::class) - ->getForm() - ; - - $media1 = new Media(); - $media1->setUrl('http://example.org/media1') - ->setDescription('media1 desc') - ; - $media2 = new Media(); - $media2->setUrl('http://example.org/media2') - ->setDescription('media2 desc') - ; - - $product = new Product(); - $product->setUrl('a2lix.fr') - ->addMedia($media1) - ->addMedia($media2) - ; - - $formData = [ - 'url' => 'a2lix.fr', - 'color' => 'blue', - 'medias' => [ - [ - 'url' => 'http://example.org/media1', - 'description' => 'media1 desc', - ], - [ - 'url' => 'http://example.org/media2', - 'description' => 'media2 desc', - ], - ], - ]; - - $form->submit($formData); - self::assertTrue($form->isSynchronized()); - self::assertEquals($product, $form->getData()); - self::assertEquals('blue', $form->get('color')->getData()); - - return $product; - } - - protected function getExtensions(): array - { - $autoFormType = $this->getConfiguredAutoFormType(); - - return [new PreloadedExtension([ - $autoFormType, - ], [])]; - } -} diff --git a/tests/Form/Type/AutoFormTypeSimpleTest.php b/tests/Form/Type/AutoFormTypeSimpleTest.php deleted file mode 100755 index 2d9767b..0000000 --- a/tests/Form/Type/AutoFormTypeSimpleTest.php +++ /dev/null @@ -1,132 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Tests\Form\Type; - -use A2lix\AutoFormBundle\Form\Type\AutoFormType; -use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media; -use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product; -use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\PreloadedExtension; - -/** - * @internal - */ -final class AutoFormTypeSimpleTest extends TypeTestCase -{ - public function testEmptyForm(): void - { - $form = $this->factory->createBuilder(AutoFormType::class, new Product()) - ->add('create', SubmitType::class) - ->getForm() - ; - - self::assertEquals(['create', 'title', 'description', 'url', 'mainMedia', 'medias'], array_keys($form->all()), 'Fields should matches Product fields'); - - $mediasFormOptions = $form->get('medias')->getConfig()->getOptions(); - self::assertEquals(AutoFormType::class, $mediasFormOptions['entry_type'], 'Media type should be an AutoType'); - self::assertEquals(Media::class, $mediasFormOptions['entry_options']['data_class'], 'Media should have its right data_class'); - } - - public function testCreationForm(): Product - { - $form = $this->factory->createBuilder(AutoFormType::class, new Product()) - ->add('create', SubmitType::class) - ->getForm() - ; - - $media1 = new Media(); - $media1->setUrl('http://example.org/media1') - ->setDescription('media1 desc') - ; - $media2 = new Media(); - $media2->setUrl('http://example.org/media2') - ->setDescription('media2 desc') - ; - - $product = new Product(); - $product->setUrl('a2lix.fr') - ->addMedia($media1) - ->addMedia($media2) - ; - - $formData = [ - 'url' => 'a2lix.fr', - 'medias' => [ - [ - 'url' => 'http://example.org/media1', - 'description' => 'media1 desc', - ], - [ - 'url' => 'http://example.org/media2', - 'description' => 'media2 desc', - ], - ], - ]; - - $form->submit($formData); - self::assertTrue($form->isSynchronized()); - self::assertEquals($product, $form->getData()); - - return $product; - } - - /** - * @depends testCreationForm - */ - public function testEditionForm(Product $product): void - { - $product->getMedias()[0]->setUrl('http://example.org/media1-edit'); - $product->getMedias()[1]->setDescription('media2 desc edit'); - - $formData = [ - 'url' => 'a2lix.fr', - 'medias' => [ - [ - 'url' => 'http://example.org/media1-edit', - 'description' => 'media1 desc', - ], - [ - 'url' => 'http://example.org/media2', - 'description' => 'media2 desc edit', - ], - ], - ]; - - $form = $this->factory->createBuilder(AutoFormType::class, new Product()) - ->add('create', SubmitType::class) - ->getForm() - ; - - $form->submit($formData); - self::assertTrue($form->isSynchronized()); - self::assertEquals($product, $form->getData()); - - $view = $form->createView(); - $children = $view->children; - - foreach (array_keys($formData) as $key) { - self::assertArrayHasKey($key, $children); - } - } - - protected function getExtensions(): array - { - $autoFormType = $this->getConfiguredAutoFormType(); - - return [new PreloadedExtension([ - $autoFormType, - ], [])]; - } -} diff --git a/tests/Form/Type/AutoTypeDtoTest.php b/tests/Form/Type/AutoTypeDtoTest.php new file mode 100755 index 0000000..da1d9d4 --- /dev/null +++ b/tests/Form/Type/AutoTypeDtoTest.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 A2lix\AutoFormBundle\Tests\Form\Type; + +use A2lix\AutoFormBundle\Form\Type\AutoFormType; +use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media; +use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product; +use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; +use PHPUnit\Framework\Attributes\Depends; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\PreloadedExtension; + +/** + * @internal + */ +final class AutoTypeDtoTest extends TypeTestCase +{ + +} diff --git a/tests/Form/Type/AutoTypeEntityTest.php b/tests/Form/Type/AutoTypeEntityTest.php new file mode 100755 index 0000000..fd37023 --- /dev/null +++ b/tests/Form/Type/AutoTypeEntityTest.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 A2lix\AutoFormBundle\Tests\Form\Type; + +use A2lix\AutoFormBundle\Form\Type\AutoFormType; +use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media; +use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product; +use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\PreloadedExtension; + +/** + * @internal + */ +final class AutoTypeEntityTest extends TypeTestCase +{ + +} diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index f83e385..e797d91 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -13,66 +13,79 @@ namespace A2lix\AutoFormBundle\Tests\Form; -use A2lix\AutoFormBundle\Form\EventListener\AutoFormListener; -use A2lix\AutoFormBundle\Form\Manipulator\DoctrineORMManipulator; -use A2lix\AutoFormBundle\Form\Type\AutoFormType; -use A2lix\AutoFormBundle\ObjectInfo\DoctrineORMInfo; +use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; +use A2lix\AutoFormBundle\Form\Type\AutoType; use Doctrine\DBAL\DriverManager; use Doctrine\ORM\EntityManager; use Doctrine\ORM\ORMSetup; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\Form\Extension\Validator\Type\FormTypeValidatorExtension; -use Symfony\Component\Form\Extension\Validator\ValidatorTypeGuesser; -use Symfony\Component\Form\FormBuilder; -use Symfony\Component\Form\Forms; +use Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor; +use Symfony\Component\Form\PreloadedExtension; +use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait; use Symfony\Component\Form\Test\TypeTestCase as BaseTypeTestCase; -use Symfony\Component\Validator\ConstraintViolationList; -use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; abstract class TypeTestCase extends BaseTypeTestCase { - protected ?DoctrineORMManipulator $doctrineORMManipulator = null; + use ValidatorExtensionTrait; - protected function setUp(): void - { - parent::setUp(); + protected ?AutoTypeBuilder $autoTypeBuilder = null; - $validator = $this->createMock(ValidatorInterface::class); - $validator->method('validate')->willReturn(new ConstraintViolationList()); + protected function getConfiguredAutoType(array $childrenExcluded = []): AutoType + { + return new AutoType($this->getAutoTypeBuilder(), $childrenExcluded); + } - $this->factory = Forms::createFormFactoryBuilder() - ->addExtensions($this->getExtensions()) - ->addTypeExtension( - new FormTypeValidatorExtension($validator) - ) - ->addTypeGuesser( - $this->createMock(ValidatorTypeGuesser::class) - ) - ->getFormFactory() - ; + private function getAutoTypeBuilder(): AutoTypeBuilder + { + if (null !== $this->autoTypeBuilder) { + return $this->autoTypeBuilder; + } - $this->dispatcher = $this->createMock(EventDispatcherInterface::class); - $this->builder = new FormBuilder(null, null, $this->dispatcher, $this->factory); + return $this->autoTypeBuilder = new AutoTypeBuilder( + $this->getPropertyInfoExtractor(), + ); } - protected function getDoctrineORMManipulator(): DoctrineORMManipulator + private function getPropertyInfoExtractor(): PropertyInfoExtractor { - if (null !== $this->doctrineORMManipulator) { - return $this->doctrineORMManipulator; - } + $config = ORMSetup::createAttributeMetadataConfig([__DIR__ . '/../Fixtures/Entity'], true); + $config->setProxyDir(__DIR__ . '/../proxies'); + $config->setProxyNamespace('EntityProxy'); - $config = ORMSetup::createAttributeMetadataConfiguration([__DIR__.'/../Fixtures/Entity'], true); $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config); $entityManager = new EntityManager($connection, $config); - $doctrineORMInfo = new DoctrineORMInfo($entityManager->getMetadataFactory()); - return $this->doctrineORMManipulator = new DoctrineORMManipulator($doctrineORMInfo, ['id', 'locale', 'translatable']); + $doctrineExtractor = new DoctrineExtractor($entityManager); + $reflectionExtractor = new ReflectionExtractor(); + + return new PropertyInfoExtractor( + listExtractors: [ + $reflectionExtractor, + $doctrineExtractor + ], + typeExtractors:[ + $doctrineExtractor, + new PhpStanExtractor(), + new PhpDocExtractor(), + $reflectionExtractor + ], + accessExtractors: [ + $doctrineExtractor, + $reflectionExtractor + ] + ); } - protected function getConfiguredAutoFormType(): AutoFormType + protected function getExtensions(): array { - $autoFormListener = new AutoFormListener($this->getDoctrineORMManipulator()); + $autoType = $this->getConfiguredAutoType(['id']); - return new AutoFormType($autoFormListener); + return [ + ...parent::getExtensions(), + new PreloadedExtension([$autoType], []), + ]; } } From 7ba3cd7e8b58935cbb1dc723abb6b84a44531f3a Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:15:20 +0000 Subject: [PATCH 02/35] Progress --- .devcontainer/Dockerfile | 4 +- .devcontainer/devcontainer.json | 2 +- .github/workflows/ci.yml | 188 ++++++++-------------- .php-cs-fixer.dist.php | 12 +- README.md | 165 +++++++++++++++---- composer.json | 28 ++-- config/services.php | 5 +- psalm.xml | 8 +- rector.php | 53 +++--- src/A2lixAutoFormBundle.php | 18 +-- src/Form/Attribute/AutoTypeCustom.php | 11 +- src/Form/Builder/AutoTypeBuilder.php | 84 ++++++---- src/Form/Type/AutoType.php | 5 +- tests/Fixtures/Dto/Media1.php | 33 ++++ tests/Fixtures/Dto/Product1.php | 52 ++++++ tests/Fixtures/Entity/Media.php | 75 --------- tests/Fixtures/Entity/Media1.php | 36 +++++ tests/Fixtures/Entity/Product.php | 122 -------------- tests/Fixtures/Entity/Product1.php | 65 ++++++++ tests/Fixtures/ProductStatus.php | 18 +++ tests/Fixtures/Type/ValidityRangeType.php | 47 ++++++ tests/Form/Type/AutoTypeDtoTest.php | 89 ++++++++-- tests/Form/Type/AutoTypeEntityTest.php | 12 +- tests/Form/TypeTestCase.php | 20 +-- 24 files changed, 669 insertions(+), 483 deletions(-) create mode 100644 tests/Fixtures/Dto/Media1.php create mode 100644 tests/Fixtures/Dto/Product1.php delete mode 100644 tests/Fixtures/Entity/Media.php create mode 100644 tests/Fixtures/Entity/Media1.php delete mode 100644 tests/Fixtures/Entity/Product.php create mode 100644 tests/Fixtures/Entity/Product1.php create mode 100644 tests/Fixtures/ProductStatus.php create mode 100644 tests/Fixtures/Type/ValidityRangeType.php diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f92a375..85f950b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -10,8 +10,8 @@ ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/relea RUN set -eux; \ install-php-extensions \ @composer \ - # apcu \ - # opcache \ + apcu \ + opcache \ xdebug \ ; diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b553784..ed3318c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "PHP CLI", + "name": "A2lix - AutoFormBundle", "build": { "dockerfile": "Dockerfile" }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0060747..0b8e45f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,128 +1,72 @@ name: CI -on: ["push", "pull_request"] - -env: - COMPOSER_ALLOW_SUPERUSER: '1' - SYMFONY_DEPRECATIONS_HELPER: max[self]=0 +on: + push: + branches: [ "1.x" ] + pull_request: + branches: [ "1.x" ] jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - container: - image: php:8.3-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Composer - run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-8.3-highest-${{ hashFiles('**/composer.json') }} - restore-keys: | - ${{ runner.os }}-composer-8.3-highest - - name: Validate Composer - run: composer validate - - name: Install highest dependencies with Composer - run: composer update --no-progress --no-suggest --ansi - - name: Disable PHP memory limit - run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini - - name: Run CS-Fixer - run: vendor/bin/php-cs-fixer fix --dry-run --diff --format=checkstyle + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-8.4-${{ hashFiles(''**/composer.lock'') }} + restore-keys: | + ${{ runner.os }}-php-8.4- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run php-cs-fixer + run: vendor/bin/php-cs-fixer check --diff --verbose + + tests: + runs-on: ubuntu-latest + needs: lint + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4'] + symfony: ['7.4.*', '8.0.*'] + + name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ hashFiles(''**/composer.lock'') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}- + + - name: Update dependencies for Symfony ${{ matrix.symfony }} + run: | + composer require "symfony/config:${{ matrix.symfony }}" "symfony/dependency-injection:${{ matrix.symfony }}" "symfony/doctrine-bridge:${{ matrix.symfony }}" "symfony/form:${{ matrix.symfony }}" "symfony/http-kernel:${{ matrix.symfony }}" "symfony/property-info:${{ matrix.symfony }}" "symfony/cache:${{ matrix.symfony }}" "symfony/validator:${{ matrix.symfony }}" "symfony/var-dumper:${{ matrix.symfony }}" --no-update + composer update --prefer-dist --no-progress --optimize-autoloader - phpunit: - name: PHPUnit (PHP ${{ matrix.php }} Deps ${{ matrix.dependencies }}) - runs-on: ubuntu-latest - container: - image: php:${{ matrix.php }}-alpine - options: >- - --tmpfs /tmp:exec - --tmpfs /var/tmp:exec - strategy: - matrix: - php: - - '8.1' - - '8.2' - - '8.3' - dependencies: - - 'lowest' - - 'highest' - include: - - php: '8.1' - phpunit-version: 10 - - php: '8.2' - phpunit-version: 10 - - php: '8.3' - phpunit-version: 10 - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install Composer - run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - - name: Get Composer Cache Directory - id: composer-cache - run: | - echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} - restore-keys: | - ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependencies }} - - name: Install lowest dependencies with Composer - if: matrix.dependencies == 'lowest' - run: composer update --no-progress --no-suggest --prefer-stable --prefer-lowest --ansi - - name: Install highest dependencies with Composer - if: matrix.dependencies == 'highest' - run: composer update --no-progress --no-suggest --ansi - - name: Run tests with PHPUnit - env: - SYMFONY_MAX_PHPUNIT_VERSION: ${{ matrix.phpunit-version }} - run: vendor/bin/simple-phpunit --colors=always + - name: Run psalm + run: vendor/bin/psalm - # coverage: - # name: Coverage (PHP 8.3) - # runs-on: ubuntu-latest - # container: - # image: php:8.3-alpine - # options: >- - # --tmpfs /tmp:exec - # --tmpfs /var/tmp:exec - # steps: - # - name: Checkout - # uses: actions/checkout@v4 - # - name: Install pcov PHP extension - # run: | - # apk add $PHPIZE_DEPS - # pecl install pcov - # docker-php-ext-enable pcov - # - name: Install Composer - # run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet - # - name: Get Composer Cache Directory - # id: composer-cache - # run: | - # echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - # - uses: actions/cache@v3 - # with: - # path: ${{ steps.composer-cache.outputs.dir }} - # key: ${{ runner.os }}-composer-8.3-highest-${{ hashFiles('**/composer.json') }} - # restore-keys: | - # ${{ runner.os }}-composer-8.3-highest - # - name: Install highest dependencies with Composer - # run: composer update --no-progress --no-suggest --ansi - # - name: Run coverage with PHPUnit - # run: vendor/bin/simple-phpunit --coverage-clover ./coverage.xml --colors=always - # - name: Send code coverage report to Codecov.io - # uses: codecov/codecov-action@v3 - # with: - # token: ${{ secrets.CODECOV_TOKEN }} + - name: Run phpunit + run: vendor/bin/phpunit diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 12cd6ec..3ec1ea4 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -18,10 +18,9 @@ ->setRiskyAllowed(true) ->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers()) ->setRules([ - '@DoctrineAnnotation' => true, - '@PHP82Migration:risky' => true, - '@PHP84Migration' => true, - '@PHPUnit100Migration:risky' => true, + '@autoPHPMigration:risky' => true, + '@autoPHPMigration' => true, + '@autoPHPUnitMigration:risky' => true, '@PhpCsFixer' => true, '@PhpCsFixer:risky' => true, '@Symfony' => true, @@ -34,6 +33,7 @@ 'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false], 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline', 'attribute_placement' => 'ignore'], 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], + 'multiline_promoted_properties' => true, 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'allow_unused_params' => true], 'nullable_type_declaration_for_default_null_value' => ['use_nullable_type_declaration' => true], 'numeric_literal_separator' => true, @@ -50,6 +50,7 @@ 'phpdoc_to_property_type' => ['scalar_types' => true], 'phpdoc_to_return_type' => ['scalar_types' => true], 'phpdoc_var_without_name' => true, + 'phpdoc_to_comment' => false, 'single_line_throw' => true, 'statement_indentation' => true, 'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arrays', 'parameters']], @@ -62,7 +63,6 @@ PhpCsFixerCustomFixers\Fixer\DeclareAfterOpeningTagFixer::name() => true, PhpCsFixerCustomFixers\Fixer\EmptyFunctionBodyFixer::name() => true, PhpCsFixerCustomFixers\Fixer\MultilineCommentOpeningClosingAloneFixer::name() => true, - PhpCsFixerCustomFixers\Fixer\MultilinePromotedPropertiesFixer::name() => true, PhpCsFixerCustomFixers\Fixer\NoDoctrineMigrationsGeneratedCommentFixer::name() => true, PhpCsFixerCustomFixers\Fixer\NoDuplicatedArrayKeyFixer::name() => true, PhpCsFixerCustomFixers\Fixer\NoUselessCommentFixer::name() => true, @@ -78,6 +78,8 @@ PhpCsFixerCustomFixers\Fixer\PhpdocSelfAccessorFixer::name() => true, PhpCsFixerCustomFixers\Fixer\PhpdocTypesCommaSpacesFixer::name() => true, PhpCsFixerCustomFixers\Fixer\PhpdocTypesTrimFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\FunctionParameterSeparationFixer::name() => true, + PhpCsFixerCustomFixers\Fixer\PhpdocPropertySortedFixer::name() => true, PhpCsFixerCustomFixers\Fixer\PromotedConstructorPropertyFixer::name() => true, PhpCsFixerCustomFixers\Fixer\ReadonlyPromotedPropertiesFixer::name() => true, PhpCsFixerCustomFixers\Fixer\SingleSpaceAfterStatementFixer::name() => true, diff --git a/README.md b/README.md index cf6fa2e..9748f25 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,162 @@ -# A2lix Auto Form Bundle - -Automate form building. +# A2lix AutoForm Bundle [![Latest Stable Version](https://poser.pugx.org/a2lix/auto-form-bundle/v/stable)](https://packagist.org/packages/a2lix/auto-form-bundle) [![Latest Unstable Version](https://poser.pugx.org/a2lix/auto-form-bundle/v/unstable)](https://packagist.org/packages/a2lix/auto-form-bundle) -[![License](https://poser.pugx.org/a2lix/auto-form-bundle/license)](https://packagist.org/packages/a2lix/auto-form-bundle) - [![Total Downloads](https://poser.pugx.org/a2lix/auto-form-bundle/downloads)](https://packagist.org/packages/a2lix/auto-form-bundle) -[![Monthly Downloads](https://poser.pugx.org/a2lix/auto-form-bundle/d/monthly)](https://packagist.org/packages/a2lix/auto-form-bundle) -[![Daily Downloads](https://poser.pugx.org/a2lix/auto-form-bundle/d/daily)](https://packagist.org/packages/a2lix/auto-form-bundle) +[![License](https://poser.pugx.org/a2lix/auto-form-bundle/license)](https://packagist.org/packages/a2lix/auto-form-bundle) +[![Build Status](https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml/badge.svg)](https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml) -| Branch | Tools | -| --- | --- | -| master | [![Build Status][ci_badge]][ci_link] [![Coverage Status][coverage_badge]][coverage_link] | +Stop writing boilerplate form code. This bundle provides a single, powerful `AutoType` form type that automatically generates a complete Symfony form from any PHP class. ## Installation -Use composer: +Use Composer to install the bundle: ```bash composer require a2lix/auto-form-bundle ``` -## Configuration +## Basic Usage -There is no minimal configuration, so this part is optional. Full list: +The simplest way to use `AutoType` is directly in your controller. It will generate a form based on the properties of the entity or DTO you pass it. -```yaml -# Create a dedicated a2lix.yaml in config/packages with: +```php +use A2lix\AutoFormBundle\Form\Type\AutoType; -a2lix_auto_form: - children_excluded: [id] # [1] +class TaskController extends AbstractController +{ + public function new(): Response + { + $task = new Task(); // Any entity or DTO + $form = $this->createForm(AutoType::class, $task); + + // ... + } +} ``` -1. Optional. +## How It Works + +`AutoType` reads the properties of the class you provide in the `data_class` option. For each property, it intelligently configures a corresponding form field. This gives you a solid foundation that you can then customize in two main ways: + +1. **Form Options:** Pass a configuration array directly when you create the form. +2. **PHP Attributes:** Add `#[AutoTypeCustom]` attributes directly to the properties of your entity or DTO. + +Options passed directly to the form will always take precedence over attributes. + +## Customization via Form Options + +This is the most flexible way to configure your form. Here is a comprehensive example: + +```php +use Symfony\Component\Form\Extension\Core\Type\MoneyType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; + +class TaskController extends AbstractController +{ + public function new(FormFactoryInterface $formFactory): Response + { + $product = new Product(); // Any entity or DTO + $form = $formFactory->createNamed('product', AutoType::class, $product, [ + // 1. Exclude properties from the form. + // Use '*' for an "exclude-by-default" strategy. + 'children_excluded' => ['id', 'internalRef'], + + // 2. Define which relations should be rendered as embedded forms. + // Use '*' to embed all relational properties. + 'children_embedded' => ['category', 'tags'], + + // 3. Customize, override, or add fields. + 'children' => [ + // Override an existing property with new options + 'description' => [ + 'child_type' => TextareaType::class, // Force a specific form type + 'label' => 'Product Description', // Standard form options + 'priority' => 10, + ], + + // Add a field that does not exist on the DTO/entity + 'terms_and_conditions' => [ + 'child_type' => CheckboxType::class, + 'mapped' => false, + 'priority' => -100, + ], + + // Completely replace a field's builder with a callable + 'price' => function(FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface { + // The callable receives the main builder and any options from a potential attribute. + // It must return a new FormBuilderInterface instance. + return $builder->create('price', MoneyType::class, ['currency' => 'EUR']); + }, + ], + + // 4. For final modifications on the complete form builder. + 'builder' => function(FormBuilderInterface $builder, array $classProperties): void { + // This callable runs after all children have been added. + if (isset($classProperties['code'])) { + $builder->remove('code'); + } + }, + ]); + + // ... + } +} -## Usage +``` -TODO +## Customization via `#[AutoTypeCustom]` Attribute -## Additional +For a more declarative approach, you can place the configuration directly on the properties of your DTO or entity. This keeps the form configuration co-located with your data model. -### Example +```php +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; -See [Demo Bundle](https://github.com/a2lix/Demo) for more examples. +class Product +{ + #[AutoTypeCustom(excluded: true)] + public int $id; -## License + public ?string $name = null; + + #[AutoTypeCustom(type: TextareaType::class, options: ['attr' => ['rows' => 5]])] + public ?string $description = null; + + #[AutoTypeCustom(embedded: true)] + public Category $category; +} +``` + +## Advanced Recipes -This package is available under the [MIT license](LICENSE). +### Creating a Compound Field with `inherit_data` + +You can use a callable in the `children` option to create complex fields that map to the parent object, which is useful for things like date ranges. + +```php +'children' => [ + 'validity_range' => function (FormBuilderInterface $builder): FormBuilderInterface { + return $builder + ->create('validity_range', FormType::class, ['inherit_data' => true]) + ->add('startsAt', DateType::class, [/* ... */]) + ->add('endsAt', DateType::class, [/* ... */]); + }, +] +``` + +## Global Configuration + +While not required, you can configure the bundle globally. For example, you can define a list of properties to always exclude. + +Create a configuration file in `config/packages/a2lix_auto_form.yaml`: + +```yaml +a2lix_auto_form: + # Exclude 'id' and 'createdAt' properties from all AutoType forms by default + children_excluded: [id, createdAt] +``` + +## License -[ci_badge]: https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml/badge.svg -[ci_link]: https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml -[coverage_badge]: https://codecov.io/gh/a2lix/AutoFormBundle/branch/master/graph/badge.svg -[coverage_link]: https://codecov.io/gh/a2lix/AutoFormBundle/branch/master +This package is available under the [MIT license](LICENSE). \ No newline at end of file diff --git a/composer.json b/composer.json index c7f903e..1f0141b 100644 --- a/composer.json +++ b/composer.json @@ -17,24 +17,24 @@ ], "require": { "php": ">=8.2", - "symfony/config": "^6.4.20|7.3", - "symfony/dependency-injection": "^6.4.20|7.3", - "symfony/doctrine-bridge": "^6.4.20|7.3", - "symfony/form": "^6.4.20|7.3", - "symfony/http-kernel": "^6.4.20|7.3", - "symfony/property-info": "^6.4.20|7.3", + "symfony/config": "^7.3", + "symfony/dependency-injection": "^7.3", + "symfony/doctrine-bridge": "^7.3", + "symfony/form": "^7.3", + "symfony/http-kernel": "^7.3", + "symfony/property-info": "^7.3", "phpdocumentor/reflection-docblock": "^5.6" }, "require-dev": { - "doctrine/orm": "^3.5.2", - "friendsofphp/php-cs-fixer": "^3.87.2", + "doctrine/orm": "^3.5", + "friendsofphp/php-cs-fixer": "^3.87", "kubawerlos/php-cs-fixer-custom-fixers": "^3.34", - "phpunit/phpunit": "^12.3.13", - "rector/rector": "^2.1.7", - "symfony/cache": "^6.4.20|7.3", - "symfony/validator": "^6.4.20|7.3", + "phpunit/phpunit": "^12.3", + "rector/rector": "^2.1", + "symfony/cache": "^7.3", + "symfony/validator": "^7.3", "symfony/var-dumper": "^7.3", - "vimeo/psalm": "^6.13.1" + "vimeo/psalm": "^6.13" }, "suggest": { "a2lix/translation-form-bundle": "For translation form" @@ -64,7 +64,7 @@ }, "extra": { "branch-alias": { - "dev-master": "0.x-dev" + "dev-master": "1.x-dev" } } } diff --git a/config/services.php b/config/services.php index c2daa46..0826f65 100644 --- a/config/services.php +++ b/config/services.php @@ -20,12 +20,11 @@ $container->services() ->set('a2lix_auto_form.form.builder.auto_type_builder', AutoTypeBuilder::class) ->args([ - service('property_info'), + '$propertyInfoExtractor' => service('property_info'), ]) ->set('a2lix_auto_form.form.type.auto_type', AutoType::class) ->args([ - service('a2lix_auto_form.form.builder.auto_type_builder'), - null, // children_excluded config option + '$autoTypeBuilder' => service('a2lix_auto_form.form.builder.auto_type_builder'), ]) ->tag('form.type') ; diff --git a/psalm.xml b/psalm.xml index bb222f1..5fdfd66 100644 --- a/psalm.xml +++ b/psalm.xml @@ -8,9 +8,15 @@ findUnusedBaselineEntry="true" > - + + + + + + + diff --git a/rector.php b/rector.php index 7db008f..9912bb5 100644 --- a/rector.php +++ b/rector.php @@ -3,11 +3,6 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Doctrine\Set\DoctrineSetList; -use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector; -use Rector\PHPUnit\Set\PHPUnitSetList; -use Rector\Symfony\Set\SymfonySetList; -use Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector; return RectorConfig::configure() ->withParallel() @@ -16,31 +11,27 @@ __DIR__ . '/src', __DIR__ . '/tests', ]) - ->withRootFiles() - ->withImportNames(importShortClasses: false) - ->withTypeCoverageLevel(0) - ->withDeadCodeLevel(0) - ->withCodeQualityLevel(0) - ->withRules([ - AddVoidReturnTypeWhereNoReturnRector::class, - ]) + // ->withRootFiles() + ->withImportNames(importShortClasses: false, removeUnusedImports: true) ->withPhpSets() - ->withSets([ - LevelSetList::UP_TO_PHP_82, - DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES, - SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES, - DoctrineSetList::GEDMO_ANNOTATIONS_TO_ATTRIBUTES, - PHPUnitSetList::PHPUNIT_110, - - PHPUnitLevelSetList::UP_TO_PHPUNIT_91, - ]) ->withAttributesSets(all: true) - ->withComposerBased(doctrine: true, phpunit: true, symfony: true) - ->withConfiguredRule(ClassPropertyAssignToConstructorPromotionRector::class, [ - 'inline_public' => true, - ]) - ->withSkip([ - ClassPropertyAssignToConstructorPromotionRector::class => [ - __DIR__ . '/src/Entity/*', - ], - ]); + ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + codingStyle: true, + typeDeclarations: true, + typeDeclarationDocblocks: true, + privatization: true, + naming: true, + instanceOf: true, + earlyReturn: true, + strictBooleans: true, + carbon: true, + rectorPreset: true, + phpunitCodeQuality: true, + doctrineCodeQuality: true, + symfonyCodeQuality: true, + symfonyConfigs: true, + ) +; diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php index c3ad0e7..d0c26ed 100644 --- a/src/A2lixAutoFormBundle.php +++ b/src/A2lixAutoFormBundle.php @@ -1,6 +1,4 @@ -rootNode() ->children() - ->arrayNode('children_excluded') - ->scalarPrototype()->end() - ->defaultValue(['id']) - ->info('Class properties to exclude from autoType children. (Default: id)') - ->end() + ->arrayNode('children_excluded') + ->scalarPrototype()->end() + ->defaultValue(['id']) + ->info('Class properties to exclude from autoType children. (Default: id)') + ->end() ->end() ; } @@ -41,7 +41,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $container->services() ->get('a2lix_auto_form.form.type.auto_type') - ->arg(1, $config['children_excluded']) + ->arg('$globalExcludedChildren', $config['children_excluded']) ; } } diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php index bdf3816..84102cb 100644 --- a/src/Form/Attribute/AutoTypeCustom.php +++ b/src/Form/Attribute/AutoTypeCustom.php @@ -1,6 +1,4 @@ - $options - * @param class-string|null $type + * @param class-string|null $type */ public function __construct( private array $options = [], @@ -31,8 +29,7 @@ public function __construct( private ?string $name = null, private ?bool $excluded = null, private ?bool $embedded = null, - ) { - } + ) {} /** * @return childOptions @@ -47,4 +44,4 @@ public function getOptions(): array ...(null !== $this->embedded ? ['child_embedded' => $this->embedded] : []), ]; } -} \ No newline at end of file +} diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index ce938e4..b75ec98 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -1,6 +1,4 @@ -getDataClass($builder->getForm()); + $dataClass = $this->getDataClass($form = $builder->getForm()); if (null === $classProperties = $this->propertyInfoExtractor->getProperties($dataClass)) { - throw new \RuntimeException(sprintf('Unable to extract properties of "%s".', $dataClass)); + throw new \RuntimeException(\sprintf('Unable to extract properties of "%s".', $dataClass)); } $refClass = new \ReflectionClass($dataClass); $allChildrenExcluded = '*' === $formOptions['children_excluded']; $allChildrenEmbedded = '*' === $formOptions['children_embedded']; + $formLevel = $this->getFormLevel($form); foreach ($classProperties as $classProperty) { - // Issue: DateTimeImmutable PHP8.4 + // Due to issue with DateTimeImmutable PHP8.4 if (!$refClass->hasProperty($classProperty)) { continue; } @@ -61,10 +60,11 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) $refProperty = $refClass->getProperty($classProperty); $propAttributeOptions = ($refProperty->getAttributes(AutoTypeCustom::class)[0] ?? null) - ?->newInstance()?->getOptions() ?? []; + ?->newInstance()?->getOptions() ?? [] + ; // FORM.children[PROP] callable? Add early - if (is_callable($propFormOptions)) { + if (\is_callable($propFormOptions)) { /** @var FormBuilderInterface */ $childBuilder = ($propFormOptions)($builder, $propAttributeOptions); $this->addChild($builder, $childBuilder); @@ -81,7 +81,7 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) if (null === $propFormOptions) { /** @var list $formOptions['children_excluded'] */ - $formChildExcluded = $allChildrenExcluded || in_array($classProperty, $formOptions['children_excluded'], true) + $formChildExcluded = $allChildrenExcluded || \in_array($classProperty, $formOptions['children_excluded'], true) || ($propAttributeOptions['child_excluded'] ?? false); // Excluded at form or attribute level? Continue early @@ -96,13 +96,13 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) ...$propAttributeOptions, ]; - // classProperty.propertyInfo? Enrich childOptions - if (null !== $propertyTypeInfo = $this->propertyInfoExtractor->getType($dataClass, $classProperty)) { + // PropertyInfo? Enrich childOptions + if (null !== $propTypeInfo = $this->propertyInfoExtractor->getType($dataClass, $classProperty)) { /** @psalm-suppress RiskyTruthyFalsyComparison */ /** @var list $formOptions['children_embedded'] */ - $formChildEmbedded = $allChildrenEmbedded || in_array($classProperty, $formOptions['children_embedded'], true) + $formChildEmbedded = $allChildrenEmbedded || \in_array($classProperty, $formOptions['children_embedded'], true) || ($propAttributeOptions['child_embedded'] ?? false); - $childOptions = $this->updateChildOptions($childOptions, $propertyTypeInfo, $formChildEmbedded); + $childOptions = $this->updateChildOptions($childOptions, $propTypeInfo, $formChildEmbedded, $formLevel); } $this->addChild($builder, $classProperty, $childOptions); @@ -112,7 +112,7 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) // Remaining FORM.children[PROP] unrelated to dataClass? E.g: mapped:false OR inherit_data:true foreach ($formOptions['children'] as $childProperty => $childOptions) { // FORM.children[PROP] callable? Continue early - if (is_callable($childOptions)) { + if (\is_callable($childOptions)) { /** @var FormBuilderInterface */ $childBuilder = ($childOptions)($builder); $this->addChild($builder, $childBuilder); @@ -133,6 +133,7 @@ private function addChild(FormBuilderInterface $builder, string|FormBuilderInter { if ($child instanceof FormBuilderInterface) { $builder->add($child); + return; } @@ -186,24 +187,28 @@ private function getDataClass(FormInterface $form): string */ private function getAssociationTargetClass(string $class, string $childName): string { - if (null === $propertyTypeInfo = $this->propertyInfoExtractor->getType($class, $childName)) { - throw new \RuntimeException(sprintf('Unable to find the association target class of "%s" in %s.', $childName, $class)); + if (null === $propTypeInfo = $this->propertyInfoExtractor->getType($class, $childName)) { + throw new \RuntimeException(\sprintf('Unable to find the association target class of "%s" in %s.', $childName, $class)); } - $innerType = $propertyTypeInfo instanceof TypeInfo\CollectionType ? $propertyTypeInfo->getCollectionValueType() : $propertyTypeInfo; + $innerType = $propTypeInfo instanceof TypeInfo\CollectionType ? $propTypeInfo->getCollectionValueType() : $propTypeInfo; if (!$innerType instanceof TypeInfo\ObjectType) { - throw new \RuntimeException(sprintf('Unable to find the association target class of "%s" in %s.', $childName, $class)); + throw new \RuntimeException(\sprintf('Unable to find the association target class of "%s" in %s.', $childName, $class)); } return $innerType->getClassName(); } - private function updateChildOptions(array $baseChildOptions, TypeInfo $propertyTypeInfo, bool $formChildEmbedded): array - { - $isObject = $propertyTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT); + private function updateChildOptions( + array $baseChildOptions, + TypeInfo $propTypeInfo, + bool $formChildEmbedded, + int $formLevel, + ): array { + $isObject = $propTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT); - if (!$isObject && !$propertyTypeInfo instanceof TypeInfo\CollectionType) { - // TODO Enrich child_type & required? + if (!$isObject && !$propTypeInfo instanceof TypeInfo\CollectionType) { + // TODO Enrich child_type & required return $baseChildOptions; } @@ -212,16 +217,18 @@ private function updateChildOptions(array $baseChildOptions, TypeInfo $propertyT } // Embeddable collection? - if ($propertyTypeInfo instanceof TypeInfo\CollectionType) { - $baseCollOptions = [ + if ($propTypeInfo instanceof TypeInfo\CollectionType) { + $baseCollOptions = [ 'child_type' => CollectionType::class, 'allow_add' => true, 'allow_delete' => true, + 'delete_empty' => true, 'by_reference' => false, + 'prototype_name' => '__name'. $formLevel .'__', ...$baseChildOptions, ]; - $collValueType = $propertyTypeInfo->getCollectionValueType(); + $collValueType = $propTypeInfo->getCollectionValueType(); // Object? if ($collValueType instanceof TypeInfo\ObjectType) { @@ -237,19 +244,34 @@ private function updateChildOptions(array $baseChildOptions, TypeInfo $propertyT } // Builtin - // TODO Enrich entry_type? + // TODO Enrich entry_type return $baseCollOptions; } // Embeddable object /** @var TypeInfo\ObjectType */ - $innerType = $propertyTypeInfo instanceof TypeInfo\NullableType ? $propertyTypeInfo->getWrappedType() : $propertyTypeInfo; + $innerType = $propTypeInfo instanceof TypeInfo\NullableType ? $propTypeInfo->getWrappedType() : $propTypeInfo; return [ 'child_type' => AutoType::class, 'data_class' => $innerType->getClassName(), - 'required' => $propertyTypeInfo->isNullable(), - ...$baseChildOptions + 'required' => $propTypeInfo->isNullable(), + ...$baseChildOptions, ]; } + + private function getFormLevel(FormInterface $form): int + { + if ($form->isRoot()) { + return 0; + } + + $level = 0; + while (null !== $formParent = $form->getParent()) { + $form = $formParent; + $level++; + } + + return $level; + } } diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index 58bb343..aecb1f3 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -1,6 +1,4 @@ - [], 'builder' => null, ]); + $resolver->setAllowedTypes('builder', ['null', 'callable']); $resolver->setInfo('builder', 'A callable that accepts two arguments (FormBuilderInterface $builder, string[] $classProperties). It should not return anything.'); diff --git a/tests/Fixtures/Dto/Media1.php b/tests/Fixtures/Dto/Media1.php new file mode 100644 index 0000000..0a28187 --- /dev/null +++ b/tests/Fixtures/Dto/Media1.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 A2lix\AutoFormBundle\Tests\Fixtures\Dto; + +class Media1 +{ + public function __construct( + public readonly ?string $id = null, + public readonly ?string $url = null, + private ?string $description = null, + ) {} + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } +} diff --git a/tests/Fixtures/Dto/Product1.php b/tests/Fixtures/Dto/Product1.php new file mode 100644 index 0000000..015cfbd --- /dev/null +++ b/tests/Fixtures/Dto/Product1.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 A2lix\AutoFormBundle\Tests\Fixtures\Dto; + +use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; + +class Product1 +{ + /** + * @param list $tags + * @param Collection $mediaColl + */ + public function __construct( + public readonly ?string $id = null, + public readonly ?string $title = null, + private ?string $description = null, + public readonly ?int $code = null, + public readonly array $tags = [], + public readonly ?Media1 $mediaMain = null, + public ?Collection $mediaColl = null, + public readonly ?ProductStatus $status = null, + public readonly ?\DateTimeImmutable $validityStartAt = null, + public readonly ?\DateTimeImmutable $validityEndAt = null, + private ?\DateTimeImmutable $createdAt = null, + ) { + $this->mediaColl ??= new ArrayCollection(); + $this->createdAt = new \DateTimeImmutable(); + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } +} diff --git a/tests/Fixtures/Entity/Media.php b/tests/Fixtures/Entity/Media.php deleted file mode 100644 index 5b70b99..0000000 --- a/tests/Fixtures/Entity/Media.php +++ /dev/null @@ -1,75 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Tests\Fixtures\Entity; - -use Doctrine\ORM\Mapping as ORM; - -#[ORM\Entity] -class Media -{ - #[ORM\Id] - #[ORM\Column(type: 'integer')] - #[ORM\GeneratedValue(strategy: 'AUTO')] - private ?int $id = null; - - #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'medias')] - private Product $product; - - #[ORM\Column(nullable: true)] - private ?string $url = null; - - #[ORM\Column(nullable: true)] - private ?string $description = null; - - public function getId(): ?int - { - return $this->id; - } - - public function getProduct(): Product - { - return $this->product; - } - - public function setProduct(Product $product): self - { - $this->product = $product; - - return $this; - } - - public function getUrl(): ?string - { - return $this->url; - } - - public function setUrl(?string $url): self - { - $this->url = $url; - - return $this; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function setDescription(?string $description): self - { - $this->description = $description; - - return $this; - } -} diff --git a/tests/Fixtures/Entity/Media1.php b/tests/Fixtures/Entity/Media1.php new file mode 100644 index 0000000..faa68de --- /dev/null +++ b/tests/Fixtures/Entity/Media1.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 A2lix\AutoFormBundle\Tests\Fixtures\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class Media1 +{ + #[ORM\Id] + #[ORM\Column] + #[ORM\GeneratedValue] + public ?int $id = null; + + #[ORM\Column] + public \DateTimeImmutable $created; + + #[ORM\Column] + public string $url; + + #[ORM\Column(nullable: true)] + public ?string $description = null; + + #[ORM\ManyToOne(targetEntity: Product1::class, inversedBy: 'mediaColl')] + #[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', nullable: false)] + public Product1 $product; +} diff --git a/tests/Fixtures/Entity/Product.php b/tests/Fixtures/Entity/Product.php deleted file mode 100644 index 898dcdb..0000000 --- a/tests/Fixtures/Entity/Product.php +++ /dev/null @@ -1,122 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Tests\Fixtures\Entity; - -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; -use Doctrine\ORM\Mapping as ORM; - -#[ORM\Entity] -class Product -{ - #[ORM\Id] - #[ORM\Column(type: 'integer')] - #[ORM\GeneratedValue(strategy: 'AUTO')] - private ?int $id = null; - - #[ORM\Column(nullable: true)] - private ?string $title = null; - - #[ORM\Column(type: 'text', nullable: true)] - private ?string $description = null; - - #[ORM\Column(nullable: true)] - private ?string $url = null; - - #[ORM\ManyToOne(targetEntity: Media::class)] - private Media $mainMedia; - - #[ORM\OneToMany(targetEntity: Media::class, mappedBy: 'product', cascade: ['all'], orphanRemoval: true)] - private ArrayCollection $medias; - - public function __construct() - { - $this->medias = new ArrayCollection(); - } - - public function getId(): ?int - { - return $this->id; - } - - public function getTitle(): ?string - { - return $this->title; - } - - public function setTitle(?string $title): self - { - $this->title = $title; - - return $this; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function setDescription(?string $description): self - { - $this->description = $description; - - return $this; - } - - public function getUrl(): ?string - { - return $this->url; - } - - public function setUrl(?string $url): self - { - $this->url = $url; - - return $this; - } - - public function getMainMedia(): ?Media - { - return $this->mainMedia; - } - - public function setMainMedia(?Media $mainMedia): self - { - $this->mainMedia = $mainMedia; - - return $this; - } - - public function getMedias(): Collection - { - return $this->medias; - } - - public function addMedia(Media $media): self - { - if (!$this->medias->contains($media)) { - $media->setProduct($this); - $this->medias->add($media); - } - - return $this; - } - - public function removeMedia(Media $media): self - { - $this->medias->removeElement($media); - - return $this; - } -} diff --git a/tests/Fixtures/Entity/Product1.php b/tests/Fixtures/Entity/Product1.php new file mode 100644 index 0000000..3e7f045 --- /dev/null +++ b/tests/Fixtures/Entity/Product1.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 A2lix\AutoFormBundle\Tests\Fixtures\Entity; + +use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class Product1 +{ + #[ORM\Id] + #[ORM\Column] + #[ORM\GeneratedValue] + public ?int $id = null; + + #[ORM\Column] + public string $title; + + #[ORM\Column(nullable: true)] + private ?string $description = null; + + #[ORM\Column] + public int $code; + + #[ORM\Column] + public array $tags = []; + + #[ORM\ManyToOne(targetEntity: Media1::class)] + public ?Media1 $mediaMain = null; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: Media1::class, mappedBy: 'product', cascade: ['all'], orphanRemoval: true)] + public Collection $mediaColl; + + #[ORM\Column] + public ProductStatus $status; + + #[ORM\Column] + public \DateTimeImmutable $validityStartAt; + + #[ORM\Column] + public \DateTimeImmutable $validityEndAt; + + #[ORM\Column] + private \DateTimeImmutable $createdAt; + + public function __construct() + { + $this->mediaColl = new ArrayCollection(); + $this->createdAt = new \DateTimeImmutable(); + } +} diff --git a/tests/Fixtures/ProductStatus.php b/tests/Fixtures/ProductStatus.php new file mode 100644 index 0000000..75fcda1 --- /dev/null +++ b/tests/Fixtures/ProductStatus.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 A2lix\AutoFormBundle\Tests\Fixtures; + +enum ProductStatus +{ + case Available; + case Unavailable; +} diff --git a/tests/Fixtures/Type/ValidityRangeType.php b/tests/Fixtures/Type/ValidityRangeType.php new file mode 100644 index 0000000..4e0c264 --- /dev/null +++ b/tests/Fixtures/Type/ValidityRangeType.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 A2lix\AutoFormBundle\Tests\Fixtures\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ValidityRangeType extends AbstractType +{ + #[\Override] + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('validityStartAt', DateTimeType::class, [ + 'input' => 'datetime_immutable', + 'date_widget' => 'single_text', + 'time_widget' => 'single_text', + 'attr' => ['class' => 'form-grid'], + ]) + ->add('validityEndAt', DateTimeType::class, [ + 'input' => 'datetime_immutable', + 'date_widget' => 'single_text', + 'time_widget' => 'single_text', + 'attr' => ['class' => 'form-grid'], + ]) + ; + } + + #[\Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'inherit_data' => true, + ]); + } +} diff --git a/tests/Form/Type/AutoTypeDtoTest.php b/tests/Form/Type/AutoTypeDtoTest.php index da1d9d4..66fa9b1 100755 --- a/tests/Form/Type/AutoTypeDtoTest.php +++ b/tests/Form/Type/AutoTypeDtoTest.php @@ -1,6 +1,4 @@ -factory + ->createBuilder(AutoType::class, $scenario->dto, $scenario->formOptions) + ->getForm() + ; + + self::assertSame(array_keys($scenario->expectedForm), array_keys($form->all())); + foreach ($form->all() as $childName => $child) { + /** @var string $childName */ + /** @psalm-suppress PossiblyUndefinedArrayOffset */ + if (null !== $expectedType = $scenario->expectedForm[$childName]['expected_type'] ?? null) { + self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class); + } + + $expectedPartialOptions = $scenario->expectedForm[$childName]; + $actualOptions = $child->getConfig()->getOptions(); + self::assertSame($expectedPartialOptions, array_intersect_key($actualOptions, $expectedPartialOptions), $childName); + } + } + + public static function provideScenarios(): iterable + { + yield 'Product1 without formOptions' => [ + new DtoScenario( + dto: new Product1(), + expectedForm: [ + 'title' => [ + 'expected_type' => FormType\TextType::class, + ], + 'code' => [ + 'expected_type' => FormType\TextType::class, + ], + 'tags' => [ + 'expected_type' => FormType\TextType::class, + ], + 'mediaMain' => [ + 'expected_type' => FormType\TextType::class, + ], + 'mediaColl' => [ + 'expected_type' => FormType\TextType::class, + ], + 'status' => [ + 'expected_type' => FormType\TextType::class, + ], + 'validityStartAt' => [ + 'expected_type' => FormType\TextType::class, + ], + 'validityEndAt' => [ + 'expected_type' => FormType\TextType::class, + ], + 'description' => [ + 'expected_type' => FormType\TextType::class, + ], + ], + ), + ]; + } +} + +class DtoScenario +{ + /** + * @param array $expectedForm + */ + public function __construct( + public readonly ?object $dto, + public readonly array $formOptions = [], + public readonly array $expectedForm = [], + ) {} } diff --git a/tests/Form/Type/AutoTypeEntityTest.php b/tests/Form/Type/AutoTypeEntityTest.php index fd37023..386bd35 100755 --- a/tests/Form/Type/AutoTypeEntityTest.php +++ b/tests/Form/Type/AutoTypeEntityTest.php @@ -1,6 +1,4 @@ -setProxyDir(__DIR__ . '/../proxies'); - $config->setProxyNamespace('EntityProxy'); + $config = ORMSetup::createAttributeMetadataConfig([__DIR__.'/../Fixtures/Entity'], true); + // $config->setProxyDir(__DIR__.'/../proxies'); + // $config->setProxyNamespace('EntityProxy'); + $config->enableNativeLazyObjects(true); $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config); $entityManager = new EntityManager($connection, $config); @@ -64,21 +63,22 @@ private function getPropertyInfoExtractor(): PropertyInfoExtractor return new PropertyInfoExtractor( listExtractors: [ $reflectionExtractor, - $doctrineExtractor + $doctrineExtractor, ], - typeExtractors:[ + typeExtractors: [ $doctrineExtractor, new PhpStanExtractor(), new PhpDocExtractor(), - $reflectionExtractor + $reflectionExtractor, ], accessExtractors: [ $doctrineExtractor, - $reflectionExtractor + $reflectionExtractor, ] ); } + #[\Override] protected function getExtensions(): array { $autoType = $this->getConfiguredAutoType(['id']); From 85ca905b1129335cd400f5917e6fb3edfdd38071 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 7 Oct 2025 18:32:49 +0000 Subject: [PATCH 03/35] Progress --- rector.php | 4 ++-- src/Form/Builder/AutoTypeBuilder.php | 4 ++-- tests/Form/Type/AutoTypeDtoTest.php | 23 +++++++++++++---------- tests/Form/Type/AutoTypeEntityTest.php | 4 ++-- tests/Form/TypeTestCase.php | 11 +++++++---- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/rector.php b/rector.php index 9912bb5..2a305c2 100644 --- a/rector.php +++ b/rector.php @@ -23,11 +23,11 @@ typeDeclarations: true, typeDeclarationDocblocks: true, privatization: true, - naming: true, + // naming: true, instanceOf: true, earlyReturn: true, strictBooleans: true, - carbon: true, + // carbon: true, rectorPreset: true, phpunitCodeQuality: true, doctrineCodeQuality: true, diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index b75ec98..930172d 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -224,7 +224,7 @@ private function updateChildOptions( 'allow_delete' => true, 'delete_empty' => true, 'by_reference' => false, - 'prototype_name' => '__name'. $formLevel .'__', + 'prototype_name' => '__name'.$formLevel.'__', ...$baseChildOptions, ]; @@ -269,7 +269,7 @@ private function getFormLevel(FormInterface $form): int $level = 0; while (null !== $formParent = $form->getParent()) { $form = $formParent; - $level++; + ++$level; } return $level; diff --git a/tests/Form/Type/AutoTypeDtoTest.php b/tests/Form/Type/AutoTypeDtoTest.php index 66fa9b1..0395cee 100755 --- a/tests/Form/Type/AutoTypeDtoTest.php +++ b/tests/Form/Type/AutoTypeDtoTest.php @@ -14,6 +14,7 @@ use A2lix\AutoFormBundle\Form\Type\AutoType; use A2lix\AutoFormBundle\Tests\Fixtures\Dto\Product1; use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; +use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Form\Extension\Core\Type as FormType; @@ -21,35 +22,37 @@ * @internal * * @psalm-suppress PropertyNotSetInConstructor - * - * @coversNothing */ +#[CoversNothing] final class AutoTypeDtoTest extends TypeTestCase { - #[DataProvider('provideScenarios')] - public function testScenario(DtoScenario $scenario): void + #[DataProvider('provideScenarioCases')] + public function testScenario(DtoScenario $dtoScenario): void { $form = $this->factory - ->createBuilder(AutoType::class, $scenario->dto, $scenario->formOptions) + ->createBuilder(AutoType::class, $dtoScenario->dto, $dtoScenario->formOptions) ->getForm() ; - self::assertSame(array_keys($scenario->expectedForm), array_keys($form->all())); + self::assertSame(array_keys($dtoScenario->expectedForm), array_keys($form->all())); foreach ($form->all() as $childName => $child) { /** @var string $childName */ /** @psalm-suppress PossiblyUndefinedArrayOffset */ - if (null !== $expectedType = $scenario->expectedForm[$childName]['expected_type'] ?? null) { - self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class); + if (null !== $expectedType = $dtoScenario->expectedForm[$childName]['expected_type'] ?? null) { + self::assertSame($child->getConfig()->getType()->getInnerType()::class, $expectedType); } - $expectedPartialOptions = $scenario->expectedForm[$childName]; + $expectedPartialOptions = $dtoScenario->expectedForm[$childName]; $actualOptions = $child->getConfig()->getOptions(); self::assertSame($expectedPartialOptions, array_intersect_key($actualOptions, $expectedPartialOptions), $childName); } } - public static function provideScenarios(): iterable + /** + * @return \Iterator> + */ + public static function provideScenarioCases(): iterable { yield 'Product1 without formOptions' => [ new DtoScenario( diff --git a/tests/Form/Type/AutoTypeEntityTest.php b/tests/Form/Type/AutoTypeEntityTest.php index 386bd35..146f525 100755 --- a/tests/Form/Type/AutoTypeEntityTest.php +++ b/tests/Form/Type/AutoTypeEntityTest.php @@ -12,12 +12,12 @@ namespace A2lix\AutoFormBundle\Tests\Form\Type; use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; +use PHPUnit\Framework\Attributes\CoversNothing; /** * @internal - * - * @coversNothing */ +#[CoversNothing] final class AutoTypeEntityTest extends TypeTestCase { } diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 062991e..70d415f 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -31,6 +31,9 @@ abstract class TypeTestCase extends BaseTypeTestCase protected ?AutoTypeBuilder $autoTypeBuilder = null; + /** + * @param list $childrenExcluded + */ protected function getConfiguredAutoType(array $childrenExcluded = []): AutoType { return new AutoType($this->getAutoTypeBuilder(), $childrenExcluded); @@ -49,13 +52,13 @@ private function getAutoTypeBuilder(): AutoTypeBuilder private function getPropertyInfoExtractor(): PropertyInfoExtractor { - $config = ORMSetup::createAttributeMetadataConfig([__DIR__.'/../Fixtures/Entity'], true); + $configuration = ORMSetup::createAttributeMetadataConfig([__DIR__.'/../Fixtures/Entity'], true); // $config->setProxyDir(__DIR__.'/../proxies'); // $config->setProxyNamespace('EntityProxy'); - $config->enableNativeLazyObjects(true); + $configuration->enableNativeLazyObjects(true); - $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config); - $entityManager = new EntityManager($connection, $config); + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $configuration); + $entityManager = new EntityManager($connection, $configuration); $doctrineExtractor = new DoctrineExtractor($entityManager); $reflectionExtractor = new ReflectionExtractor(); From a426a9ffabbf4055710bc1a3e8090c705a2f4040 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:22:32 +0000 Subject: [PATCH 04/35] Progress --- .devcontainer/Dockerfile | 1 + .devcontainer/devcontainer.json | 11 +- config/services.php | 9 ++ src/Form/Builder/AutoTypeBuilder.php | 47 ++++---- src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 109 +++++++++++++++++++ 5 files changed, 155 insertions(+), 22 deletions(-) create mode 100644 src/Form/TypeGuesser/TypeInfoTypeGuesser.php diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 85f950b..cb72d12 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,6 +3,7 @@ FROM php:8.4-cli-trixie RUN apt-get update && apt-get install -y --no-install-recommends \ file \ git \ + openssh-client \ && rm -rf /var/lib/apt/lists/* ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ed3318c..b8cf762 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,8 +5,15 @@ }, "customizations": { "vscode": { - "extensions": [ "bmewburn.vscode-intelephense-client", "xdebug.php-debug", "getpsalm.psalm-vscode-plugin" ] + "extensions": [ + "bmewburn.vscode-intelephense-client", + "xdebug.php-debug", + "getpsalm.psalm-vscode-plugin" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "bash" + } } }, "remoteUser": "vscode" -} +} \ No newline at end of file diff --git a/config/services.php b/config/services.php index 0826f65..6fb990e 100644 --- a/config/services.php +++ b/config/services.php @@ -15,6 +15,7 @@ use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\AutoFormBundle\Form\TypeGuesser\TypeInfoTypeGuesser; return static function (ContainerConfigurator $container): void { $container->services() @@ -22,10 +23,18 @@ ->args([ '$propertyInfoExtractor' => service('property_info'), ]) + ->set('a2lix_auto_form.form.type.auto_type', AutoType::class) ->args([ '$autoTypeBuilder' => service('a2lix_auto_form.form.builder.auto_type_builder'), ]) ->tag('form.type') + + + ->set('a2lix_auto_form.type_guesser.type_info', TypeInfoTypeGuesser::class) + ->args([ + '$typeResolver' => service('type_info.resolver'), + ]) + ->tag('form.type_guesser') ; }; diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 930172d..18a08bd 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -52,10 +52,6 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) continue; } - // if (!$this->propertyInfoExtractor->isWritable($dataClass, $classProperty)) { - // continue; - // } - $propFormOptions = $formOptions['children'][$classProperty] ?? null; $refProperty = $refClass->getProperty($classProperty); @@ -102,7 +98,10 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) /** @var list $formOptions['children_embedded'] */ $formChildEmbedded = $allChildrenEmbedded || \in_array($classProperty, $formOptions['children_embedded'], true) || ($propAttributeOptions['child_embedded'] ?? false); - $childOptions = $this->updateChildOptions($childOptions, $propTypeInfo, $formChildEmbedded, $formLevel); + + if ($formChildEmbedded) { + $childOptions = $this->updateChildOptions($childOptions, $propTypeInfo, $formLevel); + } } $this->addChild($builder, $classProperty, $childOptions); @@ -199,20 +198,9 @@ private function getAssociationTargetClass(string $class, string $childName): st return $innerType->getClassName(); } - private function updateChildOptions( - array $baseChildOptions, - TypeInfo $propTypeInfo, - bool $formChildEmbedded, - int $formLevel, - ): array { - $isObject = $propTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT); - - if (!$isObject && !$propTypeInfo instanceof TypeInfo\CollectionType) { - // TODO Enrich child_type & required - return $baseChildOptions; - } - - if (!$formChildEmbedded) { + private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeInfo, int $formLevel): array + { + if ($propTypeInfo->isSatisfiedBy($this->isTypeInfoWithMatchingNativeFormType(...))) { return $baseChildOptions; } @@ -244,7 +232,6 @@ private function updateChildOptions( } // Builtin - // TODO Enrich entry_type return $baseCollOptions; } @@ -260,6 +247,26 @@ private function updateChildOptions( ]; } + private static function isTypeInfoWithMatchingNativeFormType(TypeInfo $propTypeInfo): bool + { + if ($propTypeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { + return false; + } + + if (!$propTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) { + return true; + } + + return $propTypeInfo->isIdentifiedBy(\BackedEnum::class) + || $propTypeInfo->isIdentifiedBy(\DateTimeImmutable::class) + || $propTypeInfo->isIdentifiedBy(\DateTimeImmutable::class) + || $propTypeInfo->isIdentifiedBy(\DateInterval::class) + || $propTypeInfo->isIdentifiedBy(\DateTimeZone::class) + || $propTypeInfo->isIdentifiedBy('Symfony\Component\HttpFoundation\File\File') + || $propTypeInfo->isIdentifiedBy('Symfony\Component\Uid\Ulid') + || $propTypeInfo->isIdentifiedBy('Symfony\Component\Uid\Uuid'); + } + private function getFormLevel(FormInterface $form): int { if ($form->isRoot()) { diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php new file mode 100644 index 0000000..d4c8c74 --- /dev/null +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\AutoFormBundle\Form\TypeGuesser; + +use Symfony\Component\Form\Extension\Core\Type as CoreType; +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\TypeInfo\Exception\UnsupportedException; +use Symfony\Component\TypeInfo\Type as TypeInfo; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +class TypeInfoTypeGuesser implements FormTypeGuesserInterface +{ + public function __construct( + private readonly TypeResolverInterface $typeResolver, + ) {} + + #[\Override] + public function guessType(string $class, string $property): ?TypeGuess + { + /** @var class-string $class */ + if (null === $typeInfo = $this->getTypeInfo($class, $property)) { + return null; + } + + if ($typeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) { + if ($typeInfo->isIdentifiedBy(\BackedEnum::class)) { + /** @var ObjectType $typeInfo */ + $className = $typeInfo->getClassName(); + $multiple = $typeInfo->isIdentifiedBy(TypeIdentifier::ARRAY); + + return new TypeGuess(CoreType\EnumType::class, ['class' => $className, 'multiple' => $multiple], Guess::HIGH_CONFIDENCE); + } + + return match (true) { + $typeInfo->isIdentifiedBy(\DateTimeImmutable::class) => new TypeGuess(CoreType\DateTimeType::class, ['input' => 'datetime_immutable'], Guess::HIGH_CONFIDENCE), + $typeInfo->isIdentifiedBy(\DateTimeImmutable::class) => new TypeGuess(CoreType\DateTimeType::class, [], Guess::HIGH_CONFIDENCE), + $typeInfo->isIdentifiedBy(\DateInterval::class) => new TypeGuess(CoreType\DateIntervalType::class, [], Guess::HIGH_CONFIDENCE), + $typeInfo->isIdentifiedBy(\DateTimeZone::class) => new TypeGuess(CoreType\TimezoneType::class, ['input' => 'datetimezone'], Guess::HIGH_CONFIDENCE), + $typeInfo->isIdentifiedBy('Symfony\Component\HttpFoundation\File\File') => new TypeGuess(CoreType\FileType::class, [], Guess::HIGH_CONFIDENCE), + $typeInfo->isIdentifiedBy('Symfony\Component\Uid\Ulid') => new TypeGuess(CoreType\UlidType::class, [], Guess::HIGH_CONFIDENCE), + $typeInfo->isIdentifiedBy('Symfony\Component\Uid\Uuid') => new TypeGuess(CoreType\UuidType::class, [], Guess::HIGH_CONFIDENCE), + default => new TypeGuess(CoreType\TextType::class, [], Guess::LOW_CONFIDENCE) + }; + } + + return match (true) { + $typeInfo->isIdentifiedBy(TypeIdentifier::STRING) => new TypeGuess(CoreType\TextType::class, [], Guess::MEDIUM_CONFIDENCE), + $typeInfo->isIdentifiedBy(TypeIdentifier::INT) => new TypeGuess(CoreType\IntegerType::class, [], Guess::MEDIUM_CONFIDENCE), + $typeInfo->isIdentifiedBy(TypeIdentifier::FLOAT) => new TypeGuess(CoreType\NumberType::class, [], Guess::MEDIUM_CONFIDENCE), + $typeInfo->isIdentifiedBy(TypeIdentifier::BOOL) => new TypeGuess(CoreType\CheckboxType::class, [], Guess::HIGH_CONFIDENCE), + default => new TypeGuess(CoreType\TextType::class, [], Guess::LOW_CONFIDENCE) + }; + } + + #[\Override] + public function guessRequired(string $class, string $property): ?ValueGuess + { + /** @var class-string $class */ + if (null === $typeInfo = $this->getTypeInfo($class, $property)) { + return null; + } + + return new ValueGuess(!$typeInfo->isNullable(), Guess::MEDIUM_CONFIDENCE); + } + + #[\Override] + public function guessMaxLength(string $class, string $property): ?ValueGuess + { + return null; + } + + #[\Override] + public function guessPattern(string $class, string $property): ?ValueGuess + { + return null; + } + + /** + * @param class-string $class + */ + private function getTypeInfo(string $class, string $property): ?TypeInfo + { + try { + $refProperty = new \ReflectionProperty($class, $property); + } catch (\ReflectionException $e) { + return null; + } + + try { + return $this->typeResolver->resolve($refProperty); + } catch (UnsupportedException $e) { + return null; + } + } +} From 8a73c194fd788f0c26c7c80ba8f5b70a4f1b6f29 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:33:29 +0000 Subject: [PATCH 05/35] Progress --- .github/workflows/ci.yml | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b8e45f..034bd85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,19 +18,23 @@ jobs: php-version: '8.4' - name: Cache Composer packages - id: composer-cache uses: actions/cache@v4 with: - path: vendor - key: ${{ runner.os }}-php-8.4-${{ hashFiles(''**/composer.lock'') }} + path: | + vendor + ~/.composer/cache + key: ${{ runner.os }}-php-8.4-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-php-8.4- + - name: Validate composer.json and composer.lock + run: composer validate --strict + - name: Install dependencies run: composer install --prefer-dist --no-progress - - name: Run php-cs-fixer - run: vendor/bin/php-cs-fixer check --diff --verbose + - name: Run php-cs-fixer (dry run) + run: vendor/bin/php-cs-fixer fix --dry-run --diff --verbose tests: runs-on: ubuntu-latest @@ -52,17 +56,21 @@ jobs: php-version: ${{ matrix.php }} - name: Cache Composer packages - id: composer-cache uses: actions/cache@v4 with: - path: vendor - key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ hashFiles(''**/composer.lock'') }} + path: | + vendor + ~/.composer/cache + key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}- + - name: Validate composer.json and composer.lock + run: composer validate --strict + - name: Update dependencies for Symfony ${{ matrix.symfony }} run: | - composer require "symfony/config:${{ matrix.symfony }}" "symfony/dependency-injection:${{ matrix.symfony }}" "symfony/doctrine-bridge:${{ matrix.symfony }}" "symfony/form:${{ matrix.symfony }}" "symfony/http-kernel:${{ matrix.symfony }}" "symfony/property-info:${{ matrix.symfony }}" "symfony/cache:${{ matrix.symfony }}" "symfony/validator:${{ matrix.symfony }}" "symfony/var-dumper:${{ matrix.symfony }}" --no-update + composer require "symfony/config:${{ matrix.symfony }}" "symfony/dependency-injection:${{ matrix.symfony }}" "symfony/doctrine-bridge:${{ matrix.symfony }}" "symfony/form:${{ matrix.symfony }}" --no-update composer update --prefer-dist --no-progress --optimize-autoloader - name: Run psalm @@ -70,3 +78,6 @@ jobs: - name: Run phpunit run: vendor/bin/phpunit + + - name: Check for outdated dependencies + run: composer outdated From 6ca00d33a2f2639d7b45d83e84619124a7123b01 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:57:03 +0000 Subject: [PATCH 06/35] Progress --- .devcontainer/devcontainer.json | 2 +- .gitignore | 1 + README.md | 29 +++-- src/Form/Builder/AutoTypeBuilder.php | 21 ++-- src/Form/Type/AutoType.php | 4 +- src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 26 ++-- tests/Form/Type/AutoTypeDtoTest.php | 120 ++++++++++++++++++- tests/Form/TypeTestCase.php | 73 +++++++---- 8 files changed, 216 insertions(+), 60 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b8cf762..37b5d65 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "A2lix - AutoFormBundle", + "name": "A2LiX - AutoFormBundle", "build": { "dockerfile": "Dockerfile" }, diff --git a/.gitignore b/.gitignore index e7c4bef..43a5daf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .phpunit.cache composer.lock vendor/* +dump.html diff --git a/README.md b/README.md index 9748f25..b8d028d 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,17 @@ composer require a2lix/auto-form-bundle The simplest way to use `AutoType` is directly in your controller. It will generate a form based on the properties of the entity or DTO you pass it. ```php -use A2lix\AutoFormBundle\Form\Type\AutoType; +// ... class TaskController extends AbstractController { - public function new(): Response + public function new(Request $request): Response { $task = new Task(); // Any entity or DTO - $form = $this->createForm(AutoType::class, $task); + $form = $this->createForm(AutoType::class, $task) + ->add('save', SubmitType::class) + ->handleRequest($request) + ; // ... } @@ -49,24 +52,23 @@ Options passed directly to the form will always take precedence over attributes. This is the most flexible way to configure your form. Here is a comprehensive example: ```php -use Symfony\Component\Form\Extension\Core\Type\MoneyType; -use Symfony\Component\Form\Extension\Core\Type\TextareaType; +// ... class TaskController extends AbstractController { - public function new(FormFactoryInterface $formFactory): Response + public function new(Request $request, FormFactoryInterface $formFactory): Response { $product = new Product(); // Any entity or DTO $form = $formFactory->createNamed('product', AutoType::class, $product, [ - // 1. Exclude properties from the form. + // 1. Optional define which properties should be excluded from the form. // Use '*' for an "exclude-by-default" strategy. 'children_excluded' => ['id', 'internalRef'], - // 2. Define which relations should be rendered as embedded forms. + // 2. Optional define which properties should be rendered as embedded forms. // Use '*' to embed all relational properties. 'children_embedded' => ['category', 'tags'], - // 3. Customize, override, or add fields. + // 3. Optional customize, override, or add fields. 'children' => [ // Override an existing property with new options 'description' => [ @@ -88,16 +90,21 @@ class TaskController extends AbstractController // It must return a new FormBuilderInterface instance. return $builder->create('price', MoneyType::class, ['currency' => 'EUR']); }, + + // Add a new field to the form + 'save' => [ + 'child_type' => SubmitType::class, + ], ], - // 4. For final modifications on the complete form builder. + // 4. Optional final modifications on the complete form builder. 'builder' => function(FormBuilderInterface $builder, array $classProperties): void { // This callable runs after all children have been added. if (isset($classProperties['code'])) { $builder->remove('code'); } }, - ]); + ])->handleRequest($request); // ... } diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 18a08bd..2f72a01 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -200,7 +200,7 @@ private function getAssociationTargetClass(string $class, string $childName): st private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeInfo, int $formLevel): array { - if ($propTypeInfo->isSatisfiedBy($this->isTypeInfoWithMatchingNativeFormType(...))) { + if ($propTypeInfo->isSatisfiedBy(self::isTypeInfoWithMatchingNativeFormType(...))) { return $baseChildOptions; } @@ -249,22 +249,23 @@ private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeI private static function isTypeInfoWithMatchingNativeFormType(TypeInfo $propTypeInfo): bool { + // Check matching some array array with high confidence FormType handling 'multiple' option if ($propTypeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { - return false; + return $propTypeInfo instanceof TypeInfo\GenericType + && $propTypeInfo->getVariableTypes()[1]->isIdentifiedBy(\UnitEnum::class, \DateTimeZone::class); } if (!$propTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) { return true; } - return $propTypeInfo->isIdentifiedBy(\BackedEnum::class) - || $propTypeInfo->isIdentifiedBy(\DateTimeImmutable::class) - || $propTypeInfo->isIdentifiedBy(\DateTimeImmutable::class) - || $propTypeInfo->isIdentifiedBy(\DateInterval::class) - || $propTypeInfo->isIdentifiedBy(\DateTimeZone::class) - || $propTypeInfo->isIdentifiedBy('Symfony\Component\HttpFoundation\File\File') - || $propTypeInfo->isIdentifiedBy('Symfony\Component\Uid\Ulid') - || $propTypeInfo->isIdentifiedBy('Symfony\Component\Uid\Uuid'); + // Check matching some objects with high confidence FormType + return $propTypeInfo->isIdentifiedBy( + \UnitEnum::class, + \DateTime::class, \DateTimeImmutable::class, \DateInterval::class, \DateTimeZone::class, + 'Symfony\Component\Uid\Ulid', 'Symfony\Component\Uid\Uuid', + 'Symfony\Component\HttpFoundation\File\File', + ); } private function getFormLevel(FormInterface $form): int diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index aecb1f3..a6983f9 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -61,8 +61,8 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('builder', ['null', 'callable']); $resolver->setInfo('builder', 'A callable that accepts two arguments (FormBuilderInterface $builder, string[] $classProperties). It should not return anything.'); - $resolver->setNormalizer('data_class', static function (Options $options, string $value): string { - if (empty($value)) { + $resolver->setNormalizer('data_class', static function (Options $options, ?string $value): string { + if (null === $value) { throw new \RuntimeException('Missing "data_class" option of "AutoType".'); } diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php index d4c8c74..c236a72 100644 --- a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -36,23 +36,35 @@ public function guessType(string $class, string $property): ?TypeGuess return null; } + // FormTypes handling 'multiple' option + if ($typeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { + /** @var TypeInfo\CollectionType $typeInfo */ + $collValueType = $typeInfo->getCollectionValueType(); + /** @var TypeInfo\ObjectType $collValueType */ + + return match (true) { + $collValueType->isIdentifiedBy(\UnitEnum::class) => new TypeGuess(CoreType\EnumType::class, ['class' => $collValueType->getClassName(), 'multiple' => true], Guess::HIGH_CONFIDENCE), + $collValueType->isIdentifiedBy(\DateTimeZone::class) => new TypeGuess(CoreType\TimezoneType::class, ['input' => 'datetimezone', 'multiple' => true], Guess::HIGH_CONFIDENCE), + default => new TypeGuess(CoreType\TextType::class, [], Guess::LOW_CONFIDENCE) + }; + } + if ($typeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) { - if ($typeInfo->isIdentifiedBy(\BackedEnum::class)) { - /** @var ObjectType $typeInfo */ - $className = $typeInfo->getClassName(); - $multiple = $typeInfo->isIdentifiedBy(TypeIdentifier::ARRAY); + if ($typeInfo->isIdentifiedBy(\UnitEnum::class)) { + /** @var ObjectType */ + $innerType = $typeInfo instanceof TypeInfo\NullableType ? $typeInfo->getWrappedType() : $typeInfo; - return new TypeGuess(CoreType\EnumType::class, ['class' => $className, 'multiple' => $multiple], Guess::HIGH_CONFIDENCE); + return new TypeGuess(CoreType\EnumType::class, ['class' => $innerType->getClassName()], Guess::HIGH_CONFIDENCE); } return match (true) { + $typeInfo->isIdentifiedBy(\DateTime::class) => new TypeGuess(CoreType\DateTimeType::class, [], Guess::HIGH_CONFIDENCE), $typeInfo->isIdentifiedBy(\DateTimeImmutable::class) => new TypeGuess(CoreType\DateTimeType::class, ['input' => 'datetime_immutable'], Guess::HIGH_CONFIDENCE), - $typeInfo->isIdentifiedBy(\DateTimeImmutable::class) => new TypeGuess(CoreType\DateTimeType::class, [], Guess::HIGH_CONFIDENCE), $typeInfo->isIdentifiedBy(\DateInterval::class) => new TypeGuess(CoreType\DateIntervalType::class, [], Guess::HIGH_CONFIDENCE), $typeInfo->isIdentifiedBy(\DateTimeZone::class) => new TypeGuess(CoreType\TimezoneType::class, ['input' => 'datetimezone'], Guess::HIGH_CONFIDENCE), - $typeInfo->isIdentifiedBy('Symfony\Component\HttpFoundation\File\File') => new TypeGuess(CoreType\FileType::class, [], Guess::HIGH_CONFIDENCE), $typeInfo->isIdentifiedBy('Symfony\Component\Uid\Ulid') => new TypeGuess(CoreType\UlidType::class, [], Guess::HIGH_CONFIDENCE), $typeInfo->isIdentifiedBy('Symfony\Component\Uid\Uuid') => new TypeGuess(CoreType\UuidType::class, [], Guess::HIGH_CONFIDENCE), + $typeInfo->isIdentifiedBy('Symfony\Component\HttpFoundation\File\File') => new TypeGuess(CoreType\FileType::class, [], Guess::HIGH_CONFIDENCE), default => new TypeGuess(CoreType\TextType::class, [], Guess::LOW_CONFIDENCE) }; } diff --git a/tests/Form/Type/AutoTypeDtoTest.php b/tests/Form/Type/AutoTypeDtoTest.php index 0395cee..577585d 100755 --- a/tests/Form/Type/AutoTypeDtoTest.php +++ b/tests/Form/Type/AutoTypeDtoTest.php @@ -39,13 +39,14 @@ public function testScenario(DtoScenario $dtoScenario): void /** @var string $childName */ /** @psalm-suppress PossiblyUndefinedArrayOffset */ if (null !== $expectedType = $dtoScenario->expectedForm[$childName]['expected_type'] ?? null) { - self::assertSame($child->getConfig()->getType()->getInnerType()::class, $expectedType); + self::assertSame($child->getConfig()->getType()->getInnerType()::class, $expectedType, sprintf('Type of "%s"', $childName)); } $expectedPartialOptions = $dtoScenario->expectedForm[$childName]; + unset($expectedPartialOptions['expected_type']); $actualOptions = $child->getConfig()->getOptions(); - self::assertSame($expectedPartialOptions, array_intersect_key($actualOptions, $expectedPartialOptions), $childName); + self::assertSame($expectedPartialOptions, array_intersect_key($actualOptions, $expectedPartialOptions), sprintf('Options of "%s"', $childName)); } } @@ -54,7 +55,7 @@ public function testScenario(DtoScenario $dtoScenario): void */ public static function provideScenarioCases(): iterable { - yield 'Product1 without formOptions' => [ + yield 'Product1 with default behavior, no options' => [ new DtoScenario( dto: new Product1(), expectedForm: [ @@ -62,7 +63,7 @@ public static function provideScenarioCases(): iterable 'expected_type' => FormType\TextType::class, ], 'code' => [ - 'expected_type' => FormType\TextType::class, + 'expected_type' => FormType\IntegerType::class, ], 'tags' => [ 'expected_type' => FormType\TextType::class, @@ -74,14 +75,125 @@ public static function provideScenarioCases(): iterable 'expected_type' => FormType\TextType::class, ], 'status' => [ + 'expected_type' => FormType\EnumType::class, + ], + 'validityStartAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'validityEndAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'description' => [ + 'expected_type' => FormType\TextType::class, + ], + ], + ), + ]; + + yield 'Product1 with children_embedded = *' => [ + new DtoScenario( + dto: new Product1(), + formOptions: [ + 'children_embedded' => '*', + ], + expectedForm: [ + 'title' => [ + 'expected_type' => FormType\TextType::class, + ], + 'code' => [ + 'expected_type' => FormType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => FormType\CollectionType::class, + ], + 'mediaMain' => [ 'expected_type' => FormType\TextType::class, ], + 'mediaColl' => [ + 'expected_type' => FormType\CollectionType::class, + ], + 'status' => [ + 'expected_type' => FormType\EnumType::class, + ], 'validityStartAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'validityEndAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'description' => [ + 'expected_type' => FormType\TextType::class, + ], + ], + ), + ]; + + yield 'Product1 with children_embedded = [mediaColl]' => [ + new DtoScenario( + dto: new Product1(), + formOptions: [ + 'children_embedded' => ['mediaColl'], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => FormType\TextType::class, + ], + 'code' => [ + 'expected_type' => FormType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => FormType\TextType::class, + ], + 'mediaMain' => [ 'expected_type' => FormType\TextType::class, ], + 'mediaColl' => [ + 'expected_type' => FormType\CollectionType::class, + ], + 'status' => [ + 'expected_type' => FormType\EnumType::class, + ], + 'validityStartAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], 'validityEndAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'description' => [ 'expected_type' => FormType\TextType::class, ], + ], + ), + ]; + + yield 'Product1 with children_excluded = *' => [ + new DtoScenario( + dto: new Product1(), + formOptions: [ + 'children_excluded' => '*', + ], + expectedForm: [], + ), + ]; + + yield 'Product1 with children_excluded = *, custom selection' => [ + new DtoScenario( + dto: new Product1(), + formOptions: [ + 'children_excluded' => '*', + 'children' => [ + 'title' => [], + 'code' => [], + 'description' => [], + ], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => FormType\TextType::class, + ], + 'code' => [ + 'expected_type' => FormType\IntegerType::class, + ], 'description' => [ 'expected_type' => FormType\TextType::class, ], diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 70d415f..51b64ec 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -13,10 +13,16 @@ use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\AutoFormBundle\Form\TypeGuesser\TypeInfoTypeGuesser; use Doctrine\DBAL\DriverManager; use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ORMSetup; +use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\Override; +use Symfony\Bridge\Doctrine\Form\DoctrineOrmTypeGuesser; use Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor; +use Symfony\Component\Form\FormTypeGuesserChain; use Symfony\Component\Form\PreloadedExtension; use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait; use Symfony\Component\Form\Test\TypeTestCase as BaseTypeTestCase; @@ -24,43 +30,60 @@ use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; +use Symfony\Component\VarDumper\Cloner\VarCloner; +use Symfony\Component\VarDumper\Dumper\HtmlDumper; +use Symfony\Component\VarDumper\VarDumper; abstract class TypeTestCase extends BaseTypeTestCase { use ValidatorExtensionTrait; - protected ?AutoTypeBuilder $autoTypeBuilder = null; + private ?EntityManagerInterface $entityManager = null; - /** - * @param list $childrenExcluded - */ - protected function getConfiguredAutoType(array $childrenExcluded = []): AutoType + #[\Override] + protected function setUp(): void { - return new AutoType($this->getAutoTypeBuilder(), $childrenExcluded); + parent::setUp(); + + // VarDumper::setHandler(function (mixed $var) { + // (new HtmlDumper())->dump( + // (new VarCloner())->cloneVar($var), __DIR__.'/../../dump.html' + // ); + // }); } - private function getAutoTypeBuilder(): AutoTypeBuilder + #[\Override] + protected function getExtensions(): array { - if (null !== $this->autoTypeBuilder) { - return $this->autoTypeBuilder; - } - - return $this->autoTypeBuilder = new AutoTypeBuilder( - $this->getPropertyInfoExtractor(), + $autoType = new AutoType( + new AutoTypeBuilder($this->getPropertyInfoExtractor()), + ['id'] ); + + return [ + ...parent::getExtensions(), + new PreloadedExtension([$autoType], [], $this->getFormTypeGuesserChain()), + ]; } - private function getPropertyInfoExtractor(): PropertyInfoExtractor + private function getEntityManager(): EntityManagerInterface { + if (null !== $this->entityManager) { + return $this->entityManager; + } + $configuration = ORMSetup::createAttributeMetadataConfig([__DIR__.'/../Fixtures/Entity'], true); - // $config->setProxyDir(__DIR__.'/../proxies'); - // $config->setProxyNamespace('EntityProxy'); $configuration->enableNativeLazyObjects(true); $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $configuration); - $entityManager = new EntityManager($connection, $configuration); - $doctrineExtractor = new DoctrineExtractor($entityManager); + return $this->entityManager = new EntityManager($connection, $configuration); + } + + private function getPropertyInfoExtractor(): PropertyInfoExtractor + { + $doctrineExtractor = new DoctrineExtractor($this->getEntityManager()); $reflectionExtractor = new ReflectionExtractor(); return new PropertyInfoExtractor( @@ -81,14 +104,14 @@ private function getPropertyInfoExtractor(): PropertyInfoExtractor ); } - #[\Override] - protected function getExtensions(): array + private function getFormTypeGuesserChain(): FormTypeGuesserChain { - $autoType = $this->getConfiguredAutoType(['id']); + $managerRegistry = $this->createMock(ManagerRegistry::class); + $managerRegistry->method('getManagerForClass')->willReturn($this->getEntityManager()); - return [ - ...parent::getExtensions(), - new PreloadedExtension([$autoType], []), - ]; + return new FormTypeGuesserChain([ + new DoctrineOrmTypeGuesser($managerRegistry), + new TypeInfoTypeGuesser(TypeResolver::create()), + ]); } } From 40df1bb7958675d7d8a6ca5763acb58b78c8e96c Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:00:31 +0000 Subject: [PATCH 07/35] Progress --- .php-cs-fixer.dist.php | 5 ++--- src/Form/Builder/AutoTypeBuilder.php | 8 ++++++-- src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 3 +-- tests/Form/Type/AutoTypeDtoTest.php | 4 ++-- tests/Form/TypeTestCase.php | 1 - 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3ec1ea4..ff771ed 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -11,8 +11,7 @@ $finder = (new PhpCsFixer\Finder()) - ->in(['src', 'tests']) -; + ->in(['src', 'tests']); return (new PhpCsFixer\Config()) ->setRiskyAllowed(true) @@ -29,7 +28,7 @@ 'header_comment' => ['header' => $header], 'class_attributes_separation' => ['elements' => ['method' => 'one']], 'class_definition' => ['inline_constructor_arguments' => true], - 'date_time_immutable' => true, + // 'date_time_immutable' => true, 'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false], 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline', 'attribute_placement' => 'ignore'], 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 2f72a01..2227e8a 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -262,8 +262,12 @@ private static function isTypeInfoWithMatchingNativeFormType(TypeInfo $propTypeI // Check matching some objects with high confidence FormType return $propTypeInfo->isIdentifiedBy( \UnitEnum::class, - \DateTime::class, \DateTimeImmutable::class, \DateInterval::class, \DateTimeZone::class, - 'Symfony\Component\Uid\Ulid', 'Symfony\Component\Uid\Uuid', + \DateTime::class, + \DateTimeImmutable::class, + \DateInterval::class, + \DateTimeZone::class, + 'Symfony\Component\Uid\Ulid', + 'Symfony\Component\Uid\Uuid', 'Symfony\Component\HttpFoundation\File\File', ); } diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php index c236a72..e970c3e 100644 --- a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -18,7 +18,6 @@ use Symfony\Component\Form\Guess\ValueGuess; use Symfony\Component\TypeInfo\Exception\UnsupportedException; use Symfony\Component\TypeInfo\Type as TypeInfo; -use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; @@ -51,7 +50,7 @@ public function guessType(string $class, string $property): ?TypeGuess if ($typeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) { if ($typeInfo->isIdentifiedBy(\UnitEnum::class)) { - /** @var ObjectType */ + /** @var TypeInfo\ObjectType */ $innerType = $typeInfo instanceof TypeInfo\NullableType ? $typeInfo->getWrappedType() : $typeInfo; return new TypeGuess(CoreType\EnumType::class, ['class' => $innerType->getClassName()], Guess::HIGH_CONFIDENCE); diff --git a/tests/Form/Type/AutoTypeDtoTest.php b/tests/Form/Type/AutoTypeDtoTest.php index 577585d..e9e153b 100755 --- a/tests/Form/Type/AutoTypeDtoTest.php +++ b/tests/Form/Type/AutoTypeDtoTest.php @@ -39,14 +39,14 @@ public function testScenario(DtoScenario $dtoScenario): void /** @var string $childName */ /** @psalm-suppress PossiblyUndefinedArrayOffset */ if (null !== $expectedType = $dtoScenario->expectedForm[$childName]['expected_type'] ?? null) { - self::assertSame($child->getConfig()->getType()->getInnerType()::class, $expectedType, sprintf('Type of "%s"', $childName)); + self::assertSame($child->getConfig()->getType()->getInnerType()::class, $expectedType, \sprintf('Type of "%s"', $childName)); } $expectedPartialOptions = $dtoScenario->expectedForm[$childName]; unset($expectedPartialOptions['expected_type']); $actualOptions = $child->getConfig()->getOptions(); - self::assertSame($expectedPartialOptions, array_intersect_key($actualOptions, $expectedPartialOptions), sprintf('Options of "%s"', $childName)); + self::assertSame($expectedPartialOptions, array_intersect_key($actualOptions, $expectedPartialOptions), \sprintf('Options of "%s"', $childName)); } } diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 51b64ec..83e8bda 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -19,7 +19,6 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ORMSetup; use Doctrine\Persistence\ManagerRegistry; -use PHPUnit\Framework\Attributes\Override; use Symfony\Bridge\Doctrine\Form\DoctrineOrmTypeGuesser; use Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor; use Symfony\Component\Form\FormTypeGuesserChain; From 121f9f00ff2f8cd86e707ba252e8f90d882e7f33 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:49:55 +0000 Subject: [PATCH 08/35] Fixes --- .php-cs-fixer.dist.php | 2 +- src/Form/Attribute/AutoTypeCustom.php | 4 +- src/Form/Builder/AutoTypeBuilder.php | 15 +- src/Form/Type/AutoType.php | 12 +- tests/Fixtures/Dto/Media1.php | 5 + tests/Fixtures/Entity/Media1.php | 8 +- tests/Fixtures/Entity/Product1.php | 12 ++ tests/Form/Type/AutoTypeDtoTest.php | 99 +++++++------ tests/Form/Type/AutoTypeEntityTest.php | 198 ++++++++++++++++++++++++- tests/Form/Type/TestScenario.php | 31 ++++ tests/Form/TypeTestCase.php | 51 ++++++- 11 files changed, 364 insertions(+), 73 deletions(-) create mode 100644 tests/Form/Type/TestScenario.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index ff771ed..ab67f98 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -34,7 +34,7 @@ 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], 'multiline_promoted_properties' => true, 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'allow_unused_params' => true], - 'nullable_type_declaration_for_default_null_value' => ['use_nullable_type_declaration' => true], + 'nullable_type_declaration_for_default_null_value' => true, 'numeric_literal_separator' => true, 'operator_linebreak' => ['only_booleans' => true, 'position' => 'beginning'], 'ordered_imports' => ['sort_algorithm' => 'alpha', 'imports_order' => ['class', 'function', 'const']], diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php index 84102cb..8126dec 100644 --- a/src/Form/Attribute/AutoTypeCustom.php +++ b/src/Form/Attribute/AutoTypeCustom.php @@ -14,7 +14,7 @@ use A2lix\AutoFormBundle\Form\Type\AutoType; /** - * @psalm-import-type childOptions from AutoType + * @psalm-import-type ChildOptions from AutoType */ #[\Attribute(\Attribute::TARGET_PROPERTY)] final readonly class AutoTypeCustom @@ -32,7 +32,7 @@ public function __construct( ) {} /** - * @return childOptions + * @return ChildOptions */ public function getOptions(): array { diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 2227e8a..b2a25b8 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -21,8 +21,7 @@ use Symfony\Component\TypeInfo\TypeIdentifier; /** - * @psalm-import-type formOptionsDefaults from AutoType - * @psalm-import-type childOptions from AutoType + * @psalm-import-type FormOptionsDefaults from AutoType */ class AutoTypeBuilder { @@ -31,7 +30,7 @@ public function __construct( ) {} /** - * @param formOptionsDefaults $formOptions + * @param FormOptionsDefaults $formOptions */ public function buildChildren(FormBuilderInterface $builder, array $formOptions): void { @@ -200,11 +199,12 @@ private function getAssociationTargetClass(string $class, string $childName): st private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeInfo, int $formLevel): array { - if ($propTypeInfo->isSatisfiedBy(self::isTypeInfoWithMatchingNativeFormType(...))) { + // TypeInfo matching native FormType? Abort, guessers are enough + if (self::isTypeInfoWithMatchingNativeFormType($propTypeInfo)) { return $baseChildOptions; } - // Embeddable collection? + // Embeddable collection (object or builtin)? if ($propTypeInfo instanceof TypeInfo\CollectionType) { $baseCollOptions = [ 'child_type' => CollectionType::class, @@ -249,17 +249,18 @@ private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeI private static function isTypeInfoWithMatchingNativeFormType(TypeInfo $propTypeInfo): bool { - // Check matching some array array with high confidence FormType handling 'multiple' option + // Array? Some can match a native FormType with high confidence ('multiple' option) if ($propTypeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { return $propTypeInfo instanceof TypeInfo\GenericType && $propTypeInfo->getVariableTypes()[1]->isIdentifiedBy(\UnitEnum::class, \DateTimeZone::class); } + // Builtin? Native FormType will be ok. if (!$propTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) { return true; } - // Check matching some objects with high confidence FormType + // Some objects with high confidence FormType return $propTypeInfo->isIdentifiedBy( \UnitEnum::class, \DateTime::class, diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index a6983f9..ceafcc7 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -18,20 +18,20 @@ use Symfony\Component\OptionsResolver\OptionsResolver; /** - * @psalm-type childOptions = array{ + * @psalm-type ChildOptions = array{ * child_type?: class-string, * child_name?: string, * child_excluded?: bool, * child_embedded?: bool, * ... * } - * @psalm-type childBuilderCallable = callable(FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface - * @psalm-type formBuilderCallable = callable(FormBuilderInterface $builder, string[] $classProperties): void - * @psalm-type formOptionsDefaults = array{ - * children: array|[], + * @psalm-type ChildBuilderCallable = callable(FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface + * @psalm-type FormBuilderCallable = callable(FormBuilderInterface $builder, string[] $classProperties): void + * @psalm-type FormOptionsDefaults = array{ + * children: array|[], * children_excluded: list|"*", * children_embedded: list|"*", - * builder: formBuilderCallable|null, + * builder: FormBuilderCallable|null, * } */ class AutoType extends AbstractType diff --git a/tests/Fixtures/Dto/Media1.php b/tests/Fixtures/Dto/Media1.php index 0a28187..360d252 100644 --- a/tests/Fixtures/Dto/Media1.php +++ b/tests/Fixtures/Dto/Media1.php @@ -11,11 +11,16 @@ namespace A2lix\AutoFormBundle\Tests\Fixtures\Dto; +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; +use Symfony\Component\Form\Extension\Core\Type as FormType; + class Media1 { public function __construct( public readonly ?string $id = null, + #[AutoTypeCustom(options: ['help' => 'media.url_help'])] public readonly ?string $url = null, + #[AutoTypeCustom(type: FormType\TextareaType::class)] private ?string $description = null, ) {} diff --git a/tests/Fixtures/Entity/Media1.php b/tests/Fixtures/Entity/Media1.php index faa68de..98bfa82 100644 --- a/tests/Fixtures/Entity/Media1.php +++ b/tests/Fixtures/Entity/Media1.php @@ -11,7 +11,9 @@ namespace A2lix\AutoFormBundle\Tests\Fixtures\Entity; +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Form\Extension\Core\Type as FormType; #[ORM\Entity] class Media1 @@ -22,15 +24,19 @@ class Media1 public ?int $id = null; #[ORM\Column] - public \DateTimeImmutable $created; + #[AutoTypeCustom(excluded: true)] + public \DateTimeImmutable $createdAt; #[ORM\Column] + #[AutoTypeCustom(options: ['help' => 'media.url_help'])] public string $url; #[ORM\Column(nullable: true)] + #[AutoTypeCustom(type: FormType\TextareaType::class)] public ?string $description = null; #[ORM\ManyToOne(targetEntity: Product1::class, inversedBy: 'mediaColl')] #[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', nullable: false)] + #[AutoTypeCustom(excluded: true)] public Product1 $product; } diff --git a/tests/Fixtures/Entity/Product1.php b/tests/Fixtures/Entity/Product1.php index 3e7f045..59ae8f2 100644 --- a/tests/Fixtures/Entity/Product1.php +++ b/tests/Fixtures/Entity/Product1.php @@ -62,4 +62,16 @@ public function __construct() $this->mediaColl = new ArrayCollection(); $this->createdAt = new \DateTimeImmutable(); } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + + return $this; + } } diff --git a/tests/Form/Type/AutoTypeDtoTest.php b/tests/Form/Type/AutoTypeDtoTest.php index e9e153b..7457954 100755 --- a/tests/Form/Type/AutoTypeDtoTest.php +++ b/tests/Form/Type/AutoTypeDtoTest.php @@ -1,4 +1,6 @@ -factory - ->createBuilder(AutoType::class, $dtoScenario->dto, $dtoScenario->formOptions) - ->getForm() - ; - - self::assertSame(array_keys($dtoScenario->expectedForm), array_keys($form->all())); - foreach ($form->all() as $childName => $child) { - /** @var string $childName */ - /** @psalm-suppress PossiblyUndefinedArrayOffset */ - if (null !== $expectedType = $dtoScenario->expectedForm[$childName]['expected_type'] ?? null) { - self::assertSame($child->getConfig()->getType()->getInnerType()::class, $expectedType, \sprintf('Type of "%s"', $childName)); - } - - $expectedPartialOptions = $dtoScenario->expectedForm[$childName]; - unset($expectedPartialOptions['expected_type']); - $actualOptions = $child->getConfig()->getOptions(); + ->createBuilder(AutoType::class, $testScenario->obj, $testScenario->formOptions) + ->getForm(); - self::assertSame($expectedPartialOptions, array_intersect_key($actualOptions, $expectedPartialOptions), \sprintf('Options of "%s"', $childName)); - } + self::assertFormChildren($testScenario->expectedForm, $form->all()); } /** - * @return \Iterator> + * @return \Iterator> */ public static function provideScenarioCases(): iterable { yield 'Product1 with default behavior, no options' => [ - new DtoScenario( - dto: new Product1(), + new TestScenario( + obj: new Product1(), expectedForm: [ 'title' => [ 'expected_type' => FormType\TextType::class, @@ -91,8 +79,8 @@ public static function provideScenarioCases(): iterable ]; yield 'Product1 with children_embedded = *' => [ - new DtoScenario( - dto: new Product1(), + new TestScenario( + obj: new Product1(), formOptions: [ 'children_embedded' => '*', ], @@ -105,12 +93,30 @@ public static function provideScenarioCases(): iterable ], 'tags' => [ 'expected_type' => FormType\CollectionType::class, + 'entry_type' => "Symfony\Component\Form\Extension\Core\Type\TextType", + 'entry_options' => [ + "block_name" => "entry" + ], ], 'mediaMain' => [ - 'expected_type' => FormType\TextType::class, + 'expected_type' => AutoType::class, + 'expected_children' => [ + 'url' => [ + 'expected_type' => FormType\TextType::class, + 'help' => 'media.url_help', + ], + 'description' => [ + 'expected_type' => FormType\TextareaType::class, + ], + ], ], 'mediaColl' => [ 'expected_type' => FormType\CollectionType::class, + 'entry_type' => "A2lix\AutoFormBundle\Form\Type\AutoType", + 'entry_options' => [ + "data_class" => "A2lix\AutoFormBundle\Tests\Fixtures\Dto\Media1", + "block_name" => "entry" + ], ], 'status' => [ 'expected_type' => FormType\EnumType::class, @@ -129,8 +135,8 @@ public static function provideScenarioCases(): iterable ]; yield 'Product1 with children_embedded = [mediaColl]' => [ - new DtoScenario( - dto: new Product1(), + new TestScenario( + obj: new Product1(), formOptions: [ 'children_embedded' => ['mediaColl'], ], @@ -149,6 +155,10 @@ public static function provideScenarioCases(): iterable ], 'mediaColl' => [ 'expected_type' => FormType\CollectionType::class, + 'entry_options' => [ + "data_class" => "A2lix\AutoFormBundle\Tests\Fixtures\Dto\Media1", + "block_name" => "entry" + ], ], 'status' => [ 'expected_type' => FormType\EnumType::class, @@ -167,8 +177,8 @@ public static function provideScenarioCases(): iterable ]; yield 'Product1 with children_excluded = *' => [ - new DtoScenario( - dto: new Product1(), + new TestScenario( + obj: new Product1(), formOptions: [ 'children_excluded' => '*', ], @@ -176,15 +186,20 @@ public static function provideScenarioCases(): iterable ), ]; - yield 'Product1 with children_excluded = *, custom selection' => [ - new DtoScenario( - dto: new Product1(), + yield 'Product1 with children_excluded = *, custom selection with overrides' => [ + new TestScenario( + obj: new Product1(), formOptions: [ 'children_excluded' => '*', 'children' => [ 'title' => [], - 'code' => [], - 'description' => [], + 'code' => [ + 'label' => 'product.code_label', + 'required' => false, + ], + 'description' => [ + 'child_type' => FormType\TextareaType::class, + ], ], ], expectedForm: [ @@ -193,24 +208,14 @@ public static function provideScenarioCases(): iterable ], 'code' => [ 'expected_type' => FormType\IntegerType::class, + 'label' => 'product.code_label', + 'required' => false, ], 'description' => [ - 'expected_type' => FormType\TextType::class, + 'expected_type' => FormType\TextareaType::class, ], ], ), ]; } } - -class DtoScenario -{ - /** - * @param array $expectedForm - */ - public function __construct( - public readonly ?object $dto, - public readonly array $formOptions = [], - public readonly array $expectedForm = [], - ) {} -} diff --git a/tests/Form/Type/AutoTypeEntityTest.php b/tests/Form/Type/AutoTypeEntityTest.php index 146f525..dd9519c 100755 --- a/tests/Form/Type/AutoTypeEntityTest.php +++ b/tests/Form/Type/AutoTypeEntityTest.php @@ -1,4 +1,6 @@ -factory + ->createBuilder(AutoType::class, $testScenario->obj, $testScenario->formOptions) + ->getForm(); + + self::assertFormChildren($testScenario->expectedForm, $form->all()); + } + + /** + * @return \Iterator> + */ + public static function provideScenarioCases(): iterable + { + yield 'Product1 with default behavior, no options' => [ + new TestScenario( + obj: new Product1(), + expectedForm: [ + 'title' => [ + 'expected_type' => FormType\TextType::class, + ], + 'code' => [ + 'expected_type' => FormType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => FormType\TextType::class, + ], + 'mediaMain' => [ + 'expected_type' => FormType\TextType::class, + ], + 'mediaColl' => [ + 'expected_type' => FormType\TextType::class, + ], + 'status' => [ + 'expected_type' => FormType\EnumType::class, + ], + 'validityStartAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'validityEndAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'description' => [ + 'expected_type' => FormType\TextType::class, + ], + ], + ), + ]; + + yield 'Product1 with children_embedded = *' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_embedded' => '*', + ], + expectedForm: [ + 'title' => [ + 'expected_type' => FormType\TextType::class, + ], + 'code' => [ + 'expected_type' => FormType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => FormType\CollectionType::class, + 'entry_type' => "Symfony\Component\Form\Extension\Core\Type\TextType", + 'entry_options' => [ + "block_name" => "entry" + ], + ], + 'mediaMain' => [ + 'expected_type' => AutoType::class, + 'expected_children' => [ + 'url' => [ + 'expected_type' => FormType\TextType::class, + 'help' => 'media.url_help', + ], + 'description' => [ + 'expected_type' => FormType\TextareaType::class, + ], + ], + ], + 'mediaColl' => [ + 'expected_type' => FormType\CollectionType::class, + 'entry_type' => "A2lix\AutoFormBundle\Form\Type\AutoType", + 'entry_options' => [ + "data_class" => "A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media1", + "block_name" => "entry" + ], + ], + 'status' => [ + 'expected_type' => FormType\EnumType::class, + ], + 'validityStartAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'validityEndAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'description' => [ + 'expected_type' => FormType\TextType::class, + ], + ], + ), + ]; + + yield 'Product1 with children_embedded = [mediaColl]' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_embedded' => ['mediaColl'], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => FormType\TextType::class, + ], + 'code' => [ + 'expected_type' => FormType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => FormType\TextType::class, + ], + 'mediaMain' => [ + 'expected_type' => FormType\TextType::class, + ], + 'mediaColl' => [ + 'expected_type' => FormType\CollectionType::class, + 'entry_options' => [ + "data_class" => "A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media1", + "block_name" => "entry" + ], + ], + 'status' => [ + 'expected_type' => FormType\EnumType::class, + ], + 'validityStartAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'validityEndAt' => [ + 'expected_type' => FormType\DateTimeType::class, + ], + 'description' => [ + 'expected_type' => FormType\TextType::class, + ], + ], + ), + ]; + + yield 'Product1 with children_excluded = *' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => '*', + ], + expectedForm: [], + ), + ]; + + yield 'Product1 with children_excluded = *, custom selection with overrides' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => '*', + 'children' => [ + 'title' => [], + 'code' => [ + 'label' => 'product.code_label', + 'required' => false, + ], + 'description' => [ + 'child_type' => FormType\TextareaType::class, + ], + ], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => FormType\TextType::class, + ], + 'code' => [ + 'expected_type' => FormType\IntegerType::class, + 'label' => 'product.code_label', + 'required' => false, + ], + 'description' => [ + 'expected_type' => FormType\TextareaType::class, + ], + ], + ), + ]; + } } diff --git a/tests/Form/Type/TestScenario.php b/tests/Form/Type/TestScenario.php new file mode 100644 index 0000000..3d3bae7 --- /dev/null +++ b/tests/Form/Type/TestScenario.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 A2lix\AutoFormBundle\Tests\Form\Type; + +/** + * @psalm-type ExpectedChildren = array + */ +class TestScenario +{ + /** + * @param ExpectedChildren $expectedForm + */ + public function __construct( + public readonly ?object $obj, + public readonly array $formOptions = [], + public readonly array $expectedForm = [], + ) {} +} diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 83e8bda..701898e 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -14,6 +14,7 @@ use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; use A2lix\AutoFormBundle\Form\Type\AutoType; use A2lix\AutoFormBundle\Form\TypeGuesser\TypeInfoTypeGuesser; +use A2lix\AutoFormBundle\Tests\Form\Type\TestScenario; use Doctrine\DBAL\DriverManager; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; @@ -21,6 +22,7 @@ use Doctrine\Persistence\ManagerRegistry; use Symfony\Bridge\Doctrine\Form\DoctrineOrmTypeGuesser; use Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor; +use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormTypeGuesserChain; use Symfony\Component\Form\PreloadedExtension; use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait; @@ -34,6 +36,9 @@ use Symfony\Component\VarDumper\Dumper\HtmlDumper; use Symfony\Component\VarDumper\VarDumper; +/** + * @psalm-import-type ExpectedChildren from TestScenario + */ abstract class TypeTestCase extends BaseTypeTestCase { use ValidatorExtensionTrait; @@ -41,15 +46,14 @@ abstract class TypeTestCase extends BaseTypeTestCase private ?EntityManagerInterface $entityManager = null; #[\Override] - protected function setUp(): void + public static function setUpBeforeClass(): void { - parent::setUp(); - - // VarDumper::setHandler(function (mixed $var) { - // (new HtmlDumper())->dump( - // (new VarCloner())->cloneVar($var), __DIR__.'/../../dump.html' - // ); - // }); + VarDumper::setHandler(function (mixed $var) { + /** @psalm-suppress PossiblyInvalidArgument */ + (new HtmlDumper())->dump( + (new VarCloner())->cloneVar($var), @fopen(__DIR__.'/../../dump.html', 'a') + ); + }); } #[\Override] @@ -66,6 +70,37 @@ protected function getExtensions(): array ]; } + /** + * @param ExpectedChildren $expectedForm + * @param array $formChildren + */ + protected static function assertFormChildren(array $expectedForm, array $formChildren, string $parentPath = ''): void + { + self::assertSame(array_keys($expectedForm), array_keys($formChildren)); + + foreach ($formChildren as $childName => $child) { + /** @var string $childName */ + $expectedChildOptions = $expectedForm[$childName]; + $childPath = $parentPath. '.' . $childName; + + if (null !== $expectedType = $expectedChildOptions['expected_type'] ?? null) { + self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath)); + } + + /** @var ExpectedChildren|null $expectedChildOptions['expected_children'] */ + if (null !== $expectedChildren = $expectedChildOptions['expected_children'] ?? null) { + self::assertFormChildren($expectedChildren, $child->all(), $childPath); + } + + unset($expectedChildOptions['expected_type'], $expectedChildOptions['expected_children']); + $actualOptions = $child->getConfig()->getOptions(); + + /** @psalm-suppress RedundantCondition */ + /** @psalm-suppress TypeDoesNotContainNull */ + self::assertSame($expectedChildOptions, array_intersect_key($actualOptions, $expectedChildOptions ?? []), \sprintf('Options of "%s"', $childPath)); + } + } + private function getEntityManager(): EntityManagerInterface { if (null !== $this->entityManager) { From 0d14c2f816f0b5eb38b46a3e251eba4b11886515 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:51:53 +0000 Subject: [PATCH 09/35] Fixes --- tests/Fixtures/Dto/Media1.php | 2 ++ tests/Form/Type/AutoTypeDtoTest.php | 21 ++++++++++----------- tests/Form/Type/AutoTypeEntityTest.php | 21 ++++++++++----------- tests/Form/TypeTestCase.php | 9 +++++---- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/tests/Fixtures/Dto/Media1.php b/tests/Fixtures/Dto/Media1.php index 360d252..edf27fe 100644 --- a/tests/Fixtures/Dto/Media1.php +++ b/tests/Fixtures/Dto/Media1.php @@ -18,8 +18,10 @@ class Media1 { public function __construct( public readonly ?string $id = null, + #[AutoTypeCustom(options: ['help' => 'media.url_help'])] public readonly ?string $url = null, + #[AutoTypeCustom(type: FormType\TextareaType::class)] private ?string $description = null, ) {} diff --git a/tests/Form/Type/AutoTypeDtoTest.php b/tests/Form/Type/AutoTypeDtoTest.php index 7457954..3f0d432 100755 --- a/tests/Form/Type/AutoTypeDtoTest.php +++ b/tests/Form/Type/AutoTypeDtoTest.php @@ -1,6 +1,4 @@ -factory ->createBuilder(AutoType::class, $testScenario->obj, $testScenario->formOptions) - ->getForm(); + ->getForm() + ; self::assertFormChildren($testScenario->expectedForm, $form->all()); } @@ -93,9 +92,9 @@ public static function provideScenarioCases(): iterable ], 'tags' => [ 'expected_type' => FormType\CollectionType::class, - 'entry_type' => "Symfony\Component\Form\Extension\Core\Type\TextType", + 'entry_type' => 'Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType', 'entry_options' => [ - "block_name" => "entry" + 'block_name' => 'entry', ], ], 'mediaMain' => [ @@ -112,10 +111,10 @@ public static function provideScenarioCases(): iterable ], 'mediaColl' => [ 'expected_type' => FormType\CollectionType::class, - 'entry_type' => "A2lix\AutoFormBundle\Form\Type\AutoType", + 'entry_type' => 'A2lix\\AutoFormBundle\\Form\\Type\\AutoType', 'entry_options' => [ - "data_class" => "A2lix\AutoFormBundle\Tests\Fixtures\Dto\Media1", - "block_name" => "entry" + 'data_class' => 'A2lix\\AutoFormBundle\\Tests\\Fixtures\\Dto\\Media1', + 'block_name' => 'entry', ], ], 'status' => [ @@ -156,8 +155,8 @@ public static function provideScenarioCases(): iterable 'mediaColl' => [ 'expected_type' => FormType\CollectionType::class, 'entry_options' => [ - "data_class" => "A2lix\AutoFormBundle\Tests\Fixtures\Dto\Media1", - "block_name" => "entry" + 'data_class' => 'A2lix\\AutoFormBundle\\Tests\\Fixtures\\Dto\\Media1', + 'block_name' => 'entry', ], ], 'status' => [ diff --git a/tests/Form/Type/AutoTypeEntityTest.php b/tests/Form/Type/AutoTypeEntityTest.php index dd9519c..18d8a72 100755 --- a/tests/Form/Type/AutoTypeEntityTest.php +++ b/tests/Form/Type/AutoTypeEntityTest.php @@ -1,6 +1,4 @@ -factory ->createBuilder(AutoType::class, $testScenario->obj, $testScenario->formOptions) - ->getForm(); + ->getForm() + ; self::assertFormChildren($testScenario->expectedForm, $form->all()); } @@ -91,9 +90,9 @@ public static function provideScenarioCases(): iterable ], 'tags' => [ 'expected_type' => FormType\CollectionType::class, - 'entry_type' => "Symfony\Component\Form\Extension\Core\Type\TextType", + 'entry_type' => 'Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType', 'entry_options' => [ - "block_name" => "entry" + 'block_name' => 'entry', ], ], 'mediaMain' => [ @@ -110,10 +109,10 @@ public static function provideScenarioCases(): iterable ], 'mediaColl' => [ 'expected_type' => FormType\CollectionType::class, - 'entry_type' => "A2lix\AutoFormBundle\Form\Type\AutoType", + 'entry_type' => 'A2lix\\AutoFormBundle\\Form\\Type\\AutoType', 'entry_options' => [ - "data_class" => "A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media1", - "block_name" => "entry" + 'data_class' => 'A2lix\\AutoFormBundle\\Tests\\Fixtures\\Entity\\Media1', + 'block_name' => 'entry', ], ], 'status' => [ @@ -154,8 +153,8 @@ public static function provideScenarioCases(): iterable 'mediaColl' => [ 'expected_type' => FormType\CollectionType::class, 'entry_options' => [ - "data_class" => "A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media1", - "block_name" => "entry" + 'data_class' => 'A2lix\\AutoFormBundle\\Tests\\Fixtures\\Entity\\Media1', + 'block_name' => 'entry', ], ], 'status' => [ diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 701898e..0613182 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -48,10 +48,11 @@ abstract class TypeTestCase extends BaseTypeTestCase #[\Override] public static function setUpBeforeClass(): void { - VarDumper::setHandler(function (mixed $var) { + VarDumper::setHandler(static function (mixed $var): void { /** @psalm-suppress PossiblyInvalidArgument */ (new HtmlDumper())->dump( - (new VarCloner())->cloneVar($var), @fopen(__DIR__.'/../../dump.html', 'a') + (new VarCloner())->cloneVar($var), + @fopen(__DIR__.'/../../dump.html', 'a') ); }); } @@ -71,7 +72,7 @@ protected function getExtensions(): array } /** - * @param ExpectedChildren $expectedForm + * @param ExpectedChildren $expectedForm * @param array $formChildren */ protected static function assertFormChildren(array $expectedForm, array $formChildren, string $parentPath = ''): void @@ -81,7 +82,7 @@ protected static function assertFormChildren(array $expectedForm, array $formChi foreach ($formChildren as $childName => $child) { /** @var string $childName */ $expectedChildOptions = $expectedForm[$childName]; - $childPath = $parentPath. '.' . $childName; + $childPath = $parentPath.'.'.$childName; if (null !== $expectedType = $expectedChildOptions['expected_type'] ?? null) { self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath)); From 199d81a7b7dc014375219455ebd36be25d5e1633 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:37:42 +0000 Subject: [PATCH 10/35] Fixes --- README.md | 2 +- composer.json | 7 +- src/Form/Builder/AutoTypeBuilder.php | 20 +- tests/Fixtures/Dto/Media1.php | 8 +- tests/Fixtures/Dto/Product1.php | 10 +- tests/Fixtures/Entity/Media1.php | 8 +- tests/Fixtures/Entity/Product1.php | 16 +- tests/Fixtures/ProductStatus.php | 6 +- tests/Form/DataProviderDto.php | 305 +++++++++++++++++++++++++ tests/Form/DataProviderEntity.php | 304 ++++++++++++++++++++++++ tests/Form/{Type => }/TestScenario.php | 2 +- tests/Form/Type/AutoTypeDtoTest.php | 220 ------------------ tests/Form/Type/AutoTypeEntityTest.php | 218 ------------------ tests/Form/Type/AutoTypeTest.php | 74 ++++++ tests/Form/TypeTestCase.php | 36 --- 15 files changed, 737 insertions(+), 499 deletions(-) create mode 100644 tests/Form/DataProviderDto.php create mode 100644 tests/Form/DataProviderEntity.php rename tests/Form/{Type => }/TestScenario.php (93%) delete mode 100755 tests/Form/Type/AutoTypeDtoTest.php delete mode 100755 tests/Form/Type/AutoTypeEntityTest.php create mode 100755 tests/Form/Type/AutoTypeTest.php diff --git a/README.md b/README.md index b8d028d..3e33803 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ You can use a callable in the `children` option to create complex fields that ma ```php 'children' => [ - 'validity_range' => function (FormBuilderInterface $builder): FormBuilderInterface { + '_' => function (FormBuilderInterface $builder): FormBuilderInterface { return $builder ->create('validity_range', FormType::class, ['inherit_data' => true]) ->add('startsAt', DateType::class, [/* ... */]) diff --git a/composer.json b/composer.json index 1f0141b..9356fa3 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "a2lix/auto-form-bundle", "type": "symfony-bundle", "description": "Automate form building", - "keywords": ["symfony", "form", "field", "automate", "automation", "magic", "building"], + "keywords": ["symfony", "form", "field", "auto", "automate", "automation", "magic", "building"], "homepage": "https://github.com/a2lix/AutoFormBundle", "license": "MIT", "authors": [ @@ -19,19 +19,18 @@ "php": ">=8.2", "symfony/config": "^7.3", "symfony/dependency-injection": "^7.3", - "symfony/doctrine-bridge": "^7.3", "symfony/form": "^7.3", - "symfony/http-kernel": "^7.3", "symfony/property-info": "^7.3", "phpdocumentor/reflection-docblock": "^5.6" }, - "require-dev": { + "require-dev": { "doctrine/orm": "^3.5", "friendsofphp/php-cs-fixer": "^3.87", "kubawerlos/php-cs-fixer-custom-fixers": "^3.34", "phpunit/phpunit": "^12.3", "rector/rector": "^2.1", "symfony/cache": "^7.3", + "symfony/doctrine-bridge": "^7.3", "symfony/validator": "^7.3", "symfony/var-dumper": "^7.3", "vimeo/psalm": "^6.13" diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index b2a25b8..2e572b0 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -57,6 +57,10 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) $propAttributeOptions = ($refProperty->getAttributes(AutoTypeCustom::class)[0] ?? null) ?->newInstance()?->getOptions() ?? [] ; + // Custom name? + if (null !== ($propAttributeOptions['child_name'] ?? null)) { + $propAttributeOptions['property_path'] = $classProperty; + } // FORM.children[PROP] callable? Add early if (\is_callable($propFormOptions)) { @@ -75,6 +79,7 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) } if (null === $propFormOptions) { + /** @psalm-suppress RiskyTruthyFalsyComparison */ /** @var list $formOptions['children_excluded'] */ $formChildExcluded = $allChildrenExcluded || \in_array($classProperty, $formOptions['children_excluded'], true) || ($propAttributeOptions['child_excluded'] ?? false); @@ -87,8 +92,8 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) } $childOptions = [ - ...($propFormOptions ?? []), ...$propAttributeOptions, + ...($propFormOptions ?? []), ]; // PropertyInfo? Enrich childOptions @@ -249,18 +254,19 @@ private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeI private static function isTypeInfoWithMatchingNativeFormType(TypeInfo $propTypeInfo): bool { - // Array? Some can match a native FormType with high confidence ('multiple' option) - if ($propTypeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { - return $propTypeInfo instanceof TypeInfo\GenericType - && $propTypeInfo->getVariableTypes()[1]->isIdentifiedBy(\UnitEnum::class, \DateTimeZone::class); + // Array? Some native FormTypes with high confidence ('multiple' option) can match + if ($propTypeInfo instanceof TypeInfo\CollectionType) { + $collValueType = $propTypeInfo->getCollectionValueType(); + + return $collValueType->isIdentifiedBy(\UnitEnum::class, \DateTimeZone::class); } - // Builtin? Native FormType will be ok. + // Builtin? Native FormType should fine if (!$propTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) { return true; } - // Some objects with high confidence FormType + // Otherwise, some native FormTypes with high confidence can match return $propTypeInfo->isIdentifiedBy( \UnitEnum::class, \DateTime::class, diff --git a/tests/Fixtures/Dto/Media1.php b/tests/Fixtures/Dto/Media1.php index edf27fe..d7f0ded 100644 --- a/tests/Fixtures/Dto/Media1.php +++ b/tests/Fixtures/Dto/Media1.php @@ -1,4 +1,6 @@ - 'media.url_help'])] public readonly ?string $url = null, - #[AutoTypeCustom(type: FormType\TextareaType::class)] + #[AutoTypeCustom(type: CoreType\TextareaType::class)] private ?string $description = null, ) {} diff --git a/tests/Fixtures/Dto/Product1.php b/tests/Fixtures/Dto/Product1.php index 015cfbd..f1ccda4 100644 --- a/tests/Fixtures/Dto/Product1.php +++ b/tests/Fixtures/Dto/Product1.php @@ -1,4 +1,6 @@ - $tags * @param Collection $mediaColl + * @param list $statusList */ public function __construct( + #[AutoTypeCustom(excluded: true)] public readonly ?string $id = null, public readonly ?string $title = null, + #[AutoTypeCustom(type: CoreType\TextareaType::class, name: 'desc', options: ['attr' => ['rows' => 2]])] private ?string $description = null, public readonly ?int $code = null, public readonly array $tags = [], public readonly ?Media1 $mediaMain = null, public ?Collection $mediaColl = null, public readonly ?ProductStatus $status = null, + public readonly ?array $statusList = null, public readonly ?\DateTimeImmutable $validityStartAt = null, public readonly ?\DateTimeImmutable $validityEndAt = null, private ?\DateTimeImmutable $createdAt = null, diff --git a/tests/Fixtures/Entity/Media1.php b/tests/Fixtures/Entity/Media1.php index 98bfa82..b316dbf 100644 --- a/tests/Fixtures/Entity/Media1.php +++ b/tests/Fixtures/Entity/Media1.php @@ -1,4 +1,6 @@ - ['rows' => 2]])] private ?string $description = null; #[ORM\Column] @@ -45,9 +51,15 @@ class Product1 #[ORM\OneToMany(targetEntity: Media1::class, mappedBy: 'product', cascade: ['all'], orphanRemoval: true)] public Collection $mediaColl; - #[ORM\Column] + #[ORM\Column(enumType: ProductStatus::class)] public ProductStatus $status; + /** + * @var list + */ + #[ORM\Column(type: 'simple_array', enumType: ProductStatus::class)] + public array $statusList; + #[ORM\Column] public \DateTimeImmutable $validityStartAt; diff --git a/tests/Fixtures/ProductStatus.php b/tests/Fixtures/ProductStatus.php index 75fcda1..ea1c1d1 100644 --- a/tests/Fixtures/ProductStatus.php +++ b/tests/Fixtures/ProductStatus.php @@ -11,8 +11,8 @@ namespace A2lix\AutoFormBundle\Tests\Fixtures; -enum ProductStatus +enum ProductStatus: string { - case Available; - case Unavailable; + case Available = 'available'; + case Unavailable = 'unavailable'; } diff --git a/tests/Form/DataProviderDto.php b/tests/Form/DataProviderDto.php new file mode 100644 index 0000000..76a8a0d --- /dev/null +++ b/tests/Form/DataProviderDto.php @@ -0,0 +1,305 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\AutoFormBundle\Tests\Form; + +use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\AutoFormBundle\Tests\Fixtures\Dto\Media1; +use A2lix\AutoFormBundle\Tests\Fixtures\Dto\Product1; +use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus; +use Symfony\Component\Form\Extension\Core\Type as CoreType; +use Symfony\Component\Form\FormBuilderInterface; + +class DataProviderDto +{ + /** + * @return \Iterator> + */ + public static function provideScenarioCases(): iterable + { + yield 'Dto - Product1 with default behavior, no options' => [ + new TestScenario( + obj: new Product1(), + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'mediaMain' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'mediaColl' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'status' => [ + 'expected_type' => CoreType\EnumType::class, + 'class' => ProductStatus::class + ], + 'statusList' => [ + 'expected_type' => CoreType\EnumType::class, + 'multiple' => true, + 'class' => ProductStatus::class, + ], + 'validityStartAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'desc' => [ + 'expected_type' => CoreType\TextareaType::class, + 'attr' => [ + 'rows' => 2, + ], + 'property_path' => 'description', + ], + ], + ), + ]; + + yield 'Dto - Product1 with children_embedded = *' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_embedded' => '*', + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => CoreType\CollectionType::class, + 'entry_type' => CoreType\TextType::class, + 'entry_options' => [ + 'block_name' => 'entry', + ], + ], + 'mediaMain' => [ + 'expected_type' => AutoType::class, + 'expected_children' => [ + 'url' => [ + 'expected_type' => CoreType\TextType::class, + 'help' => 'media.url_help', + ], + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + ], + ], + ], + 'mediaColl' => [ + 'expected_type' => CoreType\CollectionType::class, + 'entry_type' => AutoType::class, + 'entry_options' => [ + 'data_class' => Media1::class, + 'block_name' => 'entry', + ], + ], + 'status' => [ + 'expected_type' => CoreType\EnumType::class, + 'class' => ProductStatus::class + ], + 'statusList' => [ + 'expected_type' => CoreType\EnumType::class, + 'multiple' => true, + 'class' => ProductStatus::class, + ], + 'validityStartAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'desc' => [ + 'expected_type' => CoreType\TextareaType::class, + 'attr' => [ + 'rows' => 2, + ], + 'property_path' => 'description', + ], + ], + ), + ]; + + yield 'Dto - Product1 with children_embedded = [mediaColl]' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_embedded' => ['mediaColl'], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'mediaMain' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'mediaColl' => [ + 'expected_type' => CoreType\CollectionType::class, + 'entry_options' => [ + 'data_class' => Media1::class, + 'block_name' => 'entry', + ], + ], + 'status' => [ + 'expected_type' => CoreType\EnumType::class, + 'class' => ProductStatus::class + ], + 'statusList' => [ + 'expected_type' => CoreType\EnumType::class, + 'multiple' => true, + 'class' => ProductStatus::class, + ], + 'validityStartAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'desc' => [ + 'expected_type' => CoreType\TextareaType::class, + 'attr' => [ + 'rows' => 2, + ], + 'property_path' => 'description', + ], + ], + ), + ]; + + yield 'Dto - Product1 with children_excluded = *' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => '*', + ], + expectedForm: [], + ), + ]; + + yield 'Dto - Product1 with children_excluded = *, custom selection with overrides' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => '*', + 'children' => [ + 'title' => [], + 'code' => [ + 'label' => 'product.code_label', + 'required' => false, + ], + 'description' => [ + 'attr' => [ + 'rows' => 4, + ], + ], + ], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + 'label' => 'product.code_label', + 'required' => false, + ], + 'desc' => [ + 'expected_type' => CoreType\TextareaType::class, + 'attr' => [ + 'rows' => 4, + ], + 'property_path' => 'description', + ], + ], + ), + ]; + + yield 'Dto - Product1 with children & builder callables' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => '*', + 'children' => [ + 'description' => function (FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface { + return $builder->create('description', CoreType\TextareaType::class, [ + 'attr' => $propAttributeOptions['attr'], + 'label' => 'product.description_label', + ]); + }, + '_ignoredNaming_' => function (FormBuilderInterface $builder): FormBuilderInterface { + return $builder + ->create('validity_range', CoreType\FormType::class, ['inherit_data' => true]) + ->add('validityStartAt', CoreType\DateType::class) + ->add('validityEndAt', CoreType\DateType::class); + }, + 'agreement' => [ + 'child_type' => CoreType\CheckboxType::class, + 'mapped' => false, + ], + ], + /** @psalm-suppress UnusedClosureParam */ + 'builder' => function(FormBuilderInterface $builder, array $classProperties): void { + $builder->add('save', CoreType\SubmitType::class); + }, + ], + expectedForm: [ + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + 'label' => 'product.description_label', + 'attr' => [ + 'rows' => 2, + ], + ], + 'validity_range' => [ + 'expected_type' => CoreType\FormType::class, + 'expected_children' => [ + 'validityStartAt' => [ + 'expected_type' => CoreType\DateType::class, + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateType::class, + ], + ], + ], + 'agreement' => [ + 'expected_type' => CoreType\CheckboxType::class, + 'mapped' => false, + ], + 'save' => [ + 'expected_type' => CoreType\SubmitType::class, + ], + ], + ), + ]; + } +} diff --git a/tests/Form/DataProviderEntity.php b/tests/Form/DataProviderEntity.php new file mode 100644 index 0000000..48dbfbc --- /dev/null +++ b/tests/Form/DataProviderEntity.php @@ -0,0 +1,304 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\AutoFormBundle\Tests\Form; + +use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media1; +use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product1; +use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus; +use Symfony\Component\Form\Extension\Core\Type as CoreType; +use Symfony\Component\Form\FormBuilderInterface; + +class DataProviderEntity +{ + /** + * @return \Iterator> + */ + public static function provideScenarioCases(): iterable + { + yield 'Entity - Product1 with default behavior, no options' => [ + new TestScenario( + obj: new Product1(), + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'mediaMain' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'mediaColl' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'status' => [ + 'expected_type' => CoreType\EnumType::class, + 'class' => ProductStatus::class + ], + 'statusList' => [ + 'expected_type' => CoreType\EnumType::class, + 'multiple' => true, + 'class' => ProductStatus::class, + ], + 'validityStartAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'desc' => [ + 'expected_type' => CoreType\TextareaType::class, + 'attr' => [ + 'rows' => 2, + ], + 'property_path' => 'description', + ], + ], + ), + ]; + + yield 'Entity - Product1 with children_embedded = *' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_embedded' => '*', + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => CoreType\CollectionType::class, + 'entry_type' => CoreType\TextType::class, + 'entry_options' => [ + 'block_name' => 'entry', + ], + ], + 'mediaMain' => [ + 'expected_type' => AutoType::class, + 'expected_children' => [ + 'url' => [ + 'expected_type' => CoreType\TextType::class, + 'help' => 'media.url_help', + ], + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + ], + ], + ], + 'mediaColl' => [ + 'expected_type' => CoreType\CollectionType::class, + 'entry_type' => AutoType::class, + 'entry_options' => [ + 'data_class' => Media1::class, + 'block_name' => 'entry', + ], + ], + 'status' => [ + 'expected_type' => CoreType\EnumType::class, + 'class' => ProductStatus::class + ], + 'statusList' => [ + 'expected_type' => CoreType\EnumType::class, + 'multiple' => true, + 'class' => ProductStatus::class, + ], + 'validityStartAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'desc' => [ + 'expected_type' => CoreType\TextareaType::class, + 'attr' => [ + 'rows' => 2, + ], + 'property_path' => 'description', + ], + ], + ), + ]; + + yield 'Entity - Product1 with children_embedded = [mediaColl]' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_embedded' => ['mediaColl'], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + ], + 'tags' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'mediaMain' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'mediaColl' => [ + 'expected_type' => CoreType\CollectionType::class, + 'entry_options' => [ + 'data_class' => Media1::class, + 'block_name' => 'entry', + ], + ], + 'status' => [ + 'expected_type' => CoreType\EnumType::class, + ], + 'statusList' => [ + 'expected_type' => CoreType\EnumType::class, + 'multiple' => true, + 'class' => ProductStatus::class, + ], + 'validityStartAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'desc' => [ + 'expected_type' => CoreType\TextareaType::class, + 'attr' => [ + 'rows' => 2, + ], + 'property_path' => 'description', + ], + ], + ), + ]; + + yield 'Entity - Product1 with children_excluded = *' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => '*', + ], + expectedForm: [], + ), + ]; + + yield 'Entity - Product1 with children_excluded = *, custom selection with overrides' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => '*', + 'children' => [ + 'title' => [], + 'code' => [ + 'label' => 'product.code_label', + 'required' => false, + ], + 'description' => [ + 'attr' => [ + 'rows' => 4, + ], + ], + ], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + 'label' => 'product.code_label', + 'required' => false, + ], + 'desc' => [ + 'expected_type' => CoreType\TextareaType::class, + 'attr' => [ + 'rows' => 4, + ], + 'property_path' => 'description', + ], + ], + ), + ]; + + yield 'Entity - Product1 with children & builder callables' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => '*', + 'children' => [ + 'description' => function (FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface { + return $builder->create('description', CoreType\TextareaType::class, [ + 'attr' => $propAttributeOptions['attr'], + 'label' => 'product.description_label', + ]); + }, + '_ignoredNaming_' => function (FormBuilderInterface $builder): FormBuilderInterface { + return $builder + ->create('validity_range', CoreType\FormType::class, ['inherit_data' => true]) + ->add('validityStartAt', CoreType\DateType::class) + ->add('validityEndAt', CoreType\DateType::class); + }, + 'agreement' => [ + 'child_type' => CoreType\CheckboxType::class, + 'mapped' => false, + ], + ], + /** @psalm-suppress UnusedClosureParam */ + 'builder' => function(FormBuilderInterface $builder, array $classProperties): void { + $builder->add('save', CoreType\SubmitType::class); + }, + ], + expectedForm: [ + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + 'label' => 'product.description_label', + 'attr' => [ + 'rows' => 2, + ], + ], + 'validity_range' => [ + 'expected_type' => CoreType\FormType::class, + 'expected_children' => [ + 'validityStartAt' => [ + 'expected_type' => CoreType\DateType::class, + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateType::class, + ], + ], + ], + 'agreement' => [ + 'expected_type' => CoreType\CheckboxType::class, + 'mapped' => false, + ], + 'save' => [ + 'expected_type' => CoreType\SubmitType::class, + ], + ], + ), + ]; + } +} diff --git a/tests/Form/Type/TestScenario.php b/tests/Form/TestScenario.php similarity index 93% rename from tests/Form/Type/TestScenario.php rename to tests/Form/TestScenario.php index 3d3bae7..d7971fa 100644 --- a/tests/Form/Type/TestScenario.php +++ b/tests/Form/TestScenario.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace A2lix\AutoFormBundle\Tests\Form\Type; +namespace A2lix\AutoFormBundle\Tests\Form; /** * @psalm-type ExpectedChildren = array - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Tests\Form\Type; - -use A2lix\AutoFormBundle\Form\Type\AutoType; -use A2lix\AutoFormBundle\Tests\Fixtures\Dto\Product1; -use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; -use PHPUnit\Framework\Attributes\CoversNothing; -use PHPUnit\Framework\Attributes\DataProvider; -use Symfony\Component\Form\Extension\Core\Type as FormType; - -/** - * @internal - * - * @psalm-suppress PropertyNotSetInConstructor - */ -#[CoversNothing] -final class AutoTypeDtoTest extends TypeTestCase -{ - #[DataProvider('provideScenarioCases')] - public function testScenario(TestScenario $testScenario): void - { - $form = $this->factory - ->createBuilder(AutoType::class, $testScenario->obj, $testScenario->formOptions) - ->getForm() - ; - - self::assertFormChildren($testScenario->expectedForm, $form->all()); - } - - /** - * @return \Iterator> - */ - public static function provideScenarioCases(): iterable - { - yield 'Product1 with default behavior, no options' => [ - new TestScenario( - obj: new Product1(), - expectedForm: [ - 'title' => [ - 'expected_type' => FormType\TextType::class, - ], - 'code' => [ - 'expected_type' => FormType\IntegerType::class, - ], - 'tags' => [ - 'expected_type' => FormType\TextType::class, - ], - 'mediaMain' => [ - 'expected_type' => FormType\TextType::class, - ], - 'mediaColl' => [ - 'expected_type' => FormType\TextType::class, - ], - 'status' => [ - 'expected_type' => FormType\EnumType::class, - ], - 'validityStartAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'validityEndAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'description' => [ - 'expected_type' => FormType\TextType::class, - ], - ], - ), - ]; - - yield 'Product1 with children_embedded = *' => [ - new TestScenario( - obj: new Product1(), - formOptions: [ - 'children_embedded' => '*', - ], - expectedForm: [ - 'title' => [ - 'expected_type' => FormType\TextType::class, - ], - 'code' => [ - 'expected_type' => FormType\IntegerType::class, - ], - 'tags' => [ - 'expected_type' => FormType\CollectionType::class, - 'entry_type' => 'Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType', - 'entry_options' => [ - 'block_name' => 'entry', - ], - ], - 'mediaMain' => [ - 'expected_type' => AutoType::class, - 'expected_children' => [ - 'url' => [ - 'expected_type' => FormType\TextType::class, - 'help' => 'media.url_help', - ], - 'description' => [ - 'expected_type' => FormType\TextareaType::class, - ], - ], - ], - 'mediaColl' => [ - 'expected_type' => FormType\CollectionType::class, - 'entry_type' => 'A2lix\\AutoFormBundle\\Form\\Type\\AutoType', - 'entry_options' => [ - 'data_class' => 'A2lix\\AutoFormBundle\\Tests\\Fixtures\\Dto\\Media1', - 'block_name' => 'entry', - ], - ], - 'status' => [ - 'expected_type' => FormType\EnumType::class, - ], - 'validityStartAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'validityEndAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'description' => [ - 'expected_type' => FormType\TextType::class, - ], - ], - ), - ]; - - yield 'Product1 with children_embedded = [mediaColl]' => [ - new TestScenario( - obj: new Product1(), - formOptions: [ - 'children_embedded' => ['mediaColl'], - ], - expectedForm: [ - 'title' => [ - 'expected_type' => FormType\TextType::class, - ], - 'code' => [ - 'expected_type' => FormType\IntegerType::class, - ], - 'tags' => [ - 'expected_type' => FormType\TextType::class, - ], - 'mediaMain' => [ - 'expected_type' => FormType\TextType::class, - ], - 'mediaColl' => [ - 'expected_type' => FormType\CollectionType::class, - 'entry_options' => [ - 'data_class' => 'A2lix\\AutoFormBundle\\Tests\\Fixtures\\Dto\\Media1', - 'block_name' => 'entry', - ], - ], - 'status' => [ - 'expected_type' => FormType\EnumType::class, - ], - 'validityStartAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'validityEndAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'description' => [ - 'expected_type' => FormType\TextType::class, - ], - ], - ), - ]; - - yield 'Product1 with children_excluded = *' => [ - new TestScenario( - obj: new Product1(), - formOptions: [ - 'children_excluded' => '*', - ], - expectedForm: [], - ), - ]; - - yield 'Product1 with children_excluded = *, custom selection with overrides' => [ - new TestScenario( - obj: new Product1(), - formOptions: [ - 'children_excluded' => '*', - 'children' => [ - 'title' => [], - 'code' => [ - 'label' => 'product.code_label', - 'required' => false, - ], - 'description' => [ - 'child_type' => FormType\TextareaType::class, - ], - ], - ], - expectedForm: [ - 'title' => [ - 'expected_type' => FormType\TextType::class, - ], - 'code' => [ - 'expected_type' => FormType\IntegerType::class, - 'label' => 'product.code_label', - 'required' => false, - ], - 'description' => [ - 'expected_type' => FormType\TextareaType::class, - ], - ], - ), - ]; - } -} diff --git a/tests/Form/Type/AutoTypeEntityTest.php b/tests/Form/Type/AutoTypeEntityTest.php deleted file mode 100755 index 18d8a72..0000000 --- a/tests/Form/Type/AutoTypeEntityTest.php +++ /dev/null @@ -1,218 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\AutoFormBundle\Tests\Form\Type; - -use A2lix\AutoFormBundle\Form\Type\AutoType; -use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product1; -use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; -use PHPUnit\Framework\Attributes\CoversNothing; -use PHPUnit\Framework\Attributes\DataProvider; -use Symfony\Component\Form\Extension\Core\Type as FormType; - -/** - * @internal - */ -#[CoversNothing] -final class AutoTypeEntityTest extends TypeTestCase -{ - #[DataProvider('provideScenarioCases')] - public function testScenario(TestScenario $testScenario): void - { - $form = $this->factory - ->createBuilder(AutoType::class, $testScenario->obj, $testScenario->formOptions) - ->getForm() - ; - - self::assertFormChildren($testScenario->expectedForm, $form->all()); - } - - /** - * @return \Iterator> - */ - public static function provideScenarioCases(): iterable - { - yield 'Product1 with default behavior, no options' => [ - new TestScenario( - obj: new Product1(), - expectedForm: [ - 'title' => [ - 'expected_type' => FormType\TextType::class, - ], - 'code' => [ - 'expected_type' => FormType\IntegerType::class, - ], - 'tags' => [ - 'expected_type' => FormType\TextType::class, - ], - 'mediaMain' => [ - 'expected_type' => FormType\TextType::class, - ], - 'mediaColl' => [ - 'expected_type' => FormType\TextType::class, - ], - 'status' => [ - 'expected_type' => FormType\EnumType::class, - ], - 'validityStartAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'validityEndAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'description' => [ - 'expected_type' => FormType\TextType::class, - ], - ], - ), - ]; - - yield 'Product1 with children_embedded = *' => [ - new TestScenario( - obj: new Product1(), - formOptions: [ - 'children_embedded' => '*', - ], - expectedForm: [ - 'title' => [ - 'expected_type' => FormType\TextType::class, - ], - 'code' => [ - 'expected_type' => FormType\IntegerType::class, - ], - 'tags' => [ - 'expected_type' => FormType\CollectionType::class, - 'entry_type' => 'Symfony\\Component\\Form\\Extension\\Core\\Type\\TextType', - 'entry_options' => [ - 'block_name' => 'entry', - ], - ], - 'mediaMain' => [ - 'expected_type' => AutoType::class, - 'expected_children' => [ - 'url' => [ - 'expected_type' => FormType\TextType::class, - 'help' => 'media.url_help', - ], - 'description' => [ - 'expected_type' => FormType\TextareaType::class, - ], - ], - ], - 'mediaColl' => [ - 'expected_type' => FormType\CollectionType::class, - 'entry_type' => 'A2lix\\AutoFormBundle\\Form\\Type\\AutoType', - 'entry_options' => [ - 'data_class' => 'A2lix\\AutoFormBundle\\Tests\\Fixtures\\Entity\\Media1', - 'block_name' => 'entry', - ], - ], - 'status' => [ - 'expected_type' => FormType\EnumType::class, - ], - 'validityStartAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'validityEndAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'description' => [ - 'expected_type' => FormType\TextType::class, - ], - ], - ), - ]; - - yield 'Product1 with children_embedded = [mediaColl]' => [ - new TestScenario( - obj: new Product1(), - formOptions: [ - 'children_embedded' => ['mediaColl'], - ], - expectedForm: [ - 'title' => [ - 'expected_type' => FormType\TextType::class, - ], - 'code' => [ - 'expected_type' => FormType\IntegerType::class, - ], - 'tags' => [ - 'expected_type' => FormType\TextType::class, - ], - 'mediaMain' => [ - 'expected_type' => FormType\TextType::class, - ], - 'mediaColl' => [ - 'expected_type' => FormType\CollectionType::class, - 'entry_options' => [ - 'data_class' => 'A2lix\\AutoFormBundle\\Tests\\Fixtures\\Entity\\Media1', - 'block_name' => 'entry', - ], - ], - 'status' => [ - 'expected_type' => FormType\EnumType::class, - ], - 'validityStartAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'validityEndAt' => [ - 'expected_type' => FormType\DateTimeType::class, - ], - 'description' => [ - 'expected_type' => FormType\TextType::class, - ], - ], - ), - ]; - - yield 'Product1 with children_excluded = *' => [ - new TestScenario( - obj: new Product1(), - formOptions: [ - 'children_excluded' => '*', - ], - expectedForm: [], - ), - ]; - - yield 'Product1 with children_excluded = *, custom selection with overrides' => [ - new TestScenario( - obj: new Product1(), - formOptions: [ - 'children_excluded' => '*', - 'children' => [ - 'title' => [], - 'code' => [ - 'label' => 'product.code_label', - 'required' => false, - ], - 'description' => [ - 'child_type' => FormType\TextareaType::class, - ], - ], - ], - expectedForm: [ - 'title' => [ - 'expected_type' => FormType\TextType::class, - ], - 'code' => [ - 'expected_type' => FormType\IntegerType::class, - 'label' => 'product.code_label', - 'required' => false, - ], - 'description' => [ - 'expected_type' => FormType\TextareaType::class, - ], - ], - ), - ]; - } -} diff --git a/tests/Form/Type/AutoTypeTest.php b/tests/Form/Type/AutoTypeTest.php new file mode 100755 index 0000000..0ef6b17 --- /dev/null +++ b/tests/Form/Type/AutoTypeTest.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 A2lix\AutoFormBundle\Tests\Form\Type; + +use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\AutoFormBundle\Tests\Form\DataProviderDto; +use A2lix\AutoFormBundle\Tests\Form\DataProviderEntity; +use A2lix\AutoFormBundle\Tests\Form\TestScenario; +use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProviderExternal; +use Symfony\Component\Form\FormInterface; + +/** + * @internal + * + * @psalm-suppress PropertyNotSetInConstructor + * @psalm-import-type ExpectedChildren from TestScenario + */ +#[CoversNothing] +final class AutoTypeTest extends TypeTestCase +{ + #[DataProviderExternal(DataProviderDto::class, 'provideScenarioCases')] + #[DataProviderExternal(DataProviderEntity::class, 'provideScenarioCases')] + public function testScenario(TestScenario $testScenario): void + { + $form = $this->factory + ->createBuilder(AutoType::class, $testScenario->obj, $testScenario->formOptions) + ->getForm() + ; + + self::assertFormChildren($testScenario->expectedForm, $form->all()); + } + + /** + * @param ExpectedChildren $expectedForm + * @param array $formChildren + */ + private static function assertFormChildren(array $expectedForm, array $formChildren, string $parentPath = ''): void + { + self::assertSame(array_keys($expectedForm), array_keys($formChildren)); + + foreach ($formChildren as $childName => $child) { + /** @var string $childName */ + $expectedChildOptions = $expectedForm[$childName]; + $childPath = $parentPath.'.'.$childName; + + if (null !== $expectedType = $expectedChildOptions['expected_type'] ?? null) { + self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath)); + } + + /** @var ExpectedChildren|null $expectedChildOptions['expected_children'] */ + if (null !== $expectedChildren = $expectedChildOptions['expected_children'] ?? null) { + self::assertFormChildren($expectedChildren, $child->all(), $childPath); + } + + unset($expectedChildOptions['expected_type'], $expectedChildOptions['expected_children']); + $actualOptions = $child->getConfig()->getOptions(); + + /** @psalm-suppress RedundantCondition */ + /** @psalm-suppress TypeDoesNotContainNull */ + self::assertSame($expectedChildOptions, array_intersect_key($actualOptions, $expectedChildOptions ?? []), \sprintf('Options of "%s"', $childPath)); + } + } +} diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 0613182..6ea5832 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -14,7 +14,6 @@ use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; use A2lix\AutoFormBundle\Form\Type\AutoType; use A2lix\AutoFormBundle\Form\TypeGuesser\TypeInfoTypeGuesser; -use A2lix\AutoFormBundle\Tests\Form\Type\TestScenario; use Doctrine\DBAL\DriverManager; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; @@ -22,7 +21,6 @@ use Doctrine\Persistence\ManagerRegistry; use Symfony\Bridge\Doctrine\Form\DoctrineOrmTypeGuesser; use Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor; -use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormTypeGuesserChain; use Symfony\Component\Form\PreloadedExtension; use Symfony\Component\Form\Test\Traits\ValidatorExtensionTrait; @@ -36,9 +34,6 @@ use Symfony\Component\VarDumper\Dumper\HtmlDumper; use Symfony\Component\VarDumper\VarDumper; -/** - * @psalm-import-type ExpectedChildren from TestScenario - */ abstract class TypeTestCase extends BaseTypeTestCase { use ValidatorExtensionTrait; @@ -71,37 +66,6 @@ protected function getExtensions(): array ]; } - /** - * @param ExpectedChildren $expectedForm - * @param array $formChildren - */ - protected static function assertFormChildren(array $expectedForm, array $formChildren, string $parentPath = ''): void - { - self::assertSame(array_keys($expectedForm), array_keys($formChildren)); - - foreach ($formChildren as $childName => $child) { - /** @var string $childName */ - $expectedChildOptions = $expectedForm[$childName]; - $childPath = $parentPath.'.'.$childName; - - if (null !== $expectedType = $expectedChildOptions['expected_type'] ?? null) { - self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath)); - } - - /** @var ExpectedChildren|null $expectedChildOptions['expected_children'] */ - if (null !== $expectedChildren = $expectedChildOptions['expected_children'] ?? null) { - self::assertFormChildren($expectedChildren, $child->all(), $childPath); - } - - unset($expectedChildOptions['expected_type'], $expectedChildOptions['expected_children']); - $actualOptions = $child->getConfig()->getOptions(); - - /** @psalm-suppress RedundantCondition */ - /** @psalm-suppress TypeDoesNotContainNull */ - self::assertSame($expectedChildOptions, array_intersect_key($actualOptions, $expectedChildOptions ?? []), \sprintf('Options of "%s"', $childPath)); - } - } - private function getEntityManager(): EntityManagerInterface { if (null !== $this->entityManager) { From f4805fe351ec32151308c97a78707c7f80a393df Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 14 Oct 2025 11:11:00 +0000 Subject: [PATCH 11/35] Fixes --- .php-cs-fixer.dist.php | 2 +- UPGRADE.md | 138 +++++++++++++++++++++++++++++ tests/Fixtures/Dto/Media1.php | 6 +- tests/Fixtures/Dto/Product1.php | 4 +- tests/Fixtures/Entity/Media1.php | 4 +- tests/Fixtures/Entity/Product1.php | 4 +- tests/Form/DataProviderDto.php | 32 +++---- tests/Form/DataProviderEntity.php | 30 +++---- tests/Form/Type/AutoTypeTest.php | 1 + 9 files changed, 169 insertions(+), 52 deletions(-) create mode 100644 UPGRADE.md diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index ab67f98..5b80b3c 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -77,7 +77,7 @@ PhpCsFixerCustomFixers\Fixer\PhpdocSelfAccessorFixer::name() => true, PhpCsFixerCustomFixers\Fixer\PhpdocTypesCommaSpacesFixer::name() => true, PhpCsFixerCustomFixers\Fixer\PhpdocTypesTrimFixer::name() => true, - PhpCsFixerCustomFixers\Fixer\FunctionParameterSeparationFixer::name() => true, + // PhpCsFixerCustomFixers\Fixer\FunctionParameterSeparationFixer::name() => true, PhpCsFixerCustomFixers\Fixer\PhpdocPropertySortedFixer::name() => true, PhpCsFixerCustomFixers\Fixer\PromotedConstructorPropertyFixer::name() => true, PhpCsFixerCustomFixers\Fixer\ReadonlyPromotedPropertiesFixer::name() => true, diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..e85f657 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,138 @@ +# Upgrade from 0.x to 1.x + +Version 1.x is a complete rewrite of the bundle. It is not backward compatible. +The bundle is no longer tied to Doctrine and now uses Symfony's PropertyInfo component to guess form types from any PHP object. + +## BC BREAK: Minimum Requirements + +- **PHP:** `8.2` or higher is required. +- **Symfony:** `7.3` or higher is required. + +## BC BREAK: Composer Dependencies + +1. **Update your `composer.json`** to require the new version: + ```json + { + "require": { + "a2lix/auto-form-bundle": "^1.0" + } + } + ``` + Then run `composer update a2lix/auto-form-bundle --with-all-dependencies`. + +2. **Decoupled from Doctrine:** The bundle no longer requires `doctrine/persistence` or `symfony/doctrine-bridge`. If your project relies on them, you must now require them explicitly in your own `composer.json`. + +## BC BREAK: Form Type Renaming + +The main form type has been renamed. + +- `A2lix\AutoFormBundle\Form\Type\AutoFormType` is **removed**. +- Use `A2lix\AutoFormBundle\Form\Type\AutoType` instead. + +**Before:** +```php +use A2lix\AutoFormBundle\Form\Type\AutoFormType; +$this->createForm(AutoFormType::class, /* ... */); +``` + +**After:** +```php +use A2lix\AutoFormBundle\Form\Type\AutoType; +$this->createForm(AutoType::class, /* ... */); +``` + +## BC BREAK: Field Customization + +The way to customize fields has completely changed. There are two methods: using PHP attributes (recommended) or using form options. + +### Method 1: Using `#[AutoTypeCustom]` Attribute (Recommended) + +Customization can be done using the `#[AutoTypeCustom]` PHP attribute directly on your data object's properties. + +**Before:** +```php +// In your Controller +$this->createForm(AutoFormType::class, new Product(), [ + 'fields' => [ + 'description' => [ + 'field_type' => TextareaType::class, + 'label' => 'Product Description', + ], + ], + 'excluded_fields' => ['createdAt'], +]); +``` + +**After:** +```php +// On your data object (Entity or DTO) +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; + +class Product +{ + #[AutoTypeCustom( + type: TextareaType::class, + options: ['label' => 'Product Description'] + )] + public string $description; + + #[AutoTypeCustom(display: false)] + public \DateTimeImmutable $createdAt; + + // ... other properties +} + +// In your Controller, the configuration is now minimal +$this->createForm(AutoType::class, new Product()) +``` + +### Method 2: Using Form Options + +Customization is also still possible at the form level by passing options to `AutoType`. **This method will override any `#[AutoTypeCustom]` attributes set on the data object.** + +The option keys have been renamed for clarity: + +- The main configuration array `fields` is now `children`. +- Inside a child's configuration, `field_type` is now `child_type`. +- The `excluded_fields` option is now `children_excluded`. + +**Before:** +```php +// In your Controller +$this->createForm(AutoFormType::class, new Product(), [ + 'fields' => [ + 'description' => [ + 'field_type' => TextareaType::class, + 'label' => 'Product Description', + ], + ], + 'excluded_fields' => ['createdAt'], +]); +``` + +**After:** +```php +// In your Controller +$this->createForm(AutoType::class, new Product(), [ + 'children' => [ + 'description' => [ + 'child_type' => TextareaType::class, + 'label' => 'Product Description', // Note: options are now merged at the top level + ], + ], + 'children_excluded' => ['createdAt'], +]); +``` + +## BC BREAK: Removed Classes and Concepts + +The internal architecture was refactored. The following major classes and concepts have been **removed** without direct replacement: + +- `A2lix\AutoFormBundle\Form\EventListener\AutoFormListener` +- `A2lix\AutoFormBundle\Form\Manipulator\DoctrineORMManipulator` +- `A2lix\AutoFormBundle\ObjectInfo\DoctrineORMInfo` + +## BC BREAK: Bundle Configuration + +The bundle is now zero-configuration for most use cases. You should **remove** your old configuration file at `config/packages/a2lix_auto_form.yaml`. diff --git a/tests/Fixtures/Dto/Media1.php b/tests/Fixtures/Dto/Media1.php index d7f0ded..7e26119 100644 --- a/tests/Fixtures/Dto/Media1.php +++ b/tests/Fixtures/Dto/Media1.php @@ -1,6 +1,4 @@ - 'media.url_help'])] public readonly ?string $url = null, - #[AutoTypeCustom(type: CoreType\TextareaType::class)] private ?string $description = null, ) {} diff --git a/tests/Fixtures/Dto/Product1.php b/tests/Fixtures/Dto/Product1.php index f1ccda4..e822309 100644 --- a/tests/Fixtures/Dto/Product1.php +++ b/tests/Fixtures/Dto/Product1.php @@ -1,6 +1,4 @@ - [ 'expected_type' => CoreType\EnumType::class, - 'class' => ProductStatus::class + 'class' => ProductStatus::class, ], 'statusList' => [ 'expected_type' => CoreType\EnumType::class, @@ -116,7 +114,7 @@ public static function provideScenarioCases(): iterable ], 'status' => [ 'expected_type' => CoreType\EnumType::class, - 'class' => ProductStatus::class + 'class' => ProductStatus::class, ], 'statusList' => [ 'expected_type' => CoreType\EnumType::class, @@ -170,7 +168,7 @@ public static function provideScenarioCases(): iterable ], 'status' => [ 'expected_type' => CoreType\EnumType::class, - 'class' => ProductStatus::class + 'class' => ProductStatus::class, ], 'statusList' => [ 'expected_type' => CoreType\EnumType::class, @@ -250,25 +248,21 @@ public static function provideScenarioCases(): iterable formOptions: [ 'children_excluded' => '*', 'children' => [ - 'description' => function (FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface { - return $builder->create('description', CoreType\TextareaType::class, [ - 'attr' => $propAttributeOptions['attr'], - 'label' => 'product.description_label', - ]); - }, - '_ignoredNaming_' => function (FormBuilderInterface $builder): FormBuilderInterface { - return $builder - ->create('validity_range', CoreType\FormType::class, ['inherit_data' => true]) - ->add('validityStartAt', CoreType\DateType::class) - ->add('validityEndAt', CoreType\DateType::class); - }, + 'description' => static fn (FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface => $builder->create('description', CoreType\TextareaType::class, [ + 'attr' => $propAttributeOptions['attr'], + 'label' => 'product.description_label', + ]), + '_ignoredNaming_' => static fn (FormBuilderInterface $builder): FormBuilderInterface => $builder + ->create('validity_range', CoreType\FormType::class, ['inherit_data' => true]) + ->add('validityStartAt', CoreType\DateType::class) + ->add('validityEndAt', CoreType\DateType::class), 'agreement' => [ 'child_type' => CoreType\CheckboxType::class, 'mapped' => false, ], ], /** @psalm-suppress UnusedClosureParam */ - 'builder' => function(FormBuilderInterface $builder, array $classProperties): void { + 'builder' => static function (FormBuilderInterface $builder, array $classProperties): void { $builder->add('save', CoreType\SubmitType::class); }, ], diff --git a/tests/Form/DataProviderEntity.php b/tests/Form/DataProviderEntity.php index 48dbfbc..ed86c3b 100644 --- a/tests/Form/DataProviderEntity.php +++ b/tests/Form/DataProviderEntity.php @@ -1,6 +1,4 @@ - [ 'expected_type' => CoreType\EnumType::class, - 'class' => ProductStatus::class + 'class' => ProductStatus::class, ], 'statusList' => [ 'expected_type' => CoreType\EnumType::class, @@ -116,7 +114,7 @@ public static function provideScenarioCases(): iterable ], 'status' => [ 'expected_type' => CoreType\EnumType::class, - 'class' => ProductStatus::class + 'class' => ProductStatus::class, ], 'statusList' => [ 'expected_type' => CoreType\EnumType::class, @@ -249,25 +247,21 @@ public static function provideScenarioCases(): iterable formOptions: [ 'children_excluded' => '*', 'children' => [ - 'description' => function (FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface { - return $builder->create('description', CoreType\TextareaType::class, [ - 'attr' => $propAttributeOptions['attr'], - 'label' => 'product.description_label', - ]); - }, - '_ignoredNaming_' => function (FormBuilderInterface $builder): FormBuilderInterface { - return $builder - ->create('validity_range', CoreType\FormType::class, ['inherit_data' => true]) - ->add('validityStartAt', CoreType\DateType::class) - ->add('validityEndAt', CoreType\DateType::class); - }, + 'description' => static fn (FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface => $builder->create('description', CoreType\TextareaType::class, [ + 'attr' => $propAttributeOptions['attr'], + 'label' => 'product.description_label', + ]), + '_ignoredNaming_' => static fn (FormBuilderInterface $builder): FormBuilderInterface => $builder + ->create('validity_range', CoreType\FormType::class, ['inherit_data' => true]) + ->add('validityStartAt', CoreType\DateType::class) + ->add('validityEndAt', CoreType\DateType::class), 'agreement' => [ 'child_type' => CoreType\CheckboxType::class, 'mapped' => false, ], ], /** @psalm-suppress UnusedClosureParam */ - 'builder' => function(FormBuilderInterface $builder, array $classProperties): void { + 'builder' => static function (FormBuilderInterface $builder, array $classProperties): void { $builder->add('save', CoreType\SubmitType::class); }, ], diff --git a/tests/Form/Type/AutoTypeTest.php b/tests/Form/Type/AutoTypeTest.php index 0ef6b17..87b7d89 100755 --- a/tests/Form/Type/AutoTypeTest.php +++ b/tests/Form/Type/AutoTypeTest.php @@ -24,6 +24,7 @@ * @internal * * @psalm-suppress PropertyNotSetInConstructor + * * @psalm-import-type ExpectedChildren from TestScenario */ #[CoversNothing] From d881c7f848d6490dc3d37e837f777ea5abd67de5 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:19:51 +0000 Subject: [PATCH 12/35] Fixes --- .github/workflows/ci.yml | 84 ++++++++++++++++++++++++++-------------- composer.json | 1 + config/services.php | 2 +- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 034bd85..eab6061 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: jobs: lint: + name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,12 +21,9 @@ jobs: - name: Cache Composer packages uses: actions/cache@v4 with: - path: | - vendor - ~/.composer/cache - key: ${{ runner.os }}-php-8.4-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php-8.4- + path: vendor + key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-php-8.4-composer- - name: Validate composer.json and composer.lock run: composer validate --strict @@ -36,16 +34,42 @@ jobs: - name: Run php-cs-fixer (dry run) run: vendor/bin/php-cs-fixer fix --dry-run --diff --verbose + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-php-8.4-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run psalm + run: vendor/bin/psalm + tests: + name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, deps ${{ matrix.dependencies }} runs-on: ubuntu-latest - needs: lint + needs: + - lint + - static-analysis strategy: fail-fast: false matrix: php: ['8.3', '8.4'] - symfony: ['7.4.*', '8.0.*'] - - name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }} + symfony: ['7.3.*', '7.4.*', '8.0.*'] + dependencies: ['lowest', 'stable'] steps: - uses: actions/checkout@v4 @@ -58,26 +82,30 @@ jobs: - name: Cache Composer packages uses: actions/cache@v4 with: - path: | - vendor - ~/.composer/cache - key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}- - - - name: Validate composer.json and composer.lock - run: composer validate --strict + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.dependencies }}- - name: Update dependencies for Symfony ${{ matrix.symfony }} + run: > + composer require "symfony/config:${{ matrix.symfony }}" + "symfony/dependency-injection:${{ matrix.symfony }}" + "symfony/form:${{ matrix.symfony }}" + "symfony/http-kernel:${{ matrix.symfony }}" + "symfony/property-info:${{ matrix.symfony }}" + "symfony/cache:${{ matrix.symfony }}" + "symfony/doctrine-bridge:${{ matrix.symfony }}" + "symfony/validator:${{ matrix.symfony }}" + "symfony/var-dumper:${{ matrix.symfony }}" + --no-update --no-scripts --no-plugins + + - name: Install dependencies (${{ matrix.dependencies }}) run: | - composer require "symfony/config:${{ matrix.symfony }}" "symfony/dependency-injection:${{ matrix.symfony }}" "symfony/doctrine-bridge:${{ matrix.symfony }}" "symfony/form:${{ matrix.symfony }}" --no-update - composer update --prefer-dist --no-progress --optimize-autoloader - - - name: Run psalm - run: vendor/bin/psalm - + if [ "${{ matrix.dependencies }}" = "lowest" ]; then + composer update --prefer-lowest --no-progress --no-scripts --no-plugins + else + composer update --prefer-stable --no-progress --no-scripts --no-plugins + fi + - name: Run phpunit run: vendor/bin/phpunit - - - name: Check for outdated dependencies - run: composer outdated diff --git a/composer.json b/composer.json index 9356fa3..d336cfb 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "symfony/config": "^7.3", "symfony/dependency-injection": "^7.3", "symfony/form": "^7.3", + "symfony/http-kernel": "^7.3", "symfony/property-info": "^7.3", "phpdocumentor/reflection-docblock": "^5.6" }, diff --git a/config/services.php b/config/services.php index 6fb990e..49e4ec1 100644 --- a/config/services.php +++ b/config/services.php @@ -27,10 +27,10 @@ ->set('a2lix_auto_form.form.type.auto_type', AutoType::class) ->args([ '$autoTypeBuilder' => service('a2lix_auto_form.form.builder.auto_type_builder'), + '$globalExcludedChildren' => abstract_arg('globalExcludedChildren'), ]) ->tag('form.type') - ->set('a2lix_auto_form.type_guesser.type_info', TypeInfoTypeGuesser::class) ->args([ '$typeResolver' => service('type_info.resolver'), From ada83f57cdfb9570eefa3d1d95185adc31f5eb9e Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:31:20 +0200 Subject: [PATCH 13/35] Update composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d336cfb..1c18698 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.x-dev" + "dev-main": "1.x-dev" } } } From c30fc0849511f9100481df8f7a9dcf2935195f90 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:37:15 +0200 Subject: [PATCH 14/35] Update ci.yml --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab6061..12e9aeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,7 @@ name: CI on: push: - branches: [ "1.x" ] pull_request: - branches: [ "1.x" ] jobs: lint: From 1aaa9db287bf3b1e8c5309fc0de07ab4102a05b2 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:04:06 +0000 Subject: [PATCH 15/35] Fixes --- .php-cs-fixer.dist.php | 1 + psalm.xml | 15 +++++++++++++++ src/Form/Builder/AutoTypeBuilder.php | 2 +- src/Form/Type/AutoType.php | 2 +- src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 2 +- tests/Form/DataProviderDto.php | 2 +- tests/Form/DataProviderEntity.php | 2 +- tests/Form/TestScenario.php | 2 +- 8 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 5b80b3c..f8a719e 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -51,6 +51,7 @@ 'phpdoc_var_without_name' => true, 'phpdoc_to_comment' => false, 'single_line_throw' => true, + 'string_implicit_backslashes' => false, // Temporary? 'statement_indentation' => true, 'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arrays', 'parameters']], 'use_arrow_functions' => true, diff --git a/psalm.xml b/psalm.xml index 5fdfd66..99b05ac 100644 --- a/psalm.xml +++ b/psalm.xml @@ -11,6 +11,8 @@ + + @@ -18,5 +20,18 @@ + + + + + + + + + + + + + diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 2e572b0..439bd31 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -23,7 +23,7 @@ /** * @psalm-import-type FormOptionsDefaults from AutoType */ -class AutoTypeBuilder +final class AutoTypeBuilder { public function __construct( private readonly PropertyInfoExtractorInterface $propertyInfoExtractor, diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index ceafcc7..eafe006 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -34,7 +34,7 @@ * builder: FormBuilderCallable|null, * } */ -class AutoType extends AbstractType +final class AutoType extends AbstractType { public function __construct( private readonly AutoTypeBuilder $autoTypeBuilder, diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php index e970c3e..941c2b2 100644 --- a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -21,7 +21,7 @@ use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; -class TypeInfoTypeGuesser implements FormTypeGuesserInterface +final class TypeInfoTypeGuesser implements FormTypeGuesserInterface { public function __construct( private readonly TypeResolverInterface $typeResolver, diff --git a/tests/Form/DataProviderDto.php b/tests/Form/DataProviderDto.php index 1df002d..27f19b1 100644 --- a/tests/Form/DataProviderDto.php +++ b/tests/Form/DataProviderDto.php @@ -18,7 +18,7 @@ use Symfony\Component\Form\Extension\Core\Type as CoreType; use Symfony\Component\Form\FormBuilderInterface; -class DataProviderDto +final class DataProviderDto { /** * @return \Iterator> diff --git a/tests/Form/DataProviderEntity.php b/tests/Form/DataProviderEntity.php index ed86c3b..849d9a5 100644 --- a/tests/Form/DataProviderEntity.php +++ b/tests/Form/DataProviderEntity.php @@ -18,7 +18,7 @@ use Symfony\Component\Form\Extension\Core\Type as CoreType; use Symfony\Component\Form\FormBuilderInterface; -class DataProviderEntity +final class DataProviderEntity { /** * @return \Iterator> diff --git a/tests/Form/TestScenario.php b/tests/Form/TestScenario.php index d7971fa..6080e6a 100644 --- a/tests/Form/TestScenario.php +++ b/tests/Form/TestScenario.php @@ -18,7 +18,7 @@ * ... * }> */ -class TestScenario +final class TestScenario { /** * @param ExpectedChildren $expectedForm From f3f085925c7d7297bed3a06ed2957bc2ae182266 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:16:46 +0000 Subject: [PATCH 16/35] Fixes --- .github/workflows/ci.yml | 28 +++++++++++-------- composer.json | 12 ++++----- src/Form/Builder/AutoTypeBuilder.php | 40 +++------------------------- tests/Form/TypeTestCase.php | 4 +-- 4 files changed, 29 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12e9aeb..57f8d50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,9 +65,15 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.3', '8.4'] - symfony: ['7.3.*', '7.4.*', '8.0.*'] + php: ['8.4'] dependencies: ['lowest', 'stable'] + include: + - symfony: '7.3.*' + stability: '' + - symfony: '7.4.*' + stability: '@dev' + - symfony: '8.0.*' + stability: '@dev' steps: - uses: actions/checkout@v4 @@ -86,15 +92,15 @@ jobs: - name: Update dependencies for Symfony ${{ matrix.symfony }} run: > - composer require "symfony/config:${{ matrix.symfony }}" - "symfony/dependency-injection:${{ matrix.symfony }}" - "symfony/form:${{ matrix.symfony }}" - "symfony/http-kernel:${{ matrix.symfony }}" - "symfony/property-info:${{ matrix.symfony }}" - "symfony/cache:${{ matrix.symfony }}" - "symfony/doctrine-bridge:${{ matrix.symfony }}" - "symfony/validator:${{ matrix.symfony }}" - "symfony/var-dumper:${{ matrix.symfony }}" + composer require "symfony/config:${{ matrix.symfony }}${{ matrix.stability }}" + "symfony/dependency-injection:${{ matrix.symfony }}${{ matrix.stability }}" + "symfony/form:${{ matrix.symfony }}${{ matrix.stability }}" + "symfony/http-kernel:${{ matrix.symfony }}${{ matrix.stability }}" + "symfony/property-info:${{ matrix.symfony }}${{ matrix.stability }}" + "symfony/cache:${{ matrix.symfony }}${{ matrix.stability }}" + "symfony/doctrine-bridge:${{ matrix.symfony }}${{ matrix.stability }}" + "symfony/validator:${{ matrix.symfony }}${{ matrix.stability }}" + "symfony/var-dumper:${{ matrix.symfony }}${{ matrix.stability }}" --no-update --no-scripts --no-plugins - name: Install dependencies (${{ matrix.dependencies }}) diff --git a/composer.json b/composer.json index 1c18698..a36394a 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,12 @@ } ], "require": { - "php": ">=8.2", - "symfony/config": "^7.3", - "symfony/dependency-injection": "^7.3", - "symfony/form": "^7.3", - "symfony/http-kernel": "^7.3", - "symfony/property-info": "^7.3", + "php": ">=8.4", + "symfony/config": "^7.3.4|^7.4|^8.0", + "symfony/dependency-injection": "^7.3.4|^7.4|^8.0", + "symfony/form": "^7.3.4|^7.4|^8.0", + "symfony/http-kernel": "^7.3.4|^7.4|^8.0", + "symfony/property-info": "^7.3.4|^7.4|^8.0", "phpdocumentor/reflection-docblock": "^5.6" }, "require-dev": { diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 439bd31..5d43ffc 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -160,48 +160,16 @@ private function addChild(FormBuilderInterface $builder, string|FormBuilderInter */ private function getDataClass(FormInterface $form): string { - // Form data_class config? (With old proxy handling) - if (null !== $dataClass = $form->getConfig()->getDataClass()) { - if (false !== $pos = strrpos($dataClass, '\\__CG__\\')) { + do { + if (null !== $dataClass = $form->getConfig()->getDataClass()) { /** @var class-string */ - return substr($dataClass, $pos + 8); + return $dataClass; } - - /** @var class-string */ - return $dataClass; - } - - // Loop parent form to get closest data_class config - while (null !== $formParent = $form->getParent()) { - if (null === $dataClass = $formParent->getConfig()->getDataClass()) { - $form = $formParent; - - continue; - } - - return $this->getAssociationTargetClass($dataClass, (string) $form->getPropertyPath()); - } + } while (null !== $form = $form->getParent()); throw new \RuntimeException('Unable to get dataClass'); } - /** - * @return class-string - */ - private function getAssociationTargetClass(string $class, string $childName): string - { - if (null === $propTypeInfo = $this->propertyInfoExtractor->getType($class, $childName)) { - throw new \RuntimeException(\sprintf('Unable to find the association target class of "%s" in %s.', $childName, $class)); - } - - $innerType = $propTypeInfo instanceof TypeInfo\CollectionType ? $propTypeInfo->getCollectionValueType() : $propTypeInfo; - if (!$innerType instanceof TypeInfo\ObjectType) { - throw new \RuntimeException(\sprintf('Unable to find the association target class of "%s" in %s.', $childName, $class)); - } - - return $innerType->getClassName(); - } - private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeInfo, int $formLevel): array { // TypeInfo matching native FormType? Abort, guessers are enough diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 6ea5832..216177a 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -45,8 +45,8 @@ public static function setUpBeforeClass(): void { VarDumper::setHandler(static function (mixed $var): void { /** @psalm-suppress PossiblyInvalidArgument */ - (new HtmlDumper())->dump( - (new VarCloner())->cloneVar($var), + new HtmlDumper()->dump( + new VarCloner()->cloneVar($var), @fopen(__DIR__.'/../../dump.html', 'a') ); }); From 846d6a87d323ec614c1b4ff22bd45a7f9e0fde96 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:26:08 +0000 Subject: [PATCH 17/35] Fixes --- .github/workflows/ci.yml | 26 ++++++++++---------------- composer.json | 2 ++ 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57f8d50..4732c25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,14 +66,8 @@ jobs: fail-fast: false matrix: php: ['8.4'] + symfony: ['7.3.*', '7.4.*', '8.0.*'] dependencies: ['lowest', 'stable'] - include: - - symfony: '7.3.*' - stability: '' - - symfony: '7.4.*' - stability: '@dev' - - symfony: '8.0.*' - stability: '@dev' steps: - uses: actions/checkout@v4 @@ -92,15 +86,15 @@ jobs: - name: Update dependencies for Symfony ${{ matrix.symfony }} run: > - composer require "symfony/config:${{ matrix.symfony }}${{ matrix.stability }}" - "symfony/dependency-injection:${{ matrix.symfony }}${{ matrix.stability }}" - "symfony/form:${{ matrix.symfony }}${{ matrix.stability }}" - "symfony/http-kernel:${{ matrix.symfony }}${{ matrix.stability }}" - "symfony/property-info:${{ matrix.symfony }}${{ matrix.stability }}" - "symfony/cache:${{ matrix.symfony }}${{ matrix.stability }}" - "symfony/doctrine-bridge:${{ matrix.symfony }}${{ matrix.stability }}" - "symfony/validator:${{ matrix.symfony }}${{ matrix.stability }}" - "symfony/var-dumper:${{ matrix.symfony }}${{ matrix.stability }}" + composer require "symfony/config:${{ matrix.symfony }}" + "symfony/dependency-injection:${{ matrix.symfony }}" + "symfony/form:${{ matrix.symfony }}" + "symfony/http-kernel:${{ matrix.symfony }}" + "symfony/property-info:${{ matrix.symfony }}" + "symfony/cache:${{ matrix.symfony }}" + "symfony/doctrine-bridge:${{ matrix.symfony }}" + "symfony/validator:${{ matrix.symfony }}" + "symfony/var-dumper:${{ matrix.symfony }}" --no-update --no-scripts --no-plugins - name: Install dependencies (${{ matrix.dependencies }}) diff --git a/composer.json b/composer.json index a36394a..34581d2 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,8 @@ "homepage": "https://github.com/a2lix/AutoFormBundle/contributors" } ], + "minimum-stability": "dev", + "prefer-stable": true, "require": { "php": ">=8.4", "symfony/config": "^7.3.4|^7.4|^8.0", From a30b74787556f034c52b6ba7937406927058ea9b Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:00:02 +0000 Subject: [PATCH 18/35] Fixes --- .github/workflows/ci.yml | 90 ++++++++++++++++++++++++++-------------- composer.json | 15 ++++--- 2 files changed, 66 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4732c25..42d1fb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,17 +57,43 @@ jobs: run: vendor/bin/psalm tests: - name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, deps ${{ matrix.dependencies }} + name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, deps ${{ matrix.deps_name }} runs-on: ubuntu-latest - needs: - - lint - - static-analysis + continue-on-error: ${{ matrix.stability == 'dev' }} strategy: fail-fast: false matrix: - php: ['8.4'] - symfony: ['7.3.*', '7.4.*', '8.0.*'] - dependencies: ['lowest', 'stable'] + include: + # Symfony 7.3 (stable) + - php: '8.4' + symfony: '7.3' + composer_args: '--prefer-stable' + stability: 'stable' + deps_name: 'stable' + - php: '8.4' + symfony: '7.3' + composer_args: '--prefer-lowest' + stability: 'stable' + deps_name: 'lowest' + + # Symfony 7.4 (dev) + - php: '8.4' + symfony: '7.4' + composer_args: '--prefer-stable' + stability: 'dev' + deps_name: 'stable' + - php: '8.4' + symfony: '7.4' + composer_args: '--prefer-lowest' + stability: 'dev' + deps_name: 'lowest' + + # Symfony 8.0 (dev) + - php: '8.4' + symfony: '8.0' + composer_args: '--prefer-stable' + stability: 'dev' + deps_name: 'stable' steps: - uses: actions/checkout@v4 @@ -77,33 +103,35 @@ jobs: with: php-version: ${{ matrix.php }} + - name: Cache global Composer packages + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: ${{ runner.os }}-php-${{ matrix.php }}-composer-global-cache + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-composer-global-cache + + - name: symfony/flex is required to install the correct symfony version + run: | + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require symfony/flex --quiet + + - name: Configure Composer stability + run: composer config minimum-stability ${{ matrix.stability }} + + - name: Configure Symfony version for symfony/flex + run: composer config extra.symfony.require "${{ matrix.symfony }}.*" + - name: Cache Composer packages uses: actions/cache@v4 with: path: vendor - key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.dependencies }}- - - - name: Update dependencies for Symfony ${{ matrix.symfony }} - run: > - composer require "symfony/config:${{ matrix.symfony }}" - "symfony/dependency-injection:${{ matrix.symfony }}" - "symfony/form:${{ matrix.symfony }}" - "symfony/http-kernel:${{ matrix.symfony }}" - "symfony/property-info:${{ matrix.symfony }}" - "symfony/cache:${{ matrix.symfony }}" - "symfony/doctrine-bridge:${{ matrix.symfony }}" - "symfony/validator:${{ matrix.symfony }}" - "symfony/var-dumper:${{ matrix.symfony }}" - --no-update --no-scripts --no-plugins - - - name: Install dependencies (${{ matrix.dependencies }}) - run: | - if [ "${{ matrix.dependencies }}" = "lowest" ]; then - composer update --prefer-lowest --no-progress --no-scripts --no-plugins - else - composer update --prefer-stable --no-progress --no-scripts --no-plugins - fi + key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.deps_name }}-${{ hashFiles('**/composer.json', '**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.deps_name }}- + + - name: Install dependencies (${{ matrix.deps_name }}) + run: composer update ${{ matrix.composer_args }} --no-progress --no-scripts --no-plugins - name: Run phpunit - run: vendor/bin/phpunit + run: vendor/bin/phpunit \ No newline at end of file diff --git a/composer.json b/composer.json index 34581d2..cd0c42c 100644 --- a/composer.json +++ b/composer.json @@ -15,18 +15,17 @@ "homepage": "https://github.com/a2lix/AutoFormBundle/contributors" } ], - "minimum-stability": "dev", - "prefer-stable": true, "require": { "php": ">=8.4", - "symfony/config": "^7.3.4|^7.4|^8.0", - "symfony/dependency-injection": "^7.3.4|^7.4|^8.0", - "symfony/form": "^7.3.4|^7.4|^8.0", - "symfony/http-kernel": "^7.3.4|^7.4|^8.0", - "symfony/property-info": "^7.3.4|^7.4|^8.0", + "symfony/config": "^7.3|^7.4|^8.0", + "symfony/dependency-injection": "^7.3|^7.4|^8.0", + "symfony/form": "^7.3|^7.4|^8.0", + "symfony/http-kernel": "^7.3|^7.4|^8.0", + "symfony/property-info": "^7.3|^7.4|^8.0", + "symfony/type-info": "^7.3.4|^7.4|^8.0", "phpdocumentor/reflection-docblock": "^5.6" }, - "require-dev": { + "require-dev": { "doctrine/orm": "^3.5", "friendsofphp/php-cs-fixer": "^3.87", "kubawerlos/php-cs-fixer-custom-fixers": "^3.34", From cdccbb669d4023dd9ac541420d44cb09ef150063 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:31:18 +0000 Subject: [PATCH 19/35] Fixes --- .github/workflows/ci.yml | 46 +++++++++++++++------------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42d1fb5..c762b3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,12 +16,13 @@ jobs: with: php-version: '8.4' - - name: Cache Composer packages + - name: Cache Composer dependencies uses: actions/cache@v4 with: - path: vendor + path: ~/.composer/cache key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-php-8.4-composer- + restore-keys: | + ${{ runner.os }}-php-8.4-composer- - name: Validate composer.json and composer.lock run: composer validate --strict @@ -43,12 +44,13 @@ jobs: with: php-version: '8.4' - - name: Cache Composer packages + - name: Cache Composer dependencies uses: actions/cache@v4 with: - path: vendor + path: ~/.composer/cache key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: ${{ runner.os }}-php-8.4-composer- + restore-keys: | + ${{ runner.os }}-php-8.4-composer- - name: Install dependencies run: composer install --prefer-dist --no-progress @@ -57,8 +59,11 @@ jobs: run: vendor/bin/psalm tests: - name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, deps ${{ matrix.deps_name }} + name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, deps ${{ matrix.composer_args }} runs-on: ubuntu-latest + needs: + - lint + - static-analysis continue-on-error: ${{ matrix.stability == 'dev' }} strategy: fail-fast: false @@ -69,31 +74,22 @@ jobs: symfony: '7.3' composer_args: '--prefer-stable' stability: 'stable' - deps_name: 'stable' - php: '8.4' symfony: '7.3' composer_args: '--prefer-lowest' stability: 'stable' - deps_name: 'lowest' # Symfony 7.4 (dev) - php: '8.4' symfony: '7.4' composer_args: '--prefer-stable' stability: 'dev' - deps_name: 'stable' - - php: '8.4' - symfony: '7.4' - composer_args: '--prefer-lowest' - stability: 'dev' - deps_name: 'lowest' # Symfony 8.0 (dev) - php: '8.4' symfony: '8.0' composer_args: '--prefer-stable' stability: 'dev' - deps_name: 'stable' steps: - uses: actions/checkout@v4 @@ -103,13 +99,13 @@ jobs: with: php-version: ${{ matrix.php }} - - name: Cache global Composer packages + - name: Cache Composer dependencies uses: actions/cache@v4 with: path: ~/.composer/cache - key: ${{ runner.os }}-php-${{ matrix.php }}-composer-global-cache + key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.composer_args }}-${{ hashFiles('**/composer.json') }} restore-keys: | - ${{ runner.os }}-php-${{ matrix.php }}-composer-global-cache + ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.composer_args }}- - name: symfony/flex is required to install the correct symfony version run: | @@ -122,16 +118,8 @@ jobs: - name: Configure Symfony version for symfony/flex run: composer config extra.symfony.require "${{ matrix.symfony }}.*" - - name: Cache Composer packages - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.deps_name }}-${{ hashFiles('**/composer.json', '**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.deps_name }}- - - - name: Install dependencies (${{ matrix.deps_name }}) + - name: Install dependencies run: composer update ${{ matrix.composer_args }} --no-progress --no-scripts --no-plugins - name: Run phpunit - run: vendor/bin/phpunit \ No newline at end of file + run: vendor/bin/phpunit From 5c8e78c75d018b6792a49b826b5a521691c5de51 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:36:28 +0000 Subject: [PATCH 20/35] Fixes --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c762b3f..6edfaac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,10 @@ jobs: with: php-version: '8.4' + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" + - name: Cache Composer dependencies uses: actions/cache@v4 with: From 5cc9a5762101db493f8fdc93271488831b5066ec Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:46:31 +0000 Subject: [PATCH 21/35] Fixes --- .github/workflows/ci.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6edfaac..58444bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,14 +16,10 @@ jobs: with: php-version: '8.4' - - name: Get composer cache directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" - - name: Cache Composer dependencies uses: actions/cache@v4 with: - path: ~/.composer/cache + path: ~/.cache/composer/files key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-php-8.4-composer- @@ -51,7 +47,7 @@ jobs: - name: Cache Composer dependencies uses: actions/cache@v4 with: - path: ~/.composer/cache + path: ~/.cache/composer/files key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-php-8.4-composer- @@ -106,7 +102,7 @@ jobs: - name: Cache Composer dependencies uses: actions/cache@v4 with: - path: ~/.composer/cache + path: ~/.cache/composer/files key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.composer_args }}-${{ hashFiles('**/composer.json') }} restore-keys: | ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.composer_args }}- From 9845ac7718ad87581e390af73bb60a58b75f6def Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:40:16 +0100 Subject: [PATCH 22/35] Children_groups / Child_group feature (#49) --- .devcontainer/devcontainer.json | 23 +++- .github/workflows/ci.yml | 37 +++++-- README.md | 53 ++++++++++ composer.json | 15 ++- phpstan.neon | 20 ++++ psalm.xml | 37 ------- src/A2lixAutoFormBundle.php | 2 - src/Form/Attribute/AutoTypeCustom.php | 8 +- src/Form/Builder/AutoTypeBuilder.php | 104 ++++++++++++------- src/Form/Type/AutoType.php | 34 +++--- src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 13 +-- tests/Fixtures/Dto/Product1.php | 3 + tests/Fixtures/Entity/Product1.php | 15 +-- tests/Fixtures/Type/ValidityRangeType.php | 3 + tests/Form/DataProviderDto.php | 82 ++++++++++++++- tests/Form/DataProviderEntity.php | 82 ++++++++++++++- tests/Form/TestScenario.php | 13 +-- tests/Form/Type/AutoTypeTest.php | 23 ++-- tests/Form/TypeTestCase.php | 2 +- 19 files changed, 423 insertions(+), 146 deletions(-) create mode 100644 phpstan.neon delete mode 100644 psalm.xml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 37b5d65..033d3e5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,10 +8,29 @@ "extensions": [ "bmewburn.vscode-intelephense-client", "xdebug.php-debug", - "getpsalm.psalm-vscode-plugin" + "SanderRonde.phpstan-vscode" ], "settings": { - "terminal.integrated.defaultProfile.linux": "bash" + "terminal.integrated.defaultProfile.linux": "bash", + "intelephense.telemetry.enabled": false, + "workbench.colorCustomizations": { + "statusBar.background": "#ffa600d3", + "statusBar.noFolderBackground": "#ffa600d3", + "statusBar.debuggingBackground": "#ffa600d3", + "activityBar.activeBorder": "#ffa600d3", + "activityBarBadge.background": "#ffa600d3", + "badge.background": "#ffa600d3", + "focusBorder": "#ffa600d3", + "progressBar.background": "#ffa600d3", + "notificationCenter.border": "#ffa600d3", + "notificationsInfoIcon.foreground": "#ffa600d3", + "pickerGroup.border": "#ffa600d3", + "settings.modifiedItemIndicator": "#ffa600d3", + "panelTitle.activeBorder": "#ffa600d3", + "list.activeSelectionBackground": "#6e6e6e", + "list.inactiveSelectionBackground": "#6e6e6e", + "list.hoverBackground": "#6e6e6e", + }, } } }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58444bf..cf9d312 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,12 +4,17 @@ on: push: pull_request: +permissions: + contents: read + jobs: lint: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -37,7 +42,9 @@ jobs: name: Static Analysis runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -55,8 +62,8 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress - - name: Run psalm - run: vendor/bin/psalm + - name: Run phpstan + run: vendor/bin/phpstan analyse --memory-limit=2G tests: name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, deps ${{ matrix.composer_args }} @@ -74,6 +81,7 @@ jobs: symfony: '7.3' composer_args: '--prefer-stable' stability: 'stable' + coverage: true - php: '8.4' symfony: '7.3' composer_args: '--prefer-lowest' @@ -92,12 +100,15 @@ jobs: stability: 'dev' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} + coverage: pcov - name: Cache Composer dependencies uses: actions/cache@v4 @@ -120,6 +131,18 @@ jobs: - name: Install dependencies run: composer update ${{ matrix.composer_args }} --no-progress --no-scripts --no-plugins - + - name: Run phpunit - run: vendor/bin/phpunit + run: | + if [[ "${{ matrix.coverage }}" == "true" ]]; then + mkdir -p build/logs + vendor/bin/phpunit --coverage-clover coverage.xml + else + vendor/bin/phpunit + fi + + - name: Upload coverage to Codecov + if: matrix.coverage + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 3e33803..84a0cdd 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![Total Downloads](https://poser.pugx.org/a2lix/auto-form-bundle/downloads)](https://packagist.org/packages/a2lix/auto-form-bundle) [![License](https://poser.pugx.org/a2lix/auto-form-bundle/license)](https://packagist.org/packages/a2lix/auto-form-bundle) [![Build Status](https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml/badge.svg)](https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/a2lix/AutoFormBundle/branch/main/graph/badge.svg)](https://codecov.io/gh/a2lix/AutoFormBundle) Stop writing boilerplate form code. This bundle provides a single, powerful `AutoType` form type that automatically generates a complete Symfony form from any PHP class. @@ -135,6 +136,58 @@ class Product } ``` +### Conditional Fields with Groups + +You can conditionally include fields based on groups, similar to how Symfony's `validation_groups` work. This is useful for having different versions of a form (e.g., a "creation" version vs. an "edition" version). + +To enable this, pass a `children_groups` option to your form. This option specifies which groups of fields should be included. + +```php +$form = $this->createForm(AutoType::class, $product, [ + 'children_groups' => ['product:edit'], +]); +``` + +You can then assign fields to one or more groups using either form options or attributes. + +#### Via Form Options + +Use the `child_groups` option within the `children` configuration: + +```php +// ... +'children' => [ + 'name' => [ + 'child_groups' => ['product:edit', 'product:create'], + ], + 'stock' => [ + 'child_groups' => ['product:edit'], + ], +], +// ... +``` + +In this example, if `children_groups` is set to `['product:edit']`, both `name` and `stock` will be included. If it's set to `['product:create']`, only `name` will be included. + +#### Via `#[AutoTypeCustom]` Attribute + +Use the `groups` property on the attribute: + +```php +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; + +class Product +{ + #[AutoTypeCustom(groups: ['product:edit', 'product:create'])] + public ?string $name = null; + + #[AutoTypeCustom(groups: ['product:edit'])] + public ?int $stock = null; +} +``` + +If no `children_groups` option is provided to the form, all fields are included by default, regardless of whether they have groups assigned. + ## Advanced Recipes ### Creating a Compound Field with `inherit_data` diff --git a/composer.json b/composer.json index cd0c42c..6f0c52b 100644 --- a/composer.json +++ b/composer.json @@ -29,13 +29,17 @@ "doctrine/orm": "^3.5", "friendsofphp/php-cs-fixer": "^3.87", "kubawerlos/php-cs-fixer-custom-fixers": "^3.34", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", "phpunit/phpunit": "^12.3", "rector/rector": "^2.1", "symfony/cache": "^7.3", "symfony/doctrine-bridge": "^7.3", "symfony/validator": "^7.3", - "symfony/var-dumper": "^7.3", - "vimeo/psalm": "^6.13" + "symfony/var-dumper": "^7.3" }, "suggest": { "a2lix/translation-form-bundle": "For translation form" @@ -44,8 +48,8 @@ "cs-fixer": [ "php-cs-fixer fix --verbose" ], - "psalm": [ - "psalm" + "phpstan": [ + "phpstan analyse --memory-limit=2G" ], "phpunit": [ "phpunit" @@ -54,7 +58,8 @@ "config": { "sort-packages": true, "allow-plugins": { - "composer/package-versions-deprecated": true + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true } }, "autoload": { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..7efe5bb --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,20 @@ +parameters: + level: 10 + paths: + - src + - tests + excludePaths: + - src/A2lixAutoFormBundle.php + - tests/Fixtures/* + + # Stricter setup + checkTooWideReturnTypesInProtectedAndPublicMethods: true + checkUninitializedProperties: true + rememberPossiblyImpureFunctionValues: false + checkBenevolentUnionTypes: true + #reportPossiblyNonexistentGeneralArrayOffset: true + reportPossiblyNonexistentConstantArrayOffset: true + reportAlwaysTrueInLastCondition: true + reportAnyTypeWideningInVarTag: true + checkMissingOverrideMethodAttribute: true + checkMissingCallableSignature: true diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 99b05ac..0000000 --- a/psalm.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php index d0c26ed..a6a11f9 100644 --- a/src/A2lixAutoFormBundle.php +++ b/src/A2lixAutoFormBundle.php @@ -21,8 +21,6 @@ final class A2lixAutoFormBundle extends AbstractBundle #[\Override] public function configure(DefinitionConfigurator $definition): void { - /** @psalm-suppress UndefinedMethod */ - /** @psalm-suppress MixedMethodCall */ $definition->rootNode() ->children() ->arrayNode('children_excluded') diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php index 8126dec..9104895 100644 --- a/src/Form/Attribute/AutoTypeCustom.php +++ b/src/Form/Attribute/AutoTypeCustom.php @@ -11,10 +11,10 @@ namespace A2lix\AutoFormBundle\Form\Attribute; -use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; /** - * @psalm-import-type ChildOptions from AutoType + * @phpstan-import-type ChildOptions from AutoTypeBuilder */ #[\Attribute(\Attribute::TARGET_PROPERTY)] final readonly class AutoTypeCustom @@ -22,6 +22,7 @@ /** * @param array $options * @param class-string|null $type + * @param list|null $groups */ public function __construct( private array $options = [], @@ -29,6 +30,7 @@ public function __construct( private ?string $name = null, private ?bool $excluded = null, private ?bool $embedded = null, + private ?array $groups = null, ) {} /** @@ -36,12 +38,14 @@ public function __construct( */ public function getOptions(): array { + /** @var ChildOptions */ return [ ...$this->options, ...(null !== $this->type ? ['child_type' => $this->type] : []), ...(null !== $this->name ? ['child_name' => $this->name] : []), ...(null !== $this->excluded ? ['child_excluded' => $this->excluded] : []), ...(null !== $this->embedded ? ['child_embedded' => $this->embedded] : []), + ...(null !== $this->groups ? ['child_groups' => $this->groups] : []), ]; } } diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 5d43ffc..f362374 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -21,16 +21,33 @@ use Symfony\Component\TypeInfo\TypeIdentifier; /** - * @psalm-import-type FormOptionsDefaults from AutoType + * @phpstan-type ChildOptions array{ + * child_type?: class-string, + * child_name?: string, + * child_excluded?: bool, + * child_embedded?: bool, + * child_groups?: list, + * ... + * } + * @phpstan-type ChildBuilderCallable callable(FormBuilderInterface $builder, ?array $propAttributeOptions): FormBuilderInterface + * @phpstan-type FormBuilderCallable callable(FormBuilderInterface $builder, list $classProperties): void + * @phpstan-type FormOptionsDefaults array{ + * children: array, + * children_excluded: list|"*", + * children_embedded: list|"*", + * children_groups: list|null, + * builder: FormBuilderCallable|null, + * } */ -final class AutoTypeBuilder +final readonly class AutoTypeBuilder { public function __construct( - private readonly PropertyInfoExtractorInterface $propertyInfoExtractor, + private PropertyInfoExtractorInterface $propertyInfoExtractor, ) {} /** - * @param FormOptionsDefaults $formOptions + * @param FormBuilderInterface $builder + * @param FormOptionsDefaults $formOptions */ public function buildChildren(FormBuilderInterface $builder, array $formOptions): void { @@ -43,8 +60,10 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) $refClass = new \ReflectionClass($dataClass); $allChildrenExcluded = '*' === $formOptions['children_excluded']; $allChildrenEmbedded = '*' === $formOptions['children_embedded']; + $childrenGroups = $formOptions['children_groups'] ?? ['Default']; $formLevel = $this->getFormLevel($form); + /** @var list $classProperties */ foreach ($classProperties as $classProperty) { // Due to issue with DateTimeImmutable PHP8.4 if (!$refClass->hasProperty($classProperty)) { @@ -57,6 +76,7 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) $propAttributeOptions = ($refProperty->getAttributes(AutoTypeCustom::class)[0] ?? null) ?->newInstance()?->getOptions() ?? [] ; + // Custom name? if (null !== ($propAttributeOptions['child_name'] ?? null)) { $propAttributeOptions['property_path'] = $classProperty; @@ -64,44 +84,40 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) // FORM.children[PROP] callable? Add early if (\is_callable($propFormOptions)) { - /** @var FormBuilderInterface */ $childBuilder = ($propFormOptions)($builder, $propAttributeOptions); $this->addChild($builder, $childBuilder); unset($formOptions['children'][$classProperty]); continue; } - // FORM.children[PROP].child_excluded? Continue early - /** @psalm-suppress RiskyTruthyFalsyComparison */ - if ($propFormOptions['child_excluded'] ?? false) { + /** @var ChildOptions */ + $childOptions = [ + ...$propAttributeOptions, + ...($propFormOptions ?? []), + ]; + + // @phpstan-ignore argument.type + $formChildExcluded = ((null === $propFormOptions) && ($allChildrenExcluded || \in_array($classProperty, $formOptions['children_excluded'], true))) + || ($childOptions['child_excluded'] ?? false); + + // Excluded child? Continue early + if ($formChildExcluded) { unset($formOptions['children'][$classProperty]); continue; } - if (null === $propFormOptions) { - /** @psalm-suppress RiskyTruthyFalsyComparison */ - /** @var list $formOptions['children_excluded'] */ - $formChildExcluded = $allChildrenExcluded || \in_array($classProperty, $formOptions['children_excluded'], true) - || ($propAttributeOptions['child_excluded'] ?? false); - - // Excluded at form or attribute level? Continue early - if ($formChildExcluded) { - unset($formOptions['children'][$classProperty]); - continue; - } + // Invalid matching group? Continue early + $childGroups = $childOptions['child_groups'] ?? ['Default']; + if ([] === array_intersect($childrenGroups, $childGroups)) { + unset($formOptions['children'][$classProperty]); + continue; } - $childOptions = [ - ...$propAttributeOptions, - ...($propFormOptions ?? []), - ]; - // PropertyInfo? Enrich childOptions if (null !== $propTypeInfo = $this->propertyInfoExtractor->getType($dataClass, $classProperty)) { - /** @psalm-suppress RiskyTruthyFalsyComparison */ - /** @var list $formOptions['children_embedded'] */ + // @phpstan-ignore argument.type $formChildEmbedded = $allChildrenEmbedded || \in_array($classProperty, $formOptions['children_embedded'], true) - || ($propAttributeOptions['child_embedded'] ?? false); + || ($childOptions['child_embedded'] ?? false); if ($formChildEmbedded) { $childOptions = $this->updateChildOptions($childOptions, $propTypeInfo, $formLevel); @@ -116,13 +132,11 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) foreach ($formOptions['children'] as $childProperty => $childOptions) { // FORM.children[PROP] callable? Continue early if (\is_callable($childOptions)) { - /** @var FormBuilderInterface */ - $childBuilder = ($childOptions)($builder); + $childBuilder = ($childOptions)($builder, null); $this->addChild($builder, $childBuilder); continue; } - /** @var string $childProperty */ $this->addChild($builder, $childProperty, $childOptions); } @@ -132,6 +146,11 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) } } + /** + * @param FormBuilderInterface $builder + * @param string|FormBuilderInterface $child + * @param ChildOptions $options + */ private function addChild(FormBuilderInterface $builder, string|FormBuilderInterface $child, array $options = []): void { if ($child instanceof FormBuilderInterface) { @@ -147,15 +166,20 @@ private function addChild(FormBuilderInterface $builder, string|FormBuilderInter 'child_name' => $child, 'child_type' => null, ]; - unset($options['child_name'], $options['child_type'], $options['child_excluded'], $options['child_embedded']); + unset( + $options['child_name'], + $options['child_type'], + $options['child_excluded'], + $options['child_embedded'], + $options['child_groups'], + ); - /** @var string $name */ - /** @var class-string|null $type */ - /** @var array $options */ $builder->add($name, $type, $options); } /** + * @param FormInterface $form + * * @return class-string */ private function getDataClass(FormInterface $form): string @@ -170,6 +194,11 @@ private function getDataClass(FormInterface $form): string throw new \RuntimeException('Unable to get dataClass'); } + /** + * @param ChildOptions $baseChildOptions + * + * @return ChildOptions + */ private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeInfo, int $formLevel): array { // TypeInfo matching native FormType? Abort, guessers are enough @@ -193,12 +222,12 @@ private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeI // Object? if ($collValueType instanceof TypeInfo\ObjectType) { - /** @psalm-suppress InvalidOperand */ return [ 'entry_type' => AutoType::class, ...$baseCollOptions, 'entry_options' => [ 'data_class' => $collValueType->getClassName(), + // @phpstan-ignore nullCoalesce.offset ...($baseCollOptions['entry_options'] ?? []), ], ]; @@ -209,7 +238,7 @@ private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeI } // Embeddable object - /** @var TypeInfo\ObjectType */ + /** @var TypeInfo\ObjectType */ $innerType = $propTypeInfo instanceof TypeInfo\NullableType ? $propTypeInfo->getWrappedType() : $propTypeInfo; return [ @@ -247,6 +276,9 @@ private static function isTypeInfoWithMatchingNativeFormType(TypeInfo $propTypeI ); } + /** + * @param FormInterface $form + */ private function getFormLevel(FormInterface $form): int { if ($form->isRoot()) { diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index eafe006..b84c7c9 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -18,24 +18,15 @@ use Symfony\Component\OptionsResolver\OptionsResolver; /** - * @psalm-type ChildOptions = array{ - * child_type?: class-string, - * child_name?: string, - * child_excluded?: bool, - * child_embedded?: bool, - * ... - * } - * @psalm-type ChildBuilderCallable = callable(FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface - * @psalm-type FormBuilderCallable = callable(FormBuilderInterface $builder, string[] $classProperties): void - * @psalm-type FormOptionsDefaults = array{ - * children: array|[], - * children_excluded: list|"*", - * children_embedded: list|"*", - * builder: FormBuilderCallable|null, - * } + * @phpstan-import-type FormOptionsDefaults from AutoTypeBuilder + * + * @extends AbstractType */ final class AutoType extends AbstractType { + /** + * @param list $globalExcludedChildren + */ public function __construct( private readonly AutoTypeBuilder $autoTypeBuilder, private readonly array $globalExcludedChildren = [], @@ -44,7 +35,7 @@ public function __construct( #[\Override] public function buildForm(FormBuilderInterface $builder, array $options): void { - /** @psalm-suppress MixedArgumentTypeCoercion */ + /** @var FormOptionsDefaults $options */ $this->autoTypeBuilder->buildChildren($builder, $options); } @@ -55,10 +46,14 @@ public function configureOptions(OptionsResolver $resolver): void 'children' => [], 'children_excluded' => $this->globalExcludedChildren, 'children_embedded' => [], + 'children_groups' => null, 'builder' => null, ]); - $resolver->setAllowedTypes('builder', ['null', 'callable']); + $resolver->setAllowedTypes('children_excluded', 'string[]|string'); + $resolver->setAllowedTypes('children_embedded', 'string[]|string'); + $resolver->setAllowedTypes('children_groups', 'string[]|null'); + $resolver->setAllowedTypes('builder', 'callable|null'); $resolver->setInfo('builder', 'A callable that accepts two arguments (FormBuilderInterface $builder, string[] $classProperties). It should not return anything.'); $resolver->setNormalizer('data_class', static function (Options $options, ?string $value): string { @@ -68,5 +63,10 @@ public function configureOptions(OptionsResolver $resolver): void return $value; }); + + $resolver->setDefault('validation_groups', static function (Options $options): ?array { + /** @var list|null */ + return $options['children_groups']; + }); } } diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php index 941c2b2..0961581 100644 --- a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -21,10 +21,10 @@ use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; -final class TypeInfoTypeGuesser implements FormTypeGuesserInterface +final readonly class TypeInfoTypeGuesser implements FormTypeGuesserInterface { public function __construct( - private readonly TypeResolverInterface $typeResolver, + private TypeResolverInterface $typeResolver, ) {} #[\Override] @@ -38,9 +38,10 @@ public function guessType(string $class, string $property): ?TypeGuess // FormTypes handling 'multiple' option if ($typeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { /** @var TypeInfo\CollectionType $typeInfo */ + // @phpstan-ignore missingType.generics $collValueType = $typeInfo->getCollectionValueType(); - /** @var TypeInfo\ObjectType $collValueType */ + /** @var TypeInfo\ObjectType $collValueType */ return match (true) { $collValueType->isIdentifiedBy(\UnitEnum::class) => new TypeGuess(CoreType\EnumType::class, ['class' => $collValueType->getClassName(), 'multiple' => true], Guess::HIGH_CONFIDENCE), $collValueType->isIdentifiedBy(\DateTimeZone::class) => new TypeGuess(CoreType\TimezoneType::class, ['input' => 'datetimezone', 'multiple' => true], Guess::HIGH_CONFIDENCE), @@ -50,7 +51,7 @@ public function guessType(string $class, string $property): ?TypeGuess if ($typeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) { if ($typeInfo->isIdentifiedBy(\UnitEnum::class)) { - /** @var TypeInfo\ObjectType */ + /** @var TypeInfo\ObjectType */ $innerType = $typeInfo instanceof TypeInfo\NullableType ? $typeInfo->getWrappedType() : $typeInfo; return new TypeGuess(CoreType\EnumType::class, ['class' => $innerType->getClassName()], Guess::HIGH_CONFIDENCE); @@ -107,13 +108,13 @@ private function getTypeInfo(string $class, string $property): ?TypeInfo { try { $refProperty = new \ReflectionProperty($class, $property); - } catch (\ReflectionException $e) { + } catch (\ReflectionException) { return null; } try { return $this->typeResolver->resolve($refProperty); - } catch (UnsupportedException $e) { + } catch (UnsupportedException) { return null; } } diff --git a/tests/Fixtures/Dto/Product1.php b/tests/Fixtures/Dto/Product1.php index e822309..6e6df83 100644 --- a/tests/Fixtures/Dto/Product1.php +++ b/tests/Fixtures/Dto/Product1.php @@ -36,8 +36,11 @@ public function __construct( public ?Collection $mediaColl = null, public readonly ?ProductStatus $status = null, public readonly ?array $statusList = null, + #[AutoTypeCustom(groups: ['Default', 'validity'])] public readonly ?\DateTimeImmutable $validityStartAt = null, + #[AutoTypeCustom(groups: ['Default', 'validity'])] public readonly ?\DateTimeImmutable $validityEndAt = null, + // @phpstan-ignore property.onlyWritten private ?\DateTimeImmutable $createdAt = null, ) { $this->mediaColl ??= new ArrayCollection(); diff --git a/tests/Fixtures/Entity/Product1.php b/tests/Fixtures/Entity/Product1.php index 2ac96ef..9f92319 100644 --- a/tests/Fixtures/Entity/Product1.php +++ b/tests/Fixtures/Entity/Product1.php @@ -15,6 +15,7 @@ use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Form\Extension\Core\Type as CoreType; @@ -37,34 +38,34 @@ class Product1 #[ORM\Column] public int $code; + /** @var list */ #[ORM\Column] public array $tags = []; #[ORM\ManyToOne(targetEntity: Media1::class)] public ?Media1 $mediaMain = null; - /** - * @var Collection - */ + /** @var Collection */ #[ORM\OneToMany(targetEntity: Media1::class, mappedBy: 'product', cascade: ['all'], orphanRemoval: true)] public Collection $mediaColl; #[ORM\Column(enumType: ProductStatus::class)] public ProductStatus $status; - /** - * @var list - */ - #[ORM\Column(type: 'simple_array', enumType: ProductStatus::class)] + /** @var list */ + #[ORM\Column(type: Types::SIMPLE_ARRAY, enumType: ProductStatus::class)] public array $statusList; #[ORM\Column] + #[AutoTypeCustom(groups: ['Default', 'validity'])] public \DateTimeImmutable $validityStartAt; #[ORM\Column] + #[AutoTypeCustom(groups: ['Default', 'validity'])] public \DateTimeImmutable $validityEndAt; #[ORM\Column] + // @phpstan-ignore property.onlyWritten private \DateTimeImmutable $createdAt; public function __construct() diff --git a/tests/Fixtures/Type/ValidityRangeType.php b/tests/Fixtures/Type/ValidityRangeType.php index 4e0c264..16915ef 100644 --- a/tests/Fixtures/Type/ValidityRangeType.php +++ b/tests/Fixtures/Type/ValidityRangeType.php @@ -16,6 +16,9 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +/** + * @extends AbstractType + */ class ValidityRangeType extends AbstractType { #[\Override] diff --git a/tests/Form/DataProviderDto.php b/tests/Form/DataProviderDto.php index 27f19b1..6fca2d6 100644 --- a/tests/Form/DataProviderDto.php +++ b/tests/Form/DataProviderDto.php @@ -140,11 +140,16 @@ public static function provideScenarioCases(): iterable ), ]; - yield 'Dto - Product1 with children_embedded = [mediaColl]' => [ + yield 'Dto - Product1 with children_embedded & child_embedded' => [ new TestScenario( obj: new Product1(), formOptions: [ 'children_embedded' => ['mediaColl'], + 'children' => [ + 'tags' => [ + 'child_embedded' => true, + ], + ], ], expectedForm: [ 'title' => [ @@ -154,7 +159,11 @@ public static function provideScenarioCases(): iterable 'expected_type' => CoreType\IntegerType::class, ], 'tags' => [ - 'expected_type' => CoreType\TextType::class, + 'expected_type' => CoreType\CollectionType::class, + 'entry_type' => CoreType\TextType::class, + 'entry_options' => [ + 'block_name' => 'entry', + ], ], 'mediaMain' => [ 'expected_type' => CoreType\TextType::class, @@ -204,7 +213,7 @@ public static function provideScenarioCases(): iterable ), ]; - yield 'Dto - Product1 with children_excluded = *, custom selection with overrides' => [ + yield 'Dto - Product1 with children_excluded = *, custom overrides' => [ new TestScenario( obj: new Product1(), formOptions: [ @@ -242,6 +251,72 @@ public static function provideScenarioCases(): iterable ), ]; + yield 'Dto - Product1 with children_excluded & child_excluded' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => ['tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'], + 'children' => [ + 'code' => [ + 'child_excluded' => true, + ], + 'description' => [ + 'child_excluded' => true, + ], + ], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + ], + ), + ]; + + yield 'Dto - Product1 with children_groups' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_groups' => ['unknownGrp'], + ], + expectedForm: [ + ], + ), + ]; + + yield 'Dto - Product1 with children_groups & child_groups' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_groups' => ['onTheFlyGrp', 'validity'], + 'children' => [ + 'title' => [ + 'child_groups' => ['onTheFlyGrp'], + ], + 'code' => [ + 'child_groups' => ['onTheFlyGrp', 'validity'], + ], + ], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + ], + 'validityStartAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + ], + ), + ]; + yield 'Dto - Product1 with children & builder callables' => [ new TestScenario( obj: new Product1(), @@ -261,7 +336,6 @@ public static function provideScenarioCases(): iterable 'mapped' => false, ], ], - /** @psalm-suppress UnusedClosureParam */ 'builder' => static function (FormBuilderInterface $builder, array $classProperties): void { $builder->add('save', CoreType\SubmitType::class); }, diff --git a/tests/Form/DataProviderEntity.php b/tests/Form/DataProviderEntity.php index 849d9a5..d4a44be 100644 --- a/tests/Form/DataProviderEntity.php +++ b/tests/Form/DataProviderEntity.php @@ -140,11 +140,16 @@ public static function provideScenarioCases(): iterable ), ]; - yield 'Entity - Product1 with children_embedded = [mediaColl]' => [ + yield 'Entity - Product1 with children_embedded & child_embedded' => [ new TestScenario( obj: new Product1(), formOptions: [ 'children_embedded' => ['mediaColl'], + 'children' => [ + 'tags' => [ + 'child_embedded' => true, + ], + ], ], expectedForm: [ 'title' => [ @@ -154,7 +159,11 @@ public static function provideScenarioCases(): iterable 'expected_type' => CoreType\IntegerType::class, ], 'tags' => [ - 'expected_type' => CoreType\TextType::class, + 'expected_type' => CoreType\CollectionType::class, + 'entry_type' => CoreType\TextType::class, + 'entry_options' => [ + 'block_name' => 'entry', + ], ], 'mediaMain' => [ 'expected_type' => CoreType\TextType::class, @@ -203,7 +212,7 @@ public static function provideScenarioCases(): iterable ), ]; - yield 'Entity - Product1 with children_excluded = *, custom selection with overrides' => [ + yield 'Entity - Product1 with children_excluded = *, custom overrides' => [ new TestScenario( obj: new Product1(), formOptions: [ @@ -241,6 +250,72 @@ public static function provideScenarioCases(): iterable ), ]; + yield 'Entity - Product1 with children_excluded & child_excluded' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_excluded' => ['tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'], + 'children' => [ + 'code' => [ + 'child_excluded' => true, + ], + 'description' => [ + 'child_excluded' => true, + ], + ], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + ], + ), + ]; + + yield 'Entity - Product1 with children_groups' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_groups' => ['unknownGrp'], + ], + expectedForm: [ + ], + ), + ]; + + yield 'Entity - Product1 with children_groups & child_groups' => [ + new TestScenario( + obj: new Product1(), + formOptions: [ + 'children_groups' => ['onTheFlyGrp', 'validity'], + 'children' => [ + 'title' => [ + 'child_groups' => ['onTheFlyGrp'], + ], + 'code' => [ + 'child_groups' => ['onTheFlyGrp', 'validity'], + ], + ], + ], + expectedForm: [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'code' => [ + 'expected_type' => CoreType\IntegerType::class, + ], + 'validityStartAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + 'validityEndAt' => [ + 'expected_type' => CoreType\DateTimeType::class, + 'input' => 'datetime_immutable', + ], + ], + ), + ]; + yield 'Entity - Product1 with children & builder callables' => [ new TestScenario( obj: new Product1(), @@ -260,7 +335,6 @@ public static function provideScenarioCases(): iterable 'mapped' => false, ], ], - /** @psalm-suppress UnusedClosureParam */ 'builder' => static function (FormBuilderInterface $builder, array $classProperties): void { $builder->add('save', CoreType\SubmitType::class); }, diff --git a/tests/Form/TestScenario.php b/tests/Form/TestScenario.php index 6080e6a..8c700e6 100644 --- a/tests/Form/TestScenario.php +++ b/tests/Form/TestScenario.php @@ -12,20 +12,21 @@ namespace A2lix\AutoFormBundle\Tests\Form; /** - * @psalm-type ExpectedChildren = array */ -final class TestScenario +final readonly class TestScenario { /** - * @param ExpectedChildren $expectedForm + * @param array $formOptions + * @param ExpectedChildren $expectedForm */ public function __construct( - public readonly ?object $obj, - public readonly array $formOptions = [], - public readonly array $expectedForm = [], + public ?object $obj, + public array $formOptions = [], + public array $expectedForm = [], ) {} } diff --git a/tests/Form/Type/AutoTypeTest.php b/tests/Form/Type/AutoTypeTest.php index 87b7d89..9a0dd5a 100755 --- a/tests/Form/Type/AutoTypeTest.php +++ b/tests/Form/Type/AutoTypeTest.php @@ -11,23 +11,27 @@ namespace A2lix\AutoFormBundle\Tests\Form\Type; +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; +use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder; use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\AutoFormBundle\Form\TypeGuesser\TypeInfoTypeGuesser; use A2lix\AutoFormBundle\Tests\Form\DataProviderDto; use A2lix\AutoFormBundle\Tests\Form\DataProviderEntity; use A2lix\AutoFormBundle\Tests\Form\TestScenario; use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; -use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProviderExternal; use Symfony\Component\Form\FormInterface; /** * @internal * - * @psalm-suppress PropertyNotSetInConstructor - * - * @psalm-import-type ExpectedChildren from TestScenario + * @phpstan-import-type ExpectedChildren from TestScenario */ -#[CoversNothing] +#[CoversClass(AutoType::class)] +#[CoversClass(AutoTypeBuilder::class)] +#[CoversClass(AutoTypeCustom::class)] +#[CoversClass(TypeInfoTypeGuesser::class)] final class AutoTypeTest extends TypeTestCase { #[DataProviderExternal(DataProviderDto::class, 'provideScenarioCases')] @@ -43,8 +47,8 @@ public function testScenario(TestScenario $testScenario): void } /** - * @param ExpectedChildren $expectedForm - * @param array $formChildren + * @param ExpectedChildren $expectedForm + * @param array> $formChildren */ private static function assertFormChildren(array $expectedForm, array $formChildren, string $parentPath = ''): void { @@ -59,16 +63,15 @@ private static function assertFormChildren(array $expectedForm, array $formChild self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath)); } - /** @var ExpectedChildren|null $expectedChildOptions['expected_children'] */ if (null !== $expectedChildren = $expectedChildOptions['expected_children'] ?? null) { + // @phpstan-ignore argument.type self::assertFormChildren($expectedChildren, $child->all(), $childPath); } unset($expectedChildOptions['expected_type'], $expectedChildOptions['expected_children']); $actualOptions = $child->getConfig()->getOptions(); - /** @psalm-suppress RedundantCondition */ - /** @psalm-suppress TypeDoesNotContainNull */ + // @phpstan-ignore nullCoalesce.variable, staticMethod.alreadyNarrowedType self::assertSame($expectedChildOptions, array_intersect_key($actualOptions, $expectedChildOptions ?? []), \sprintf('Options of "%s"', $childPath)); } } diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 216177a..c613557 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -44,9 +44,9 @@ abstract class TypeTestCase extends BaseTypeTestCase public static function setUpBeforeClass(): void { VarDumper::setHandler(static function (mixed $var): void { - /** @psalm-suppress PossiblyInvalidArgument */ new HtmlDumper()->dump( new VarCloner()->cloneVar($var), + // @phpstan-ignore argument.type @fopen(__DIR__.'/../../dump.html', 'a') ); }); From f077ccd0091eea50c07c0b9bae85cb14d6f1578d Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 27 Nov 2025 20:42:40 +0100 Subject: [PATCH 23/35] Progress --- .devcontainer/Dockerfile | 18 +++--- src/A2lixAutoFormBundle.php | 17 +++++- src/Form/Attribute/AutoTypeCustom.php | 2 + src/Form/Builder/AutoTypeBuilder.php | 59 ++++++++++++++++---- src/Form/Type/AutoType.php | 33 +++++++++-- src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 1 + 6 files changed, 104 insertions(+), 26 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index cb72d12..b2884bf 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,20 +1,20 @@ FROM php:8.4-cli-trixie RUN apt-get update && apt-get install -y --no-install-recommends \ - file \ - git \ + file \ + git \ openssh-client \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ RUN set -eux; \ - install-php-extensions \ - @composer \ - apcu \ - opcache \ - xdebug \ - ; + install-php-extensions \ + @composer \ + apcu \ + opcache \ + xdebug \ + ; RUN useradd -m vscode diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php index a6a11f9..c325715 100644 --- a/src/A2lixAutoFormBundle.php +++ b/src/A2lixAutoFormBundle.php @@ -12,11 +12,12 @@ namespace A2lix\AutoFormBundle; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; -final class A2lixAutoFormBundle extends AbstractBundle +final class A2lixAutoFormBundle extends AbstractBundle implements CompilerPassInterface { #[\Override] public function configure(DefinitionConfigurator $definition): void @@ -42,4 +43,18 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ->arg('$globalExcludedChildren', $config['children_excluded']) ; } + + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass($this); + } + + public function process(ContainerBuilder $container): void + { + if ($container->hasExtension('a2lix_translation_form')) { + $container->getDefinition('a2lix_auto_form.form.type.auto_type') + ->setArgument('$globalTranslatedChildren', true) + ; + } + } } diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php index 9104895..c2dcb1d 100644 --- a/src/Form/Attribute/AutoTypeCustom.php +++ b/src/Form/Attribute/AutoTypeCustom.php @@ -30,6 +30,7 @@ public function __construct( private ?string $name = null, private ?bool $excluded = null, private ?bool $embedded = null, + private ?bool $translated = null, private ?array $groups = null, ) {} @@ -45,6 +46,7 @@ public function getOptions(): array ...(null !== $this->name ? ['child_name' => $this->name] : []), ...(null !== $this->excluded ? ['child_excluded' => $this->excluded] : []), ...(null !== $this->embedded ? ['child_embedded' => $this->embedded] : []), + ...(null !== $this->embedded ? ['child_translated' => $this->translated] : []), ...(null !== $this->groups ? ['child_groups' => $this->groups] : []), ]; } diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index f362374..4208ec9 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -13,6 +13,7 @@ use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; use A2lix\AutoFormBundle\Form\Type\AutoType; +use Doctrine\Common\Collections\ArrayCollection; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; @@ -26,6 +27,7 @@ * child_name?: string, * child_excluded?: bool, * child_embedded?: bool, + * child_translated?: bool, * child_groups?: list, * ... * } @@ -35,6 +37,7 @@ * children: array, * children_excluded: list|"*", * children_embedded: list|"*", + * children_translated: bool, * children_groups: list|null, * builder: FormBuilderCallable|null, * } @@ -52,6 +55,7 @@ public function __construct( public function buildChildren(FormBuilderInterface $builder, array $formOptions): void { $dataClass = $this->getDataClass($form = $builder->getForm()); + dump($dataClass); if (null === $classProperties = $this->propertyInfoExtractor->getProperties($dataClass)) { throw new \RuntimeException(\sprintf('Unable to extract properties of "%s".', $dataClass)); @@ -61,7 +65,7 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) $allChildrenExcluded = '*' === $formOptions['children_excluded']; $allChildrenEmbedded = '*' === $formOptions['children_embedded']; $childrenGroups = $formOptions['children_groups'] ?? ['Default']; - $formLevel = $this->getFormLevel($form); + $formDepth = $this->getFormDepth($form); /** @var list $classProperties */ foreach ($classProperties as $classProperty) { @@ -116,12 +120,17 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) // PropertyInfo? Enrich childOptions if (null !== $propTypeInfo = $this->propertyInfoExtractor->getType($dataClass, $classProperty)) { // @phpstan-ignore argument.type + $formChildTranslated = ($formOptions['children_translated'] || ($childOptions['child_translated'] ?? false)) + && ('translations' === $classProperty); + // @phpstan-ignore argument.type $formChildEmbedded = $allChildrenEmbedded || \in_array($classProperty, $formOptions['children_embedded'], true) || ($childOptions['child_embedded'] ?? false); - if ($formChildEmbedded) { - $childOptions = $this->updateChildOptions($childOptions, $propTypeInfo, $formLevel); - } + $childOptions = match (true) { + $formChildTranslated => $this->updateTranslatedChildOptions($childOptions, $propTypeInfo, $refProperty), + $formChildEmbedded => $this->updateEmbeddedChildOptions($childOptions, $propTypeInfo, $refProperty, $formDepth), + default => $childOptions, + }; } $this->addChild($builder, $classProperty, $childOptions); @@ -171,6 +180,7 @@ private function addChild(FormBuilderInterface $builder, string|FormBuilderInter $options['child_type'], $options['child_excluded'], $options['child_embedded'], + $options['child_translated'], $options['child_groups'], ); @@ -193,14 +203,41 @@ private function getDataClass(FormInterface $form): string throw new \RuntimeException('Unable to get dataClass'); } + /** + * @param ChildOptions $baseChildOptions + * + * @return ChildOptions + */ + private function updateTranslatedChildOptions( + array $baseChildOptions, + TypeInfo $propTypeInfo, + \ReflectionProperty $refProperty, + ): array { + if (!$propTypeInfo instanceof TypeInfo\CollectionType) { + return []; + } + + dump($refProperty); + + return [ + 'child_type' => 'A2lix\TranslationFormBundle\Form\Type\TranslationsType', + 'translation_class' => $propTypeInfo->getCollectionValueType()->getClassName(), + 'required' => $propTypeInfo->isNullable(), + ...$baseChildOptions, + ]; + } /** * @param ChildOptions $baseChildOptions * * @return ChildOptions */ - private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeInfo, int $formLevel): array - { + private function updateEmbeddedChildOptions( + array $baseChildOptions, + TypeInfo $propTypeInfo, + \ReflectionProperty $refProperty, + int $formDepth + ): array { // TypeInfo matching native FormType? Abort, guessers are enough if (self::isTypeInfoWithMatchingNativeFormType($propTypeInfo)) { return $baseChildOptions; @@ -214,7 +251,7 @@ private function updateChildOptions(array $baseChildOptions, TypeInfo $propTypeI 'allow_delete' => true, 'delete_empty' => true, 'by_reference' => false, - 'prototype_name' => '__name'.$formLevel.'__', + 'prototype_name' => '__name'.$formDepth.'__', ...$baseChildOptions, ]; @@ -279,18 +316,18 @@ private static function isTypeInfoWithMatchingNativeFormType(TypeInfo $propTypeI /** * @param FormInterface $form */ - private function getFormLevel(FormInterface $form): int + private function getFormDepth(FormInterface $form): int { if ($form->isRoot()) { return 0; } - $level = 0; + $depth = 0; while (null !== $formParent = $form->getParent()) { $form = $formParent; - ++$level; + ++$depth; } - return $level; + return $depth; } } diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index b84c7c9..b6fd453 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -26,10 +26,13 @@ final class AutoType extends AbstractType { /** * @param list $globalExcludedChildren + * @param list $globalEmbeddedChildren */ public function __construct( private readonly AutoTypeBuilder $autoTypeBuilder, private readonly array $globalExcludedChildren = [], + private readonly array $globalEmbeddedChildren = [], + private readonly bool $globalTranslatedChildren = false, ) {} #[\Override] @@ -45,17 +48,38 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setDefaults([ 'children' => [], 'children_excluded' => $this->globalExcludedChildren, - 'children_embedded' => [], + 'children_embedded' => $this->globalEmbeddedChildren, + 'children_translated' => $this->globalTranslatedChildren, 'children_groups' => null, 'builder' => null, ]); - $resolver->setAllowedTypes('children_excluded', 'string[]|string'); - $resolver->setAllowedTypes('children_embedded', 'string[]|string'); + $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable'); + $resolver->setInfo('children_excluded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); + $resolver->addNormalizer('children_excluded', static function (Options $options, mixed $value): mixed { + if (is_callable($value)) { + return ($value)($options['children_excluded']); + } + + return $value; + }); + + $resolver->setAllowedTypes('children_embedded', 'string[]|string|callable'); + $resolver->setInfo('children_embedded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); + $resolver->addNormalizer('children_embedded', static function (Options $options, mixed $value): mixed { + if (is_callable($value)) { + return ($value)($options['children_embedded']); + } + + return $value; + }); + + $resolver->setAllowedTypes('children_translated', 'bool'); $resolver->setAllowedTypes('children_groups', 'string[]|null'); $resolver->setAllowedTypes('builder', 'callable|null'); - $resolver->setInfo('builder', 'A callable that accepts two arguments (FormBuilderInterface $builder, string[] $classProperties). It should not return anything.'); + $resolver->setInfo('builder', 'A callable (FormBuilderInterface $builder, string[] $classProperties): void'); + // Others defaults FormType:class options $resolver->setNormalizer('data_class', static function (Options $options, ?string $value): string { if (null === $value) { throw new \RuntimeException('Missing "data_class" option of "AutoType".'); @@ -63,7 +87,6 @@ public function configureOptions(OptionsResolver $resolver): void return $value; }); - $resolver->setDefault('validation_groups', static function (Options $options): ?array { /** @var list|null */ return $options['children_groups']; diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php index 0961581..38f05e6 100644 --- a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -37,6 +37,7 @@ public function guessType(string $class, string $property): ?TypeGuess // FormTypes handling 'multiple' option if ($typeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { + dump($typeInfo); /** @var TypeInfo\CollectionType $typeInfo */ // @phpstan-ignore missingType.generics $collValueType = $typeInfo->getCollectionValueType(); From 5ddcb5bd655e17dc1a42eed6585e09c8a79b9542 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:50:59 +0100 Subject: [PATCH 24/35] Progress --- package.json | 9 +++++ src/A2lixAutoFormBundle.php | 2 +- src/Form/Attribute/AutoTypeCustom.php | 2 -- src/Form/Builder/AutoTypeBuilder.php | 50 ++++++++++++++++----------- src/Form/Type/AutoType.php | 9 +++-- 5 files changed, 46 insertions(+), 26 deletions(-) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..e5b2b7b --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "@a2lix/auto-form-bundle", + "version": "1.0.0", + "symfony": { + "importmap": { + "@a2lix/symfony-collection": "^0.6" + } + } +} diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php index c325715..ea37f10 100644 --- a/src/A2lixAutoFormBundle.php +++ b/src/A2lixAutoFormBundle.php @@ -53,7 +53,7 @@ public function process(ContainerBuilder $container): void { if ($container->hasExtension('a2lix_translation_form')) { $container->getDefinition('a2lix_auto_form.form.type.auto_type') - ->setArgument('$globalTranslatedChildren', true) + ->setArgument('$handleTranslationTypes', true) ; } } diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php index c2dcb1d..9104895 100644 --- a/src/Form/Attribute/AutoTypeCustom.php +++ b/src/Form/Attribute/AutoTypeCustom.php @@ -30,7 +30,6 @@ public function __construct( private ?string $name = null, private ?bool $excluded = null, private ?bool $embedded = null, - private ?bool $translated = null, private ?array $groups = null, ) {} @@ -46,7 +45,6 @@ public function getOptions(): array ...(null !== $this->name ? ['child_name' => $this->name] : []), ...(null !== $this->excluded ? ['child_excluded' => $this->excluded] : []), ...(null !== $this->embedded ? ['child_embedded' => $this->embedded] : []), - ...(null !== $this->embedded ? ['child_translated' => $this->translated] : []), ...(null !== $this->groups ? ['child_groups' => $this->groups] : []), ]; } diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 4208ec9..070a1db 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -27,7 +27,6 @@ * child_name?: string, * child_excluded?: bool, * child_embedded?: bool, - * child_translated?: bool, * child_groups?: list, * ... * } @@ -37,9 +36,10 @@ * children: array, * children_excluded: list|"*", * children_embedded: list|"*", - * children_translated: bool, * children_groups: list|null, * builder: FormBuilderCallable|null, + * handle_translation_types: bool, + * gedmo_only: bool, * } */ final readonly class AutoTypeBuilder @@ -55,7 +55,6 @@ public function __construct( public function buildChildren(FormBuilderInterface $builder, array $formOptions): void { $dataClass = $this->getDataClass($form = $builder->getForm()); - dump($dataClass); if (null === $classProperties = $this->propertyInfoExtractor->getProperties($dataClass)) { throw new \RuntimeException(\sprintf('Unable to extract properties of "%s".', $dataClass)); @@ -65,6 +64,8 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) $allChildrenExcluded = '*' === $formOptions['children_excluded']; $allChildrenEmbedded = '*' === $formOptions['children_embedded']; $childrenGroups = $formOptions['children_groups'] ?? ['Default']; + $handleTranslationTypes = $formOptions['handle_translation_types']; + $gedmoTranslatable = $handleTranslationTypes && (null !== ($refClass->getAttributes('Gedmo\Mapping\Annotation\TranslationEntity')[0] ?? null)); $formDepth = $this->getFormDepth($form); /** @var list $classProperties */ @@ -74,9 +75,22 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) continue; } - $propFormOptions = $formOptions['children'][$classProperty] ?? null; - $refProperty = $refClass->getProperty($classProperty); + + // Gedmo Translatable property? Possible continue early + if ($gedmoTranslatable) { + $hasGedmoAttribute = null !== ($refProperty->getAttributes('Gedmo\Mapping\Annotation\Translatable')[0] ?? null); + + if ( + ($formOptions['gedmo_only'] && !$hasGedmoAttribute) + || (!$formOptions['gedmo_only'] && $hasGedmoAttribute) + ) { + unset($formOptions['children'][$classProperty]); + continue; + } + } + + $propFormOptions = $formOptions['children'][$classProperty] ?? null; $propAttributeOptions = ($refProperty->getAttributes(AutoTypeCustom::class)[0] ?? null) ?->newInstance()?->getOptions() ?? [] ; @@ -119,15 +133,13 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) // PropertyInfo? Enrich childOptions if (null !== $propTypeInfo = $this->propertyInfoExtractor->getType($dataClass, $classProperty)) { - // @phpstan-ignore argument.type - $formChildTranslated = ($formOptions['children_translated'] || ($childOptions['child_translated'] ?? false)) - && ('translations' === $classProperty); + $formChildTranslations = $handleTranslationTypes && ('translations' === $classProperty); // @phpstan-ignore argument.type $formChildEmbedded = $allChildrenEmbedded || \in_array($classProperty, $formOptions['children_embedded'], true) || ($childOptions['child_embedded'] ?? false); $childOptions = match (true) { - $formChildTranslated => $this->updateTranslatedChildOptions($childOptions, $propTypeInfo, $refProperty), + $formChildTranslations => $this->updateTranslationsChildOptions($childOptions, $dataClass, $gedmoTranslatable), $formChildEmbedded => $this->updateEmbeddedChildOptions($childOptions, $propTypeInfo, $refProperty, $formDepth), default => $childOptions, }; @@ -137,6 +149,10 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) unset($formOptions['children'][$classProperty]); } + if ($formOptions['gedmo_only']) { + return; + } + // Remaining FORM.children[PROP] unrelated to dataClass? E.g: mapped:false OR inherit_data:true foreach ($formOptions['children'] as $childProperty => $childOptions) { // FORM.children[PROP] callable? Continue early @@ -208,21 +224,15 @@ private function getDataClass(FormInterface $form): string * * @return ChildOptions */ - private function updateTranslatedChildOptions( + private function updateTranslationsChildOptions( array $baseChildOptions, - TypeInfo $propTypeInfo, - \ReflectionProperty $refProperty, + string $translatableClass, + bool $gedmoTranslatable, ): array { - if (!$propTypeInfo instanceof TypeInfo\CollectionType) { - return []; - } - - dump($refProperty); - return [ 'child_type' => 'A2lix\TranslationFormBundle\Form\Type\TranslationsType', - 'translation_class' => $propTypeInfo->getCollectionValueType()->getClassName(), - 'required' => $propTypeInfo->isNullable(), + 'translatable_class' => $translatableClass, + 'gedmo' => $gedmoTranslatable, ...$baseChildOptions, ]; } diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index b6fd453..93b0844 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -32,7 +32,7 @@ public function __construct( private readonly AutoTypeBuilder $autoTypeBuilder, private readonly array $globalExcludedChildren = [], private readonly array $globalEmbeddedChildren = [], - private readonly bool $globalTranslatedChildren = false, + private readonly bool $handleTranslationTypes = false, ) {} #[\Override] @@ -49,9 +49,10 @@ public function configureOptions(OptionsResolver $resolver): void 'children' => [], 'children_excluded' => $this->globalExcludedChildren, 'children_embedded' => $this->globalEmbeddedChildren, - 'children_translated' => $this->globalTranslatedChildren, 'children_groups' => null, 'builder' => null, + 'handle_translation_types' => $this->handleTranslationTypes, + 'gedmo_only' => false, ]); $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable'); @@ -74,11 +75,13 @@ public function configureOptions(OptionsResolver $resolver): void return $value; }); - $resolver->setAllowedTypes('children_translated', 'bool'); $resolver->setAllowedTypes('children_groups', 'string[]|null'); $resolver->setAllowedTypes('builder', 'callable|null'); $resolver->setInfo('builder', 'A callable (FormBuilderInterface $builder, string[] $classProperties): void'); + // Translation options (translation_form_bundle required) + $resolver->setAllowedTypes('handle_translation_types', 'bool'); + $resolver->setAllowedTypes('gedmo_only', 'bool'); // Others defaults FormType:class options $resolver->setNormalizer('data_class', static function (Options $options, ?string $value): string { if (null === $value) { From 42da54d131b445ebb2fcb10ba6f483c01cfd383d Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:35:59 +0100 Subject: [PATCH 25/35] Progress --- src/Form/Builder/AutoTypeBuilder.php | 28 ++++++++++++++++++---------- src/Form/Type/AutoType.php | 26 ++++++++++++++++---------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 070a1db..1de107a 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -15,6 +15,7 @@ use A2lix\AutoFormBundle\Form\Type\AutoType; use Doctrine\Common\Collections\ArrayCollection; use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; @@ -81,10 +82,7 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) if ($gedmoTranslatable) { $hasGedmoAttribute = null !== ($refProperty->getAttributes('Gedmo\Mapping\Annotation\Translatable')[0] ?? null); - if ( - ($formOptions['gedmo_only'] && !$hasGedmoAttribute) - || (!$formOptions['gedmo_only'] && $hasGedmoAttribute) - ) { + if ($formOptions['gedmo_only'] xor $hasGedmoAttribute) { unset($formOptions['children'][$classProperty]); continue; } @@ -139,8 +137,8 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) || ($childOptions['child_embedded'] ?? false); $childOptions = match (true) { - $formChildTranslations => $this->updateTranslationsChildOptions($childOptions, $dataClass, $gedmoTranslatable), - $formChildEmbedded => $this->updateEmbeddedChildOptions($childOptions, $propTypeInfo, $refProperty, $formDepth), + $formChildTranslations => $this->updateTranslationsChildOptions($dataClass, $gedmoTranslatable, $childOptions, $formOptions), + $formChildEmbedded => $this->updateEmbeddedChildOptions($propTypeInfo, $childOptions, $formOptions, $formDepth, $refProperty), default => $childOptions, }; } @@ -196,7 +194,6 @@ private function addChild(FormBuilderInterface $builder, string|FormBuilderInter $options['child_type'], $options['child_excluded'], $options['child_embedded'], - $options['child_translated'], $options['child_groups'], ); @@ -225,14 +222,18 @@ private function getDataClass(FormInterface $form): string * @return ChildOptions */ private function updateTranslationsChildOptions( - array $baseChildOptions, string $translatableClass, bool $gedmoTranslatable, + array $baseChildOptions, + array $formOptions, ): array { return [ 'child_type' => 'A2lix\TranslationFormBundle\Form\Type\TranslationsType', 'translatable_class' => $translatableClass, 'gedmo' => $gedmoTranslatable, + 'children_excluded' => $baseChildOptions['child_excluded'] ?? $formOptions['children_embedded'], + 'children_embedded' => $baseChildOptions['child_embedded'] ?? $formOptions['children_embedded'], + 'children_groups' => $baseChildOptions['child_groups'] ?? $formOptions['children_groups'], ...$baseChildOptions, ]; } @@ -243,10 +244,11 @@ private function updateTranslationsChildOptions( * @return ChildOptions */ private function updateEmbeddedChildOptions( - array $baseChildOptions, TypeInfo $propTypeInfo, + array $baseChildOptions, + array $formOptions, + int $formDepth, \ReflectionProperty $refProperty, - int $formDepth ): array { // TypeInfo matching native FormType? Abort, guessers are enough if (self::isTypeInfoWithMatchingNativeFormType($propTypeInfo)) { @@ -274,6 +276,9 @@ private function updateEmbeddedChildOptions( ...$baseCollOptions, 'entry_options' => [ 'data_class' => $collValueType->getClassName(), + 'children_excluded' => $baseChildOptions['child_excluded'] ?? $formOptions['children_embedded'], + 'children_embedded' => $baseChildOptions['child_embedded'] ?? $formOptions['children_embedded'], + 'children_groups' => $baseChildOptions['child_groups'] ?? $formOptions['children_groups'], // @phpstan-ignore nullCoalesce.offset ...($baseCollOptions['entry_options'] ?? []), ], @@ -292,6 +297,9 @@ private function updateEmbeddedChildOptions( 'child_type' => AutoType::class, 'data_class' => $innerType->getClassName(), 'required' => $propTypeInfo->isNullable(), + 'children_excluded' => $baseChildOptions['child_excluded'] ?? $formOptions['children_embedded'], + 'children_embedded' => $baseChildOptions['child_embedded'] ?? $formOptions['children_embedded'], + 'children_groups' => $baseChildOptions['child_groups'] ?? $formOptions['children_groups'], ...$baseChildOptions, ]; } diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index 93b0844..aa62328 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -47,32 +47,38 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'children' => [], - 'children_excluded' => $this->globalExcludedChildren, - 'children_embedded' => $this->globalEmbeddedChildren, + 'children_excluded_' => $this->globalExcludedChildren, + 'children_excluded' => null, + 'children_embedded_' => $this->globalEmbeddedChildren, + 'children_embedded' => null, 'children_groups' => null, 'builder' => null, 'handle_translation_types' => $this->handleTranslationTypes, 'gedmo_only' => false, ]); - $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable'); + $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable|null'); $resolver->setInfo('children_excluded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); - $resolver->addNormalizer('children_excluded', static function (Options $options, mixed $value): mixed { + $resolver->setNormalizer('children_excluded', static function (Options $options, mixed $value): mixed { + $defaultValue = $options['children_excluded_']; + if (is_callable($value)) { - return ($value)($options['children_excluded']); + return $value($defaultValue); } - return $value; + return $value ?? $defaultValue; }); - $resolver->setAllowedTypes('children_embedded', 'string[]|string|callable'); + $resolver->setAllowedTypes('children_embedded', 'string[]|string|callable|null'); $resolver->setInfo('children_embedded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); - $resolver->addNormalizer('children_embedded', static function (Options $options, mixed $value): mixed { + $resolver->setNormalizer('children_embedded', static function (Options $options, mixed $value): mixed { + $defaultValue = $options['children_embedded_']; + if (is_callable($value)) { - return ($value)($options['children_embedded']); + return $value($defaultValue); } - return $value; + return $value ?? $defaultValue; }); $resolver->setAllowedTypes('children_groups', 'string[]|null'); From bfc32ec98d54142b6637cda0fe42e37bf5330d81 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:27:07 +0100 Subject: [PATCH 26/35] Progress --- .devcontainer/Dockerfile | 2 +- .github/workflows/ci.yml | 40 +++++++++----------- UPGRADE.md | 4 +- composer.json | 20 +++++----- src/A2lixAutoFormBundle.php | 40 ++++++++++++++++++-- src/Form/Builder/AutoTypeBuilder.php | 27 +++++++------ src/Form/Type/AutoType.php | 14 +++---- src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 11 ++++-- tests/Fixtures/Entity/Media1.php | 2 +- tests/Fixtures/Entity/Product1.php | 2 +- 10 files changed, 94 insertions(+), 68 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b2884bf..a17b310 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.4-cli-trixie +FROM php:8.5-cli-trixie RUN apt-get update && apt-get install -y --no-install-recommends \ file \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf9d312..4c787e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,22 +12,22 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.5' - name: Cache Composer dependencies uses: actions/cache@v4 with: path: ~/.cache/composer/files - key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-php-8.4-composer- + ${{ runner.os }}-php-8.5-composer- - name: Validate composer.json and composer.lock run: composer validate --strict @@ -42,22 +42,22 @@ jobs: name: Static Analysis runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.5' - name: Cache Composer dependencies uses: actions/cache@v4 with: path: ~/.cache/composer/files - key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-php-8.4-composer- + ${{ runner.os }}-php-8.5-composer- - name: Install dependencies run: composer install --prefer-dist --no-progress @@ -76,31 +76,25 @@ jobs: fail-fast: false matrix: include: - # Symfony 7.3 (stable) - - php: '8.4' - symfony: '7.3' + # Symfony 7.4 (stable) + - php: '8.5' + symfony: '7.4' composer_args: '--prefer-stable' stability: 'stable' coverage: true - - php: '8.4' - symfony: '7.3' + - php: '8.5' + symfony: '7.4' composer_args: '--prefer-lowest' stability: 'stable' - # Symfony 7.4 (dev) - - php: '8.4' - symfony: '7.4' - composer_args: '--prefer-stable' - stability: 'dev' - - # Symfony 8.0 (dev) - - php: '8.4' + # Symfony 8.0 (stable) + - php: '8.5' symfony: '8.0' composer_args: '--prefer-stable' - stability: 'dev' + stability: 'stable' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: persist-credentials: false diff --git a/UPGRADE.md b/UPGRADE.md index e85f657..25a864e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -5,8 +5,8 @@ The bundle is no longer tied to Doctrine and now uses Symfony's PropertyInfo com ## BC BREAK: Minimum Requirements -- **PHP:** `8.2` or higher is required. -- **Symfony:** `7.3` or higher is required. +- **PHP:** `8.4` or higher is required. +- **Symfony:** `7.4` or higher is required. ## BC BREAK: Composer Dependencies diff --git a/composer.json b/composer.json index 6f0c52b..cafb832 100644 --- a/composer.json +++ b/composer.json @@ -17,12 +17,12 @@ ], "require": { "php": ">=8.4", - "symfony/config": "^7.3|^7.4|^8.0", - "symfony/dependency-injection": "^7.3|^7.4|^8.0", - "symfony/form": "^7.3|^7.4|^8.0", - "symfony/http-kernel": "^7.3|^7.4|^8.0", - "symfony/property-info": "^7.3|^7.4|^8.0", - "symfony/type-info": "^7.3.4|^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/type-info": "^7.4|^8.0", "phpdocumentor/reflection-docblock": "^5.6" }, "require-dev": { @@ -36,10 +36,10 @@ "phpstan/phpstan-symfony": "^2.0", "phpunit/phpunit": "^12.3", "rector/rector": "^2.1", - "symfony/cache": "^7.3", - "symfony/doctrine-bridge": "^7.3", - "symfony/validator": "^7.3", - "symfony/var-dumper": "^7.3" + "symfony/cache": "^7.4", + "symfony/doctrine-bridge": "^7.4", + "symfony/validator": "^7.4", + "symfony/var-dumper": "^7.4" }, "suggest": { "a2lix/translation-form-bundle": "For translation form" diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php index ea37f10..839ea80 100644 --- a/src/A2lixAutoFormBundle.php +++ b/src/A2lixAutoFormBundle.php @@ -44,6 +44,28 @@ public function loadExtension(array $config, ContainerConfigurator $container, C ; } + public function prependExtension(ContainerConfigurator $configurator, ContainerBuilder $container): void + { + if (!$container->hasExtension('a2lix_translation_form')) { + return; + } + + $config = $container->getExtensionConfig($this->extensionAlias)[0]; + + if (null === ($config['children_excluded'] ?? null)) { + $container->prependExtensionConfig($this->extensionAlias, [ + 'children_excluded' => [ + 'id', + 'newTranslations', + 'translatable', + 'locale', + 'currentLocale', + 'defaultLocale', + ], + ]); + } + } + public function build(ContainerBuilder $container): void { $container->addCompilerPass($this); @@ -51,10 +73,20 @@ public function build(ContainerBuilder $container): void public function process(ContainerBuilder $container): void { - if ($container->hasExtension('a2lix_translation_form')) { - $container->getDefinition('a2lix_auto_form.form.type.auto_type') - ->setArgument('$handleTranslationTypes', true) - ; + if (!$container->hasExtension('a2lix_translation_form')) { + return; } + + $container->getDefinition('a2lix_auto_form.form.type.auto_type') + ->setArgument('$handleTranslationTypes', true) + ; + + $config = $container->getExtensionConfig($this->extensionAlias)[0]; + $container->getDefinition('A2lix\TranslationFormBundle\Form\Type\TranslationsType') + ->setArguments([ + '$globalExcludedChildren' => $config['children_excluded'] ?? [], + '$globalEmbeddedChildren' => $config['children_embedded'] ?? [], + ]) + ; } } diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 1de107a..8304ced 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -14,6 +14,7 @@ use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; use A2lix\AutoFormBundle\Form\Type\AutoType; use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -64,7 +65,7 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) $refClass = new \ReflectionClass($dataClass); $allChildrenExcluded = '*' === $formOptions['children_excluded']; $allChildrenEmbedded = '*' === $formOptions['children_embedded']; - $childrenGroups = $formOptions['children_groups'] ?? ['Default']; + $childrenGroups = $formOptions['children_groups']; $handleTranslationTypes = $formOptions['handle_translation_types']; $gedmoTranslatable = $handleTranslationTypes && (null !== ($refClass->getAttributes('Gedmo\Mapping\Annotation\TranslationEntity')[0] ?? null)); $formDepth = $this->getFormDepth($form); @@ -137,8 +138,8 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) || ($childOptions['child_embedded'] ?? false); $childOptions = match (true) { - $formChildTranslations => $this->updateTranslationsChildOptions($dataClass, $gedmoTranslatable, $childOptions, $formOptions), - $formChildEmbedded => $this->updateEmbeddedChildOptions($propTypeInfo, $childOptions, $formOptions, $formDepth, $refProperty), + $formChildTranslations => $this->updateTranslationsChildOptions($dataClass, $gedmoTranslatable, $childOptions), + $formChildEmbedded => $this->updateEmbeddedChildOptions($propTypeInfo, $childOptions, $formDepth, $refProperty), default => $childOptions, }; } @@ -225,15 +226,11 @@ private function updateTranslationsChildOptions( string $translatableClass, bool $gedmoTranslatable, array $baseChildOptions, - array $formOptions, ): array { return [ 'child_type' => 'A2lix\TranslationFormBundle\Form\Type\TranslationsType', 'translatable_class' => $translatableClass, 'gedmo' => $gedmoTranslatable, - 'children_excluded' => $baseChildOptions['child_excluded'] ?? $formOptions['children_embedded'], - 'children_embedded' => $baseChildOptions['child_embedded'] ?? $formOptions['children_embedded'], - 'children_groups' => $baseChildOptions['child_groups'] ?? $formOptions['children_groups'], ...$baseChildOptions, ]; } @@ -246,7 +243,6 @@ private function updateTranslationsChildOptions( private function updateEmbeddedChildOptions( TypeInfo $propTypeInfo, array $baseChildOptions, - array $formOptions, int $formDepth, \ReflectionProperty $refProperty, ): array { @@ -276,9 +272,6 @@ private function updateEmbeddedChildOptions( ...$baseCollOptions, 'entry_options' => [ 'data_class' => $collValueType->getClassName(), - 'children_excluded' => $baseChildOptions['child_excluded'] ?? $formOptions['children_embedded'], - 'children_embedded' => $baseChildOptions['child_embedded'] ?? $formOptions['children_embedded'], - 'children_groups' => $baseChildOptions['child_groups'] ?? $formOptions['children_groups'], // @phpstan-ignore nullCoalesce.offset ...($baseCollOptions['entry_options'] ?? []), ], @@ -293,13 +286,19 @@ private function updateEmbeddedChildOptions( /** @var TypeInfo\ObjectType */ $innerType = $propTypeInfo instanceof TypeInfo\NullableType ? $propTypeInfo->getWrappedType() : $propTypeInfo; + if (Collection::class === $innerType->getClassName()) { + throw new \RuntimeException(sprintf( + 'Unprecise PhpDoc Collection detected for "%s:%s". Fix it. For example: "@param Collection $%s"', + $refProperty->class, + $refProperty->name, + $refProperty->name, + )); + } + return [ 'child_type' => AutoType::class, 'data_class' => $innerType->getClassName(), 'required' => $propTypeInfo->isNullable(), - 'children_excluded' => $baseChildOptions['child_excluded'] ?? $formOptions['children_embedded'], - 'children_embedded' => $baseChildOptions['child_embedded'] ?? $formOptions['children_embedded'], - 'children_groups' => $baseChildOptions['child_groups'] ?? $formOptions['children_groups'], ...$baseChildOptions, ]; } diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index aa62328..972002b 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -51,7 +51,7 @@ public function configureOptions(OptionsResolver $resolver): void 'children_excluded' => null, 'children_embedded_' => $this->globalEmbeddedChildren, 'children_embedded' => null, - 'children_groups' => null, + 'children_groups' => ['Default'], 'builder' => null, 'handle_translation_types' => $this->handleTranslationTypes, 'gedmo_only' => false, @@ -60,25 +60,21 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable|null'); $resolver->setInfo('children_excluded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); $resolver->setNormalizer('children_excluded', static function (Options $options, mixed $value): mixed { - $defaultValue = $options['children_excluded_']; - if (is_callable($value)) { - return $value($defaultValue); + return $value($options['children_excluded_']); } - return $value ?? $defaultValue; + return $value ?? $options['children_excluded_']; }); $resolver->setAllowedTypes('children_embedded', 'string[]|string|callable|null'); $resolver->setInfo('children_embedded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); $resolver->setNormalizer('children_embedded', static function (Options $options, mixed $value): mixed { - $defaultValue = $options['children_embedded_']; - if (is_callable($value)) { - return $value($defaultValue); + return $value($options['children_embedded_']); } - return $value ?? $defaultValue; + return $value ?? $options['children_embedded_']; }); $resolver->setAllowedTypes('children_groups', 'string[]|null'); diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php index 38f05e6..0d3fe12 100644 --- a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -37,9 +37,14 @@ public function guessType(string $class, string $property): ?TypeGuess // FormTypes handling 'multiple' option if ($typeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { - dump($typeInfo); - /** @var TypeInfo\CollectionType $typeInfo */ - // @phpstan-ignore missingType.generics + if (!$typeInfo instanceof TypeInfo\CollectionType) { + throw new \RuntimeException(sprintf( + 'Unprecise PhpDoc array detected for "%s:%s". Fix it. For example: "@param list $%s"', + $class, + $property, + $property, + )); + } $collValueType = $typeInfo->getCollectionValueType(); /** @var TypeInfo\ObjectType $collValueType */ diff --git a/tests/Fixtures/Entity/Media1.php b/tests/Fixtures/Entity/Media1.php index 0cd8cd2..96a00cd 100644 --- a/tests/Fixtures/Entity/Media1.php +++ b/tests/Fixtures/Entity/Media1.php @@ -21,7 +21,7 @@ class Media1 #[ORM\Id] #[ORM\Column] #[ORM\GeneratedValue] - public ?int $id = null; + public private(set) ?int $id = null; #[ORM\Column] #[AutoTypeCustom(excluded: true)] diff --git a/tests/Fixtures/Entity/Product1.php b/tests/Fixtures/Entity/Product1.php index 9f92319..354c106 100644 --- a/tests/Fixtures/Entity/Product1.php +++ b/tests/Fixtures/Entity/Product1.php @@ -26,7 +26,7 @@ class Product1 #[ORM\Column] #[ORM\GeneratedValue] #[AutoTypeCustom(excluded: true)] - public ?int $id = null; + public private(set) ?int $id = null; #[ORM\Column] public string $title; From f605b7982c46bfa0df84304e1aa13e336e8be7f9 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:28:03 +0100 Subject: [PATCH 27/35] Progress --- src/Form/Builder/AutoTypeBuilder.php | 10 ++-------- src/Form/Type/AutoType.php | 4 ++-- src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 7 +------ 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index 8304ced..c9eed67 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -13,10 +13,8 @@ use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; use A2lix\AutoFormBundle\Form\Type\AutoType; -use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Symfony\Component\Form\Extension\Core\Type\CollectionType; -use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; @@ -217,6 +215,7 @@ private function getDataClass(FormInterface $form): string throw new \RuntimeException('Unable to get dataClass'); } + /** * @param ChildOptions $baseChildOptions * @@ -287,12 +286,7 @@ private function updateEmbeddedChildOptions( $innerType = $propTypeInfo instanceof TypeInfo\NullableType ? $propTypeInfo->getWrappedType() : $propTypeInfo; if (Collection::class === $innerType->getClassName()) { - throw new \RuntimeException(sprintf( - 'Unprecise PhpDoc Collection detected for "%s:%s". Fix it. For example: "@param Collection $%s"', - $refProperty->class, - $refProperty->name, - $refProperty->name, - )); + throw new \RuntimeException(\sprintf('Unprecise PhpDoc Collection detected for "%s:%s". Fix it. For example: "@param Collection $%s"', $refProperty->class, $refProperty->name, $refProperty->name)); } return [ diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index 972002b..63176a8 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -60,7 +60,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable|null'); $resolver->setInfo('children_excluded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); $resolver->setNormalizer('children_excluded', static function (Options $options, mixed $value): mixed { - if (is_callable($value)) { + if (\is_callable($value)) { return $value($options['children_excluded_']); } @@ -70,7 +70,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('children_embedded', 'string[]|string|callable|null'); $resolver->setInfo('children_embedded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); $resolver->setNormalizer('children_embedded', static function (Options $options, mixed $value): mixed { - if (is_callable($value)) { + if (\is_callable($value)) { return $value($options['children_embedded_']); } diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php index 0d3fe12..1edb004 100644 --- a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -38,12 +38,7 @@ public function guessType(string $class, string $property): ?TypeGuess // FormTypes handling 'multiple' option if ($typeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) { if (!$typeInfo instanceof TypeInfo\CollectionType) { - throw new \RuntimeException(sprintf( - 'Unprecise PhpDoc array detected for "%s:%s". Fix it. For example: "@param list $%s"', - $class, - $property, - $property, - )); + throw new \RuntimeException(\sprintf('Unprecise PhpDoc array detected for "%s:%s". Fix it. For example: "@param list $%s"', $class, $property, $property)); } $collValueType = $typeInfo->getCollectionValueType(); From f3898344d37df7ac369a5ffd078b5e901ae5a4ca Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 4 Dec 2025 19:05:33 +0100 Subject: [PATCH 28/35] Progress --- src/A2lixAutoFormBundle.php | 2 +- src/Form/Attribute/AutoTypeCustom.php | 1 - src/Form/TypeGuesser/TypeInfoTypeGuesser.php | 1 + tests/Form/Type/AutoTypeTest.php | 2 +- tests/Form/TypeTestCase.php | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php index 839ea80..77f7c15 100644 --- a/src/A2lixAutoFormBundle.php +++ b/src/A2lixAutoFormBundle.php @@ -82,7 +82,7 @@ public function process(ContainerBuilder $container): void ; $config = $container->getExtensionConfig($this->extensionAlias)[0]; - $container->getDefinition('A2lix\TranslationFormBundle\Form\Type\TranslationsType') + $container->getDefinition('a2lix_translation_form.form.type.translations_type') ->setArguments([ '$globalExcludedChildren' => $config['children_excluded'] ?? [], '$globalEmbeddedChildren' => $config['children_embedded'] ?? [], diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php index 9104895..a60c1e1 100644 --- a/src/Form/Attribute/AutoTypeCustom.php +++ b/src/Form/Attribute/AutoTypeCustom.php @@ -38,7 +38,6 @@ public function __construct( */ public function getOptions(): array { - /** @var ChildOptions */ return [ ...$this->options, ...(null !== $this->type ? ['child_type' => $this->type] : []), diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php index 1edb004..3f62091 100644 --- a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php +++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php @@ -40,6 +40,7 @@ public function guessType(string $class, string $property): ?TypeGuess if (!$typeInfo instanceof TypeInfo\CollectionType) { throw new \RuntimeException(\sprintf('Unprecise PhpDoc array detected for "%s:%s". Fix it. For example: "@param list $%s"', $class, $property, $property)); } + $collValueType = $typeInfo->getCollectionValueType(); /** @var TypeInfo\ObjectType $collValueType */ diff --git a/tests/Form/Type/AutoTypeTest.php b/tests/Form/Type/AutoTypeTest.php index 9a0dd5a..f07d444 100755 --- a/tests/Form/Type/AutoTypeTest.php +++ b/tests/Form/Type/AutoTypeTest.php @@ -60,7 +60,7 @@ private static function assertFormChildren(array $expectedForm, array $formChild $childPath = $parentPath.'.'.$childName; if (null !== $expectedType = $expectedChildOptions['expected_type'] ?? null) { - self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath)); + self::assertSame($child->getConfig()->getType()->getInnerType()::class, $expectedType, \sprintf('Type of "%s"', $childPath)); } if (null !== $expectedChildren = $expectedChildOptions['expected_children'] ?? null) { diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index c613557..765834e 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -68,7 +68,7 @@ protected function getExtensions(): array private function getEntityManager(): EntityManagerInterface { - if (null !== $this->entityManager) { + if ($this->entityManager instanceof EntityManagerInterface) { return $this->entityManager; } From 4826710008096da95370a74ebdb8d94cbfc01f53 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:21:16 +0100 Subject: [PATCH 29/35] Progress --- composer.json | 20 ++++++++++---------- src/Form/Attribute/AutoTypeCustom.php | 1 + src/Form/Builder/AutoTypeBuilder.php | 5 +++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index cafb832..6e04b4f 100644 --- a/composer.json +++ b/composer.json @@ -26,16 +26,16 @@ "phpdocumentor/reflection-docblock": "^5.6" }, "require-dev": { - "doctrine/orm": "^3.5", - "friendsofphp/php-cs-fixer": "^3.87", - "kubawerlos/php-cs-fixer-custom-fixers": "^3.34", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpstan/phpstan-symfony": "^2.0", - "phpunit/phpunit": "^12.3", - "rector/rector": "^2.1", + "doctrine/orm": "^3.5.8", + "friendsofphp/php-cs-fixer": "^3.91.2", + "kubawerlos/php-cs-fixer-custom-fixers": "^3.35.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.32", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0.7", + "phpstan/phpstan-symfony": "^2.0.9", + "phpunit/phpunit": "^12.4.5", + "rector/rector": "^2.2.11", "symfony/cache": "^7.4", "symfony/doctrine-bridge": "^7.4", "symfony/validator": "^7.4", diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php index a60c1e1..9104895 100644 --- a/src/Form/Attribute/AutoTypeCustom.php +++ b/src/Form/Attribute/AutoTypeCustom.php @@ -38,6 +38,7 @@ public function __construct( */ public function getOptions(): array { + /** @var ChildOptions */ return [ ...$this->options, ...(null !== $this->type ? ['child_type' => $this->type] : []), diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php index c9eed67..6e56d07 100644 --- a/src/Form/Builder/AutoTypeBuilder.php +++ b/src/Form/Builder/AutoTypeBuilder.php @@ -36,7 +36,7 @@ * children: array, * children_excluded: list|"*", * children_embedded: list|"*", - * children_groups: list|null, + * children_groups: list, * builder: FormBuilderCallable|null, * handle_translation_types: bool, * gedmo_only: bool, @@ -135,6 +135,7 @@ public function buildChildren(FormBuilderInterface $builder, array $formOptions) $formChildEmbedded = $allChildrenEmbedded || \in_array($classProperty, $formOptions['children_embedded'], true) || ($childOptions['child_embedded'] ?? false); + /** @var ChildOptions */ $childOptions = match (true) { $formChildTranslations => $this->updateTranslationsChildOptions($dataClass, $gedmoTranslatable, $childOptions), $formChildEmbedded => $this->updateEmbeddedChildOptions($propTypeInfo, $childOptions, $formDepth, $refProperty), @@ -219,7 +220,7 @@ private function getDataClass(FormInterface $form): string /** * @param ChildOptions $baseChildOptions * - * @return ChildOptions + * @return array */ private function updateTranslationsChildOptions( string $translatableClass, From 3c4fe5bbd5e236d0bc0a07590897c4f02eb8e201 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:54:28 +0100 Subject: [PATCH 30/35] Fix --- .devcontainer/Dockerfile | 2 +- .github/workflows/ci.yml | 4 ++-- README.md | 6 ++++++ src/A2lixAutoFormBundle.php | 23 +++++++++++++---------- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a17b310..f43a048 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -13,7 +13,7 @@ RUN set -eux; \ @composer \ apcu \ opcache \ - xdebug \ + # xdebug \ ; RUN useradd -m vscode diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c787e6..630f56e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,9 +82,9 @@ jobs: composer_args: '--prefer-stable' stability: 'stable' coverage: true - - php: '8.5' + - php: '8.4' symfony: '7.4' - composer_args: '--prefer-lowest' + composer_args: '--prefer-stable' stability: 'stable' # Symfony 8.0 (stable) diff --git a/README.md b/README.md index 84a0cdd..a517933 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,12 @@ a2lix_auto_form: children_excluded: [id, createdAt] ``` +## Translations + +If you need to manage form translations, please see the [A2lix TranslationFormBundle](https://github.com/a2lix/TranslationFormBundle), which is designed to work with this bundle. + +A complete demonstration is also available at [a2lix/demo](https://github.com/a2lix/Demo). + ## License This package is available under the [MIT license](LICENSE). \ No newline at end of file diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php index 77f7c15..c2bb177 100644 --- a/src/A2lixAutoFormBundle.php +++ b/src/A2lixAutoFormBundle.php @@ -34,16 +34,6 @@ public function configure(DefinitionConfigurator $definition): void } #[\Override] - public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void - { - $container->import('../config/services.php'); - - $container->services() - ->get('a2lix_auto_form.form.type.auto_type') - ->arg('$globalExcludedChildren', $config['children_excluded']) - ; - } - public function prependExtension(ContainerConfigurator $configurator, ContainerBuilder $container): void { if (!$container->hasExtension('a2lix_translation_form')) { @@ -66,11 +56,24 @@ public function prependExtension(ContainerConfigurator $configurator, ContainerB } } + #[\Override] + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('../config/services.php'); + + $container->services() + ->get('a2lix_auto_form.form.type.auto_type') + ->arg('$globalExcludedChildren', $config['children_excluded']) + ; + } + + #[\Override] public function build(ContainerBuilder $container): void { $container->addCompilerPass($this); } + #[\Override] public function process(ContainerBuilder $container): void { if (!$container->hasExtension('a2lix_translation_form')) { From 94764a3d2e3f3ee958d1090255021483881c3ffe Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:23:28 +0000 Subject: [PATCH 31/35] Fix --- .devcontainer/devcontainer.json | 3 ++- .editorconfig | 6 ++++++ README.md | 20 ++++++++++--------- composer.json | 16 +++++++-------- phpstan.neon | 2 +- src/Form/Type/AutoType.php | 4 ++-- tests/Form/DataProviderDto.php | 2 +- tests/Form/DataProviderEntity.php | 9 +++++---- tests/Form/Type/AutoTypeTest.php | 8 +++++--- tests/Form/TypeTestCase.php | 33 +++++++++++++++++++------------ 10 files changed, 61 insertions(+), 42 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 033d3e5..0d7f751 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,6 +13,7 @@ "settings": { "terminal.integrated.defaultProfile.linux": "bash", "intelephense.telemetry.enabled": false, + "phpstan.checkValidity": true, "workbench.colorCustomizations": { "statusBar.background": "#ffa600d3", "statusBar.noFolderBackground": "#ffa600d3", @@ -35,4 +36,4 @@ } }, "remoteUser": "vscode" -} \ No newline at end of file +} diff --git a/.editorconfig b/.editorconfig index 677e36e..40bd16e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,9 @@ indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/README.md b/README.md index a517933..164e425 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,13 @@ Stop writing boilerplate form code. This bundle provides a single, powerful `AutoType` form type that automatically generates a complete Symfony form from any PHP class. +> [!NOTE] +> If you need to manage form translations, please see the [A2lix TranslationFormBundle](https://github.com/a2lix/TranslationFormBundle), which is designed to work with this bundle. + +> [!TIP] +> A complete demonstration is also available at [a2lix/demo](https://github.com/a2lix/Demo). + + ## Installation Use Composer to install the bundle: @@ -29,7 +36,8 @@ class TaskController extends AbstractController public function new(Request $request): Response { $task = new Task(); // Any entity or DTO - $form = $this->createForm(AutoType::class, $task) + $form = $this + ->createForm(AutoType::class, $task) ->add('save', SubmitType::class) ->handleRequest($request) ; @@ -67,7 +75,7 @@ class TaskController extends AbstractController // 2. Optional define which properties should be rendered as embedded forms. // Use '*' to embed all relational properties. - 'children_embedded' => ['category', 'tags'], + 'children_embedded' => static fn (mixed $current) => [...$current, 'category', 'tags'], // 3. Optional customize, override, or add fields. 'children' => [ @@ -124,7 +132,7 @@ use Symfony\Component\Form\Extension\Core\Type\TextareaType; class Product { #[AutoTypeCustom(excluded: true)] - public int $id; + public private(set) int $id; public ?string $name = null; @@ -217,12 +225,6 @@ a2lix_auto_form: children_excluded: [id, createdAt] ``` -## Translations - -If you need to manage form translations, please see the [A2lix TranslationFormBundle](https://github.com/a2lix/TranslationFormBundle), which is designed to work with this bundle. - -A complete demonstration is also available at [a2lix/demo](https://github.com/a2lix/Demo). - ## License This package is available under the [MIT license](LICENSE). \ No newline at end of file diff --git a/composer.json b/composer.json index 6e04b4f..fe488a8 100644 --- a/composer.json +++ b/composer.json @@ -27,18 +27,18 @@ }, "require-dev": { "doctrine/orm": "^3.5.8", - "friendsofphp/php-cs-fixer": "^3.91.2", + "friendsofphp/php-cs-fixer": "^3.91.3", "kubawerlos/php-cs-fixer-custom-fixers": "^3.35.1", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.32", - "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-phpunit": "^2.0.10", "phpstan/phpstan-strict-rules": "^2.0.7", "phpstan/phpstan-symfony": "^2.0.9", - "phpunit/phpunit": "^12.4.5", - "rector/rector": "^2.2.11", - "symfony/cache": "^7.4", - "symfony/doctrine-bridge": "^7.4", - "symfony/validator": "^7.4", + "phpunit/phpunit": "^12.5.2", + "rector/rector": "^2.2.14", + "symfony/cache": "^7.4.1", + "symfony/doctrine-bridge": "^7.4.1", + "symfony/validator": "^7.4.2", "symfony/var-dumper": "^7.4" }, "suggest": { diff --git a/phpstan.neon b/phpstan.neon index 7efe5bb..7869c99 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,7 +5,7 @@ parameters: - tests excludePaths: - src/A2lixAutoFormBundle.php - - tests/Fixtures/* + - tests/Fixtures # Stricter setup checkTooWideReturnTypesInProtectedAndPublicMethods: true diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php index 63176a8..7153b5b 100644 --- a/src/Form/Type/AutoType.php +++ b/src/Form/Type/AutoType.php @@ -58,7 +58,7 @@ public function configureOptions(OptionsResolver $resolver): void ]); $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable|null'); - $resolver->setInfo('children_excluded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); + $resolver->setInfo('children_excluded', 'An array of properties, the * wildcard, or a callable (mixed $current): mixed'); $resolver->setNormalizer('children_excluded', static function (Options $options, mixed $value): mixed { if (\is_callable($value)) { return $value($options['children_excluded_']); @@ -68,7 +68,7 @@ public function configureOptions(OptionsResolver $resolver): void }); $resolver->setAllowedTypes('children_embedded', 'string[]|string|callable|null'); - $resolver->setInfo('children_embedded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): mixed'); + $resolver->setInfo('children_embedded', 'An array of properties, the * wildcard, or a callable (mixed $current): mixed'); $resolver->setNormalizer('children_embedded', static function (Options $options, mixed $value): mixed { if (\is_callable($value)) { return $value($options['children_embedded_']); diff --git a/tests/Form/DataProviderDto.php b/tests/Form/DataProviderDto.php index 6fca2d6..4b8d477 100644 --- a/tests/Form/DataProviderDto.php +++ b/tests/Form/DataProviderDto.php @@ -255,7 +255,7 @@ public static function provideScenarioCases(): iterable new TestScenario( obj: new Product1(), formOptions: [ - 'children_excluded' => ['tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'], + 'children_excluded' => static fn (mixed $current) => [...$current, 'tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'], 'children' => [ 'code' => [ 'child_excluded' => true, diff --git a/tests/Form/DataProviderEntity.php b/tests/Form/DataProviderEntity.php index d4a44be..3b4dc53 100644 --- a/tests/Form/DataProviderEntity.php +++ b/tests/Form/DataProviderEntity.php @@ -15,6 +15,7 @@ use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media1; use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product1; use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\Extension\Core\Type as CoreType; use Symfony\Component\Form\FormBuilderInterface; @@ -39,10 +40,10 @@ public static function provideScenarioCases(): iterable 'expected_type' => CoreType\TextType::class, ], 'mediaMain' => [ - 'expected_type' => CoreType\TextType::class, + 'expected_type' => EntityType::class, ], 'mediaColl' => [ - 'expected_type' => CoreType\TextType::class, + 'expected_type' => EntityType::class, ], 'status' => [ 'expected_type' => CoreType\EnumType::class, @@ -166,7 +167,7 @@ public static function provideScenarioCases(): iterable ], ], 'mediaMain' => [ - 'expected_type' => CoreType\TextType::class, + 'expected_type' => EntityType::class, ], 'mediaColl' => [ 'expected_type' => CoreType\CollectionType::class, @@ -254,7 +255,7 @@ public static function provideScenarioCases(): iterable new TestScenario( obj: new Product1(), formOptions: [ - 'children_excluded' => ['tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'], + 'children_excluded' => static fn (array $current) => [...$current, 'tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'], 'children' => [ 'code' => [ 'child_excluded' => true, diff --git a/tests/Form/Type/AutoTypeTest.php b/tests/Form/Type/AutoTypeTest.php index f07d444..967e556 100755 --- a/tests/Form/Type/AutoTypeTest.php +++ b/tests/Form/Type/AutoTypeTest.php @@ -19,6 +19,7 @@ use A2lix\AutoFormBundle\Tests\Form\DataProviderEntity; use A2lix\AutoFormBundle\Tests\Form\TestScenario; use A2lix\AutoFormBundle\Tests\Form\TypeTestCase; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProviderExternal; use Symfony\Component\Form\FormInterface; @@ -32,6 +33,7 @@ #[CoversClass(AutoTypeBuilder::class)] #[CoversClass(AutoTypeCustom::class)] #[CoversClass(TypeInfoTypeGuesser::class)] +#[AllowMockObjectsWithoutExpectations] // https://github.com/symfony/symfony/issues/62669 final class AutoTypeTest extends TypeTestCase { #[DataProviderExternal(DataProviderDto::class, 'provideScenarioCases')] @@ -59,11 +61,11 @@ private static function assertFormChildren(array $expectedForm, array $formChild $expectedChildOptions = $expectedForm[$childName]; $childPath = $parentPath.'.'.$childName; - if (null !== $expectedType = $expectedChildOptions['expected_type'] ?? null) { - self::assertSame($child->getConfig()->getType()->getInnerType()::class, $expectedType, \sprintf('Type of "%s"', $childPath)); + if (null !== $expectedType = ($expectedChildOptions['expected_type'] ?? null)) { + self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath)); } - if (null !== $expectedChildren = $expectedChildOptions['expected_children'] ?? null) { + if (null !== $expectedChildren = ($expectedChildOptions['expected_children'] ?? null)) { // @phpstan-ignore argument.type self::assertFormChildren($expectedChildren, $child->all(), $childPath); } diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 765834e..b280a5d 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -20,6 +20,7 @@ use Doctrine\ORM\ORMSetup; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bridge\Doctrine\Form\DoctrineOrmTypeGuesser; +use Symfony\Bridge\Doctrine\Form\DoctrineOrmExtension; use Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor; use Symfony\Component\Form\FormTypeGuesserChain; use Symfony\Component\Form\PreloadedExtension; @@ -60,15 +61,32 @@ protected function getExtensions(): array ['id'] ); + $managerRegistryStub = $this->createStub(ManagerRegistry::class); + $managerRegistryStub + ->method('getManager') + ->willReturn($this->getEntityManager()) + ; + $managerRegistryStub + ->method('getManagers') + ->willReturn(['default' => $this->getEntityManager()]) + ; + return [ ...parent::getExtensions(), - new PreloadedExtension([$autoType], [], $this->getFormTypeGuesserChain()), + new DoctrineOrmExtension($managerRegistryStub), + new PreloadedExtension( + [$autoType], + [], + new FormTypeGuesserChain([ + new TypeInfoTypeGuesser(TypeResolver::create()), + ]), + ), ]; } private function getEntityManager(): EntityManagerInterface { - if ($this->entityManager instanceof EntityManagerInterface) { + if (null !== $this->entityManager) { return $this->entityManager; } @@ -102,15 +120,4 @@ private function getPropertyInfoExtractor(): PropertyInfoExtractor ] ); } - - private function getFormTypeGuesserChain(): FormTypeGuesserChain - { - $managerRegistry = $this->createMock(ManagerRegistry::class); - $managerRegistry->method('getManagerForClass')->willReturn($this->getEntityManager()); - - return new FormTypeGuesserChain([ - new DoctrineOrmTypeGuesser($managerRegistry), - new TypeInfoTypeGuesser(TypeResolver::create()), - ]); - } } From 99e93f77c650d0a2c5c03beb5e3a4a5de1a644fd Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:23:55 +0000 Subject: [PATCH 32/35] Fix --- tests/Form/Type/AutoTypeTest.php | 2 +- tests/Form/TypeTestCase.php | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Form/Type/AutoTypeTest.php b/tests/Form/Type/AutoTypeTest.php index 967e556..de4f82b 100755 --- a/tests/Form/Type/AutoTypeTest.php +++ b/tests/Form/Type/AutoTypeTest.php @@ -33,7 +33,7 @@ #[CoversClass(AutoTypeBuilder::class)] #[CoversClass(AutoTypeCustom::class)] #[CoversClass(TypeInfoTypeGuesser::class)] -#[AllowMockObjectsWithoutExpectations] // https://github.com/symfony/symfony/issues/62669 +#[AllowMockObjectsWithoutExpectations] // https://github.com/symfony/symfony/issues/62669 final class AutoTypeTest extends TypeTestCase { #[DataProviderExternal(DataProviderDto::class, 'provideScenarioCases')] diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index b280a5d..c780638 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -19,7 +19,6 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\ORMSetup; use Doctrine\Persistence\ManagerRegistry; -use Symfony\Bridge\Doctrine\Form\DoctrineOrmTypeGuesser; use Symfony\Bridge\Doctrine\Form\DoctrineOrmExtension; use Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor; use Symfony\Component\Form\FormTypeGuesserChain; @@ -61,7 +60,7 @@ protected function getExtensions(): array ['id'] ); - $managerRegistryStub = $this->createStub(ManagerRegistry::class); + $managerRegistryStub = self::createStub(ManagerRegistry::class); $managerRegistryStub ->method('getManager') ->willReturn($this->getEntityManager()) From 75eebb6e1b3527804d331edd7be2197402c3544d Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:25:40 +0000 Subject: [PATCH 33/35] Fix --- tests/Form/DataProviderDto.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Form/DataProviderDto.php b/tests/Form/DataProviderDto.php index 4b8d477..4623e76 100644 --- a/tests/Form/DataProviderDto.php +++ b/tests/Form/DataProviderDto.php @@ -255,7 +255,7 @@ public static function provideScenarioCases(): iterable new TestScenario( obj: new Product1(), formOptions: [ - 'children_excluded' => static fn (mixed $current) => [...$current, 'tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'], + 'children_excluded' => static fn (array $current) => [...$current, 'tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'], 'children' => [ 'code' => [ 'child_excluded' => true, From 51b7033dbd7d94dc3cdc34d76169c8132707e33a Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:16:58 +0000 Subject: [PATCH 34/35] Fix --- tests/Form/TypeTestCase.php | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index c780638..e054a01 100755 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -83,20 +83,6 @@ protected function getExtensions(): array ]; } - private function getEntityManager(): EntityManagerInterface - { - if (null !== $this->entityManager) { - return $this->entityManager; - } - - $configuration = ORMSetup::createAttributeMetadataConfig([__DIR__.'/../Fixtures/Entity'], true); - $configuration->enableNativeLazyObjects(true); - - $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $configuration); - - return $this->entityManager = new EntityManager($connection, $configuration); - } - private function getPropertyInfoExtractor(): PropertyInfoExtractor { $doctrineExtractor = new DoctrineExtractor($this->getEntityManager()); @@ -119,4 +105,18 @@ private function getPropertyInfoExtractor(): PropertyInfoExtractor ] ); } + + private function getEntityManager(): EntityManagerInterface + { + if (null !== $this->entityManager) { + return $this->entityManager; + } + + $configuration = ORMSetup::createAttributeMetadataConfig([__DIR__.'/../Fixtures/Entity'], true); + $configuration->enableNativeLazyObjects(true); + + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $configuration); + + return $this->entityManager = new EntityManager($connection, $configuration); + } } From f6ac308d46f6486712fd1654820b16d9c066b456 Mon Sep 17 00:00:00 2001 From: David ALLIX <517753+webda2l@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:24:23 +0000 Subject: [PATCH 35/35] Fix --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f43a048..a17b310 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -13,7 +13,7 @@ RUN set -eux; \ @composer \ apcu \ opcache \ - # xdebug \ + xdebug \ ; RUN useradd -m vscode