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
[](https://packagist.org/packages/a2lix/auto-form-bundle)
[](https://packagist.org/packages/a2lix/auto-form-bundle)
-[](https://packagist.org/packages/a2lix/auto-form-bundle)
-
[](https://packagist.org/packages/a2lix/auto-form-bundle)
-[](https://packagist.org/packages/a2lix/auto-form-bundle)
-[](https://packagist.org/packages/a2lix/auto-form-bundle)
+[](https://packagist.org/packages/a2lix/auto-form-bundle)
+[](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 @@
[](https://packagist.org/packages/a2lix/auto-form-bundle)
[](https://packagist.org/packages/a2lix/auto-form-bundle)
[](https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml)
+[](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