diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index 46d82c5..a7ffcee 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -97,7 +97,7 @@ jobs:
with:
php-version: 8.4
extensions: json
- ini-values: date.timezone=Europe/Paris
+ ini-values: date.timezone=Europe/Paris, opcache.jit=disable
- name: Install Infection
run: |
diff --git a/.gitignore b/.gitignore
index 6d5cecd..5472aac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/.idea
/vendor
/composer.lock
+.phpunit.result.cache
infection.log
diff --git a/README.md b/README.md
index a8fa9be..404e7c4 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ Library for handle form, and request validation.
- [Error Handling](#error-handling)
- [Simple usage](#simple-usage)
- [Printer](#printer)
+- [Using PHP 8 attributes](#using-php-8-attributes)
## Installation using composer
@@ -1261,3 +1262,180 @@ class ApiPrinter implements \Bdf\Form\Error\FormErrorPrinterInterface
// Usage
return new JsonReponse(['errors' => $form->error()->print(new ApiPrinter())]);
```
+
+## Using PHP 8 attributes
+
+You can usePHP 8 attributes and typed properties to declare form elements and configure them,
+instead of using the "classical" way by overriding `configure()` method.
+
+### Declare a form class
+
+To create a form using PHP 8 attributes, first you have to extend [AttributeForm](src/Attribute/AttributeForm.php).
+
+Then declare all input elements and buttons as property :
+- For element : `public|protected|private MyElementType $myElementName;`
+- For button : `public|protected|private ButtonInterface $myButton;`
+
+Finally, use attributes on properties (or form class) for configure elements, add constraints, transformers...
+
+```php
+#[Positive, UnitTransformer, GetSet]
+public IntegerElement $weight;
+```
+
+> Adaptation of example from BDF Form : [Handle entities](https://github.com/b2pweb/bdf-form#handle-entities)
+
+```php
+
+use Bdf\Form\Attribute\Form\Generates;
+use Bdf\Form\Leaf\StringElement;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Bdf\Form\Attribute\Child\GetSet;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Leaf\Date\DateTimeElement;
+use Bdf\Form\Attribute\Element\Date\ImmutableDateTime;
+use Bdf\Form\Attribute\Child\CallbackModelTransformer;
+use Bdf\Form\ElementInterface;
+
+// Declare the entity
+class Person
+{
+ public string $firstName;
+ public string $lastName;
+ public ?DateTimeInterface $birthDate;
+ public ?Country $country;
+}
+
+#[Generates(Person::class)] // Define that PersonForm::value() should return a Person instance
+class PersonForm extends AttributeForm // The form must extend AttributeForm to use PHP 8 attributes syntax
+{
+ // Declare a property for declare an input on the form
+ // The property type is used as element type
+ // use NotBlank for mark the input as required
+ // GetSet will define entity accessor
+ #[NotBlank, GetSet]
+ private StringElement $firstName;
+
+ #[NotBlank, GetSet]
+ private StringElement $lastName;
+
+ // Use ImmutableDateTime to change the value of birthDate to DateTimeImmutable
+ #[ImmutableDateTime, GetSet]
+ private DateTimeElement $birthDate;
+
+ // Custom transformer can be declared with a method name as first parameter of ModelTransformer
+ // Transformers methods must be declared as public on the form class
+ #[ImmutableDateTime, CallbackModelTransformer(toEntity: 'findCountry', toInput: 'extractCountryCode'), GetSet]
+ private StringElement $country;
+
+ // Transformer used when extracting input value from entity
+ public function findCountry(Country $value, ElementInterface $element): string
+ {
+ return $value->code;
+ }
+
+ // Transformer used when filling entity with input value
+ public function extractCountryCode(string $value, ElementInterface $element): ?Country
+ {
+ return Country::findByCode($value);
+ }
+}
+```
+
+### Supported attributes
+
+This library supports various attributes types for configure form elements :
+
+- [Symfony validator's](https://github.com/symfony/validator) `Constraint`, translated as `...->satisfy(new Constraint(...))`
+- [`ExtractorInterface`](https://github.com/b2pweb/bdf-form/blob/master/src/PropertyAccess/ExtractorInterface.php), translated as `...->extractor(new Extractor(...))`
+- [`HydratorInterface`](https://github.com/b2pweb/bdf-form/blob/master/src/PropertyAccess/HydratorInterface.php), translated as `...->hydrator(new Hydrator(...))`
+- [`FilterInterface`](https://github.com/b2pweb/bdf-form/blob/master/src/Filter/FilterInterface.php), translated as `...->filter(new Filter(...))`
+- [`TransformerInterface`](https://github.com/b2pweb/bdf-form/blob/master/src/Transformer/TransformerInterface.php), translated as `...->transformer(new Transformer(...))`
+
+### Generate the configurator code from attributes
+
+To improve performance, and to do without the use of reflection, attributes can be used to generate the PHP code
+of the configurator, instead of dynamically configure the form.
+
+To do that, use [`CompileAttributesProcessor`](src/Processor/CompileAttributesProcessor.php) as argument of form constructor.
+
+```php
+const GENERATED_NAMESPACE = 'Generated\\';
+const GENERATED_DIRECTORY = __DIR__ . '/var/generated/form/';
+
+// Configure the processor by setting class and file resolvers
+$processor = new CompileAttributesProcessor(
+ fn (AttributeForm $form) => GENERATED_NAMESPACE . get_class($form) . 'Configurator', // Retrieve the configurator class name from the form object
+ fn (string $className) => GENERATED_DIRECTORY . str_replace('\\', DIRECTORY_SEPARATOR, $className) . '.php', // Get the filename of the configurator class from the configurator class name
+);
+
+$form = new MyForm(processor: $processor); // Set the processor on the constructor
+$form->submit(['firstName' => 'John']); // Directly use the form : the configurator will be automatically generated
+
+// You can also pre-generate the form configurator using CompileAttributesProcessor::generate()
+$processor->generate(new MyOtherForm());
+```
+
+## Available attributes
+
+### On form class
+
+| Attribute | Example | Translated to | Purpose |
+|-------------------------------------------------------|---------------------------------|--------------------------------------------|----------------------------------------------------------------------------------------------------------------|
+| [`Generates`](src/Form/Generates.php) | `Generates(MyEntity::class)` | `$builder->generates(MyEntity::class)` | Define the entity class generated by the form. |
+| [`CallbackGenerator`](src/Form/CallbackGenerator.php) | `CallbackGenerator('generate')` | `$builder->generates([$this, 'generate'])` | Define the method to use for generate the form value. The method must be declared as public on the form class. |
+| [`Csrf`](src/Form/Csrf.php) | `Csrf(tokenId: 'MyToken')` | `$builder->csrf()->tokenId('MyToken')` | Add a CSRF element on the form. |
+
+### On method
+
+| Attribute | Example | Translated to | Purpose |
+|------------------------------------------------------------|--------------------------------------|-------------------------------------------------------|-------------------------------------------------------------|
+| [`AsConstraint`](src/Constraint/AsConstraint.php) | `AsConstraint('validateFoo')` | `$builder->satisfy([$this, 'validateFoo'])` | Use the method as constraint for the target element. |
+| [`AsArrayConstraint`](src/Aggregate/AsArrayConstraint.php) | `AsArrayConstraint('validateFoo')` | `$builder->arrayConstraint([$this, 'validateFoo'])` | Use the method as constraint for the target array element. |
+| [`AsFilter`](src/Child/AsFilter.php) | `AsFilter('filterFoo')` | `$builder->filter([$this, 'filterFoo'])` | Use the method as filter for the target element. |
+| [`AsTransformer`](src/Element/AsTransformer.php) | `AsTransformer('transformFoo')` | `$builder->transformer([$this, 'transformFoo'])` | Use the method as HTTP transformer for the target element. |
+| [`AsModelTransformer`](src/Child/AsModelTransformer.php) | `AsModelTransformer('transformFoo')` | `$builder->modelTransformer([$this, 'transformFoo'])` | Use the method as model transformer for the target element. |
+
+### On button property
+
+| Attribute | Example | Translated to | Purpose |
+|-----------------------------------|------------------------|-------------------------------|-------------------------------------------------------------------|
+| [`Groups`](src/Button/Groups.php) | `Groups('foo', 'bar')` | `...->groups(['foo', 'bar'])` | Define validation groups to use when the given button is clicked. |
+| [`Value`](src/Button/Value.php) | `Value('foo')` | `...->value('foo')` | Define the button value. |
+
+### On element property
+
+| Attribute | Example | Translated to | Purpose |
+|----------------------------------------------------------------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Child** | | | |
+| [`ModelTransformer`](src/Child/ModelTransformer.php) | `ModelTransformer(MyTransformer::class, ['ctroarg'])` | `...->modelTransformer(new MyTransformer('ctorarg))` | Define a [model transformer](https://github.com/b2pweb/bdf-form#10---apply-model-transformer-scope-child) on the current child. |
+| [`CallbackModelTransformer`](src/Child/CallbackModelTransformer.php) | `CallbackModelTransformer(toEntity: 'parseInput', toInput: 'normalize')` | `...->modelTransformer(fn ($value, $input, $toEntity) => $toEntity ? $this->parseInput($value, $input) : $this->normalize($value, $input))` | Define a [model transformer](https://github.com/b2pweb/bdf-form#10---apply-model-transformer-scope-child) using a form method. |
+| [`Configure`](src/Child/Configure.php) | `Configure('configureInput')` | `...->configureField($elementBuilder)` | Manually configure the element builder using a form method. The method must be public and declared on the form class. |
+| [`DefaultValue`](src/Child/DefaultValue.php) | `DefaultValue(42)` | `...->configureField($elementBuilder)` | Define the default value of the input. |
+| [`Dependencies`](src/Child/Dependencies.php) | `Dependencies('foo', 'bar')` | `...->depends('foo', 'bar')` | Declare dependencies on the current input. Dependencies will be submitted before the current field. |
+| [`GetSet`](src/Child/GetSet.php) | `GetSet('realField')` | `...->getter('realField')->setter('realField')` | Enable hydration and extraction of the entity. |
+| [`CallbackFilter`](src/Child/CallbackFilter.php) | `CallbackFilter('filterMethod')` | `...->filter([$this, 'filterMethod'])` | Add a filter on the current child using a method. |
+| [`HttpField`](src/Child/HttpField.php) | `HttpField('_field')` | `...->httpField(new ArrayOffsetHttpField('_field'))` | Define the http field name to use on the current child, instead of use the property name. |
+| **Element** | | | |
+| [`CallbackConstraint`](src/Constraint/CallbackConstraint.php) | `CallbackConstraint('validateInput')` | `...->satisfy([$this, 'validateInput'])` | Validate an input using a method. |
+| [`Satisfy`](src/Constraint/Satisfy.php) | `Satisfy(MyConstraint::class, ['opt' => 'val'])` | `...->satisfy(new MyConstraint(['opt' => 'val']))` | Add a constraint on the input. Prefer directly use the constraint class as attribute if possible. |
+| [`Transformer`](src/Element/Transformer.php) | `Transformer(MyTransformer::class, ['ctorarg'])` | `...->transformer(new MyTransformer('ctorarg))` | Add a [transformer](https://github.com/b2pweb/bdf-form#6---call-element-transformers-scope-element) on the input. Prefer directly use the transformer class as attribute if possible. |
+| [`CallbackTransformer`](src/Element/CallbackTransformer.php) | `CallbackTransformer(fromHttp: 'parse', toHttp: 'stringify')` | `...->transformer(fn ($value, $input, $toPhp) => $toPhp ? $this->parse($value, $input) : $this->stringify($value, $input))` | Add a [transformer](https://github.com/b2pweb/bdf-form#6---call-element-transformers-scope-element) using a form method. |
+| [`Choices`](src/Element/Choices.php) | `Choices(['foo', 'bar'])` | `...->choices(['foo', 'bar'])` | Define the values choices of the input. Supports using a method as choices provider. |
+| [`Raw`](src/Element/Raw.php) | `Raw` | `...->raw()` | For number elements. Use native PHP cast instead of locale parsing for convert number. |
+| [`TransformerError`](src/Element/TransformerError.php) | `TransformerError(message: 'Invalid value provided')` | `...->transformerErrorMessage('Invalid value provided')` | Configure error handling of transformer exceptions. |
+| [`IgnoreTransformerException`](src/Element/IgnoreTransformerException.php) | `IgnoreTransformerException` | `...->ignoreTransformerException()` | Ignore transformer exception. If enable and an exception occurs, the raw value will be used. |
+| [`Required`](src/Element/Required.php) | `Required` | `...->required()` | Mark the element as required. The error message can be defined as parameter of the attribute. |
+| **DateTimeElement** | | | |
+| [`DateFormat`](src/Element/Date/DateFormat.php) | `DateFormat('d/m/Y H:i')` | `...->format('d/m/Y H:i')` | Define the input date format. |
+| [`DateTimeClass`](src/Element/Date/DateTimeClass.php) | `DateTimeClass(Carbon::class)` | `...->className(Carbon::class)` | Define date time class to use on for parse the date. |
+| [`ImmutableDateTime`](src/Element/Date/ImmutableDateTime.php) | `ImmutableDateTime` | `...->immutable()` | Use `DateTimeImmutable` as date time class. |
+| [`Timezone`](src/Element/Date/Timezone.php) | `Timezone('Europe/Paris')` | `...->timezone('Europe/Paris')` | Define the parsing and normalized timezone to use. |
+| [`AfterField`](src/Element/Date/AfterField.php) | `AfterField('otherField')` | `...->afterField('otherField')` | Add a greater than an other field constraint to the current element. |
+| [`BeforeField`](src/Element/Date/BeforeField.php) | `BeforeField('otherField')` | `...->beforeField('otherField')` | Add a less than an other field constraint to the current element. |
+| **ArrayElement** | | | |
+| [`ArrayConstraint`](src/Aggregate/ArrayConstraint.php) | `ArrayConstraint(MyConstraint::class, ['opt' => 'val'])` | `...->arrayConstraint(new MyConstraint(['opt' => 'val']))` | Add a constraint on the whole array element. |
+| [`CallbackArrayConstraint`](src/Aggregate/CallbackArrayConstraint.php) | `CallbackArrayConstraint('validateInput')` | `...->arrayConstraint([$this, 'validateInput'])` | Add a constraint on the whole array element, using a form method. |
+| [`Count`](src/Aggregate/Count.php) | `Count(min: 3, max: 6)` | `...->arrayConstraint(new Count(min: 3, max: 6))` | Add a Count constraint on the array element. |
+| [`ElementType`](src/Aggregate/ElementType.php) | `ElementType(IntegerElement::class, 'configureElement')` | `...->element(IntegerElement::class, [$this, 'configureElement'])` | Define the array element type. A configuration callback method can be define for configure the inner element. |
+| [`ArrayTransformer`](src/Aggregate/ArrayTransformer.php) | `ArrayTransformer(MyTransformer::class, ['ctroarg'])` | `...->arrayTransformer(new MyTransformer('ctorarg))` | Add a transformer for the whole array input. |
diff --git a/composer.json b/composer.json
index 2bcae80..ff529c0 100755
--- a/composer.json
+++ b/composer.json
@@ -1,11 +1,13 @@
{
"name": "b2pweb/bdf-form",
"description": "Simple and flexible form library",
+ "keywords": ["attributes", "PHP 8", "form", "validator"],
"type": "library",
"license": "MIT",
"authors": [
{
- "name": "Vincent Quatrevieux"
+ "name": "Vincent Quatrevieux",
+ "email": "vquatrevieux@b2pweb.com"
}
],
"autoload": {
@@ -15,19 +17,21 @@
},
"autoload-dev": {
"psr-4": {
- "Bdf\\Form\\": "tests"
+ "Bdf\\Form\\": "tests",
+ "Tests\\Form\\Attribute\\": "tests/Attribute"
}
},
"require": {
"php": "~8.4",
"symfony/property-access": "~6.4|~7.0|~8.0",
- "symfony/validator": "~6.4|~7.0|~8.0"
+ "symfony/validator": "~6.4|~7.0|~8.0",
+ "nette/php-generator": "~3.6|~4.0"
},
"require-dev": {
"symfony/security-csrf": "~6.4|~7.0|~8.0",
"giggsey/libphonenumber-for-php": "~8.0|~9.0",
"phpunit/phpunit": "~13.0",
- "vimeo/psalm": "~6.15.1",
+ "vimeo/psalm": "^6.16.0",
"symfony/http-foundation": "~6.4|~7.0|~8.0",
"symfony/form": "~6.4|~7.0|~8.0"
},
diff --git a/src/Attribute/Aggregate/ArrayConstraint.php b/src/Attribute/Aggregate/ArrayConstraint.php
new file mode 100644
index 0000000..c179498
--- /dev/null
+++ b/src/Attribute/Aggregate/ArrayConstraint.php
@@ -0,0 +1,76 @@
+
+ * $builder->array('values')->arrayConstraints(MyConstraint::class, $options);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[ArrayConstraint(Unique::class, ['message' => 'My error'])]
+ * private ArrayElement $values;
+ *
+ * // or on PHP 8.1
+ * #[ArrayConstraint(new Unique(['message' => 'My error']))]
+ * private ArrayElement $values;
+ * }
+ *
+ *
+ * @see Satisfy Attribute for add constraint for items
+ * @see ArrayElementBuilder::arrayConstraint() The called method
+ * @see CallbackArrayConstraint Use for a custom method validation
+ *
+ * @implements ChildBuilderAttributeInterface
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class ArrayConstraint implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The constraint
+ *
+ * You can use a class name, and provider arguments on the next parameter,
+ * or directly use the constraint instance.
+ *
+ * When a constraint instance is used, in case of code generation,
+ * the constructor parameters will be deduced from public properties of the constraint.
+ * This may not work if the constraint has a complex constructor.
+ *
+ * @var Constraint
+ */
+ private Constraint $constraint,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->arrayConstraint($this->constraint);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $constraint = ObjectInstantiation::promotedProperties($this->constraint)->render($generator);
+ $generator->line('$?->arrayConstraint(?);', [$name, $constraint]);
+ }
+}
diff --git a/src/Attribute/Aggregate/ArrayTransformer.php b/src/Attribute/Aggregate/ArrayTransformer.php
new file mode 100644
index 0000000..7dfef5f
--- /dev/null
+++ b/src/Attribute/Aggregate/ArrayTransformer.php
@@ -0,0 +1,45 @@
+
+ * $builder->string('foo')->arrayTransformer(new MyTransformer(...$arguments));
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[ArrayTransformer(MyTransformer::class, ['foo', 'bar']), ElementType(IntegerElement::class)]
+ * private ArrayElement $foo;
+ * }
+ *
+ *
+ * @see ArrayElementBuilder::arrayTransformer() The called method
+ * @see CallbackTransformer For use custom methods as transformer instead of class
+ * @see Transformer To add a transformer the item of the array
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+class ArrayTransformer extends Transformer
+{
+ /**
+ * @param class-string $transformerClass The transformer class name
+ * @param array $constructorArguments Arguments to provide on the transformer constructor
+ */
+ public function __construct(string $transformerClass, array $constructorArguments = [])
+ {
+ parent::__construct($transformerClass, $constructorArguments, true);
+ }
+}
diff --git a/src/Attribute/Aggregate/AsArrayConstraint.php b/src/Attribute/Aggregate/AsArrayConstraint.php
new file mode 100644
index 0000000..b04090a
--- /dev/null
+++ b/src/Attribute/Aggregate/AsArrayConstraint.php
@@ -0,0 +1,88 @@
+
+ * $builder->array('foo')->arrayConstraint([$this, 'validateFoo'], 'Foo is invalid');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * private ArrayElement $foo;
+ *
+ * #[AsArrayConstraint('foo', message: 'Foo is invalid')]
+ * public function validateFoo(array $value, ElementInterface $input): bool
+ * {
+ * return count($value) % 5 > 2;
+ * }
+ * }
+ *
+ *
+ * @see ArrayElementBuilder::arrayConstraint() The called method
+ * @see Constraint
+ * @see Closure The used constraint
+ * @see ArrayConstraint Use for a class constraint
+ * @see CallbackArrayConstraint To annotate the property instead of the method
+ *
+ * @implements MethodChildBuilderAttributeInterface
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
+final readonly class AsArrayConstraint implements MethodChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The element property name to which the constraint is applied
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $target,
+ /**
+ * The error message to use
+ * This option is used only if the validator return false, in other cases,
+ * the message returned by the validator will be used
+ *
+ * @var string|null
+ * @readonly
+ */
+ private ?string $message = null,
+ ) {
+ }
+
+ #[Override]
+ public function targetElements(): array
+ {
+ return [$this->target];
+ }
+
+ #[Override]
+ public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface
+ {
+ return new CallbackArrayConstraint($method->getName(), $this->message);
+ }
+}
diff --git a/src/Attribute/Aggregate/CallbackArrayConstraint.php b/src/Attribute/Aggregate/CallbackArrayConstraint.php
new file mode 100644
index 0000000..202a6d5
--- /dev/null
+++ b/src/Attribute/Aggregate/CallbackArrayConstraint.php
@@ -0,0 +1,98 @@
+
+ * $builder->array('foo')->arrayConstraint([$this, 'validateFoo'], 'Foo is invalid');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[CustomConstraint('validateFoo', message: 'Foo is invalid')]
+ * private ArrayElement $foo;
+ *
+ * public function validateFoo(array $value, ElementInterface $input): bool
+ * {
+ * return count($value) % 5 > 2;
+ * }
+ * }
+ *
+ *
+ * @see ArrayElementBuilder::arrayConstraint() The called method
+ * @see Constraint
+ * @see Closure The used constraint
+ * @see ArrayConstraint Use for a class constraint
+ *
+ * @implements ChildBuilderAttributeInterface
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class CallbackArrayConstraint implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The method name to use as validator
+ * Must be a public method declared on the form class
+ *
+ * Its prototype should be :
+ * `public function (array $value, ElementInterface $input): bool|string|array{code: string message: string}|null`
+ *
+ * - Return true, or null (nothing) for a valid input
+ * - Return false for invalid input, with the default error message (or the declared one)
+ * - Return string for a custom error message
+ * - Return array with error message and code
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $methodName,
+ /**
+ * The error message to use
+ * This option is used only if the validator return false, in other cases,
+ * the message returned by the validator will be used
+ *
+ * @var string|null
+ * @readonly
+ */
+ private ?string $message = null,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $constraint = new Closure($form->{$this->methodName}(...), $this->message);
+
+ $builder->arrayConstraint($constraint);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->use(Closure::class, 'ClosureConstraint');
+
+ $parameters = $this->message !== null
+ ? new Literal('$form->?(...), ?', [$this->methodName, $this->message])
+ : new Literal('$form->?(...)', [$this->methodName])
+ ;
+
+ $generator->line('$?->arrayConstraint(new ClosureConstraint(?));', [$name, $parameters]);
+ }
+}
diff --git a/src/Attribute/Aggregate/Count.php b/src/Attribute/Aggregate/Count.php
new file mode 100644
index 0000000..6e30f6c
--- /dev/null
+++ b/src/Attribute/Aggregate/Count.php
@@ -0,0 +1,81 @@
+
+ * $builder->array('values')->count(['min' => 3]);
+ * $builder->array('values')->arrayConstraint(new Count(min: 3));
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Count(min: 3, max: 42)]
+ * private ArrayElement $values;
+ * }
+ *
+ *
+ * @see CountConstraint The used constraint
+ * @see ArrayElementBuilder::arrayConstraint() The called method
+ * @see ArrayElementBuilder::count() Equivalent method call
+ *
+ * @implements ChildBuilderAttributeInterface
+ *
+ * @api
+ *
+ * @psalm-suppress PropertyNotSetInConstructor
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final class Count extends CountConstraint implements ChildBuilderAttributeInterface
+{
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->arrayConstraint($this);
+ }
+
+ #[Override]
+ public function validatedBy(): string
+ {
+ return CountConstraint::class . 'Validator';
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $defaultParameters = get_class_vars(CountConstraint::class);
+ /** @var array{
+ * minMessage?: string,
+ * maxMessage?: string,
+ * exactMessage?: string,
+ * divisibleByMessage?: string,
+ * min?: int|null,
+ * max?: int|null,
+ * divisibleBy?: int|null,
+ * } $parameters
+ */
+ $parameters = get_object_vars($this);
+
+ foreach ($parameters as $paramName => $value) {
+ if (!array_key_exists($paramName, $defaultParameters) || $value === $defaultParameters[$paramName]) {
+ unset($parameters[$paramName]);
+ }
+ }
+
+ $generator->line('$?->arrayConstraint(?);', [$name, $generator->new(CountConstraint::class, $parameters)]);
+ }
+}
diff --git a/src/Attribute/Aggregate/ElementType.php b/src/Attribute/Aggregate/ElementType.php
new file mode 100644
index 0000000..f77dd69
--- /dev/null
+++ b/src/Attribute/Aggregate/ElementType.php
@@ -0,0 +1,88 @@
+
+ * $builder->array('values')->element(IntegerElement::class, [$this, 'myConfigurator']);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[ElementType(IntegerElement::class, 'configureValueItem')]
+ * private ArrayElement $values;
+ *
+ * // The method must be public and take the builder as parameter
+ * public function configureValueItem(IntegerElementBuilder $builder)
+ * {
+ * $builder->min(5); // Configure the element
+ * }
+ * }
+ *
+ *
+ * @see ArrayElementBuilder::element() The called method
+ *
+ * @implements ChildBuilderAttributeInterface
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+class ElementType implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The form element class name
+ *
+ * @var class-string
+ * @readonly
+ */
+ private string $elementType,
+ /**
+ * The element configuration method name
+ * This method must be defined on the form class, and with public visibility
+ *
+ * @var literal-string|null
+ * @readonly
+ */
+ private ?string $configurator = null
+ ) {
+ }
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $configurator = $this->configurator !== null ? [$form, $this->configurator] : null;
+ $builder->element($this->elementType, $configurator);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $elementType = new Literal($generator->useAndSimplifyType($this->elementType));
+
+ if ($this->configurator !== null) {
+ $generator->line('$?->element(?::class, [$form, ?]);', [$name, $elementType, $this->configurator]);
+ } else {
+ $generator->line('$?->element(?::class);', [$name, $elementType]);
+ }
+ }
+}
diff --git a/src/Attribute/AttributeForm.php b/src/Attribute/AttributeForm.php
new file mode 100644
index 0000000..15f0bbb
--- /dev/null
+++ b/src/Attribute/AttributeForm.php
@@ -0,0 +1,66 @@
+
+ *
+ * @api
+ */
+abstract class AttributeForm extends CustomForm
+{
+ /**
+ * Implementation use to process attributes and properties
+ * and for configure the form builder
+ *
+ * @var AttributesProcessorInterface
+ * @readonly
+ */
+ private AttributesProcessorInterface $processor;
+
+ /**
+ * Action to perform after the form was built
+ *
+ * @var PostConfigureInterface|null
+ */
+ private ?PostConfigureInterface $postConfigure = null;
+
+ /**
+ * @param FormBuilderInterface|null $builder The form builder using by CustomForm
+ * @param AttributesProcessorInterface|null $processor The attributes processor.
+ * By default, use ReflectionProcessor with ConfigureFormBuilderStrategy as strategy
+ */
+ public function __construct(?FormBuilderInterface $builder = null, ?AttributesProcessorInterface $processor = null)
+ {
+ parent::__construct($builder);
+
+ $this->processor = $processor ?? new ReflectionProcessor(new ConfigureFormBuilderStrategy());
+ }
+
+ #[Override]
+ protected function configure(FormBuilderInterface $builder): void
+ {
+ $this->postConfigure = $this->processor->configureBuilder($this, $builder);
+ }
+
+ #[Override]
+ public function postConfigure(FormInterface $form): void
+ {
+ if ($this->postConfigure) {
+ $this->postConfigure->postConfigure($this, $form);
+ $this->postConfigure = null;
+ }
+ }
+}
diff --git a/src/Attribute/Button/ButtonBuilderAttributeInterface.php b/src/Attribute/Button/ButtonBuilderAttributeInterface.php
new file mode 100644
index 0000000..3dab8c1
--- /dev/null
+++ b/src/Attribute/Button/ButtonBuilderAttributeInterface.php
@@ -0,0 +1,36 @@
+
+ * $builder->button('btn')->groups(['Foo', 'Bar']);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Groups('Foo', 'Bar')]
+ * private ButtonInterface $btn;
+ * }
+ *
+ *
+ * @see ButtonBuilderInterface::groups() The called method
+ * @see ButtonInterface::constraintGroups() Modify this value
+ * @see RootElementInterface::constraintGroups()
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final class Groups implements ButtonBuilderAttributeInterface
+{
+ /**
+ * @var list
+ * @readonly
+ */
+ private array $groups;
+
+ /**
+ * @param string ...$groups List of validation groups
+ * @no-named-arguments
+ */
+ public function __construct(string ...$groups)
+ {
+ $this->groups = $groups;
+ }
+
+ #[Override]
+ public function applyOnButtonBuilder(AttributeForm $form, ButtonBuilderInterface $builder): void
+ {
+ $builder->groups($this->groups);
+ }
+
+ #[Override]
+ public function generateCodeForButtonBuilder(AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line(' ->groups(?)', [$this->groups]);
+ }
+}
diff --git a/src/Attribute/Button/Value.php b/src/Attribute/Button/Value.php
new file mode 100644
index 0000000..c18985c
--- /dev/null
+++ b/src/Attribute/Button/Value.php
@@ -0,0 +1,57 @@
+
+ * $builder->button('btn')->value('Foo');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Value('Foo')]
+ * private ButtonInterface $btn;
+ * }
+ *
+ *
+ * @see ButtonBuilderInterface::value() The called method
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class Value implements ButtonBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The button HTTP value
+ * @readonly
+ */
+ private string $value,
+ ) {}
+
+ #[Override]
+ public function applyOnButtonBuilder(AttributeForm $form, ButtonBuilderInterface $builder): void
+ {
+ $builder->value($this->value);
+ }
+
+ #[Override]
+ public function generateCodeForButtonBuilder(AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line(' ->value(?)', [$this->value]);
+ }
+}
diff --git a/src/Attribute/Child/AsFilter.php b/src/Attribute/Child/AsFilter.php
new file mode 100644
index 0000000..51bfa44
--- /dev/null
+++ b/src/Attribute/Child/AsFilter.php
@@ -0,0 +1,71 @@
+
+ * $builder->string('foo')->filter([$this, 'myTransformer']);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * private IntegerElement $foo;
+ *
+ * #[AsFilter('filterFoo')]
+ * public function filterFoo($value, ChildInterface $child, $default): string
+ * {
+ * return hexdec($value);
+ * }
+ * }
+ *
+ *
+ * @implements MethodChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilderInterface::filter() The called method
+ * @see ClosureFilter The used filter class
+ * @see CallbackTransformer For use transformer in same way, but for http transformer intead of filter one
+ * @see CallbackFilter To annotate the property instead
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_METHOD)]
+final readonly class AsFilter implements MethodChildBuilderAttributeInterface
+{
+ /**
+ * @var list
+ * @readonly
+ */
+ private array $targets;
+
+ /**
+ * @param non-empty-string ...$targets List of elements names that the attribute can be applied on. Must be a property name.
+ * @no-named-arguments
+ */
+ public function __construct(string ...$targets)
+ {
+ $this->targets = $targets;
+ }
+
+ #[Override]
+ public function targetElements(): array
+ {
+ return $this->targets;
+ }
+
+ #[Override]
+ public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface
+ {
+ return new CallbackFilter($method->getName());
+ }
+}
diff --git a/src/Attribute/Child/AsModelTransformer.php b/src/Attribute/Child/AsModelTransformer.php
new file mode 100644
index 0000000..8448f09
--- /dev/null
+++ b/src/Attribute/Child/AsModelTransformer.php
@@ -0,0 +1,79 @@
+
+ * $builder->string('foo')->modelTransformer([$this, 'myTransformer']);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * private IntegerElement $bar;
+ *
+ * #[AsModelTransformer('bar')]
+ * public function barTransformer($value, IntegerElement $input, bool $toEntity)
+ * {
+ * return $toEntity ? dechex($value) : hexdec($value);
+ * }
+ * }
+ *
+ *
+ * @implements MethodChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilderInterface::modelTransformer() The called method
+ * @see ModelTransformer For use a transformer class as model transformer
+ * @see CallbackTransformer For use transformer in same way, but for http transformer intead of model one
+ * @see CallbackModelTransformer To annotate the property instead
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_METHOD)]
+final readonly class AsModelTransformer implements MethodChildBuilderAttributeInterface
+{
+ /**
+ * @var list
+ * @readonly
+ */
+ private array $targets;
+
+ /**
+ * @param non-empty-string ...$targets Target elements properties names
+ * @no-named-arguments
+ */
+ public function __construct(string ...$targets)
+ {
+ $this->targets = $targets;
+ }
+
+ #[Override]
+ public function targetElements(): array
+ {
+ return $this->targets;
+ }
+
+ #[Override]
+ public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface
+ {
+ return new CallbackModelTransformer($method->getName());
+ }
+}
diff --git a/src/Attribute/Child/CallbackFilter.php b/src/Attribute/Child/CallbackFilter.php
new file mode 100644
index 0000000..ebedaf9
--- /dev/null
+++ b/src/Attribute/Child/CallbackFilter.php
@@ -0,0 +1,70 @@
+
+ * $builder->string('foo')->filter([$this, 'myTransformer']);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[CallbackFilter('filterFoo')]
+ * private IntegerElement $foo;
+ *
+ * public function filterFoo($value, ChildInterface $child, $default): string
+ * {
+ * return hexdec($value);
+ * }
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilderInterface::filter() The called method
+ * @see ClosureFilter The used filter class
+ * @see CallbackTransformer For use transformer in same way, but for http transformer intead of filter one
+ * @see AsFilter To annotate a method as filter
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class CallbackFilter implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The method name to use as filter
+ *
+ * The method must be public and follow the signature `function (mixed $value, ElementInterface $input, mixed|null $default): mixed`
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $method,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->filter([$form, $this->method]);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->filter([$form, ?]);', [$name, $this->method]);
+ }
+}
diff --git a/src/Attribute/Child/CallbackModelTransformer.php b/src/Attribute/Child/CallbackModelTransformer.php
new file mode 100644
index 0000000..ecaba17
--- /dev/null
+++ b/src/Attribute/Child/CallbackModelTransformer.php
@@ -0,0 +1,173 @@
+
+ * // For unified callback
+ * $builder->string('foo')->modelTransformer([$this, 'myTransformer']);
+ *
+ * // When using two methods (toEntity: 'transformFooToEntity', toInput: 'transformFooToInput')
+ * $builder->string('foo')->modelTransformer(function ($value, ElementInterface $input, bool $toEntity) {
+ * return $toEntity ? $this->transformFooToEntity($value, $input) : $this->transformFooToInput($value, $input);
+ * });
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[CallbackModelTransformer(toEntity: 'fooToModel', toInput: 'fooToInput')]
+ * private IntegerElement $foo;
+ *
+ * // With unified transformer (same as above)
+ * #[CallbackModelTransformer('barTransformer')]
+ * private IntegerElement $bar;
+ *
+ * public function fooToModel(int $value, IntegerElement $input): string
+ * {
+ * return dechex($value);
+ * }
+ *
+ * public function fooToInput(string $value, IntegerElement $input): int
+ * {
+ * return hexdec($value);
+ * }
+ *
+ * public function barTransformer($value, IntegerElement $input, bool $toEntity)
+ * {
+ * return $toEntity ? dechex($value) : hexdec($value);
+ * }
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilderInterface::modelTransformer() The called method
+ * @see ModelTransformer For use a transformer class as model transformer
+ * @see CallbackTransformer For use transformer in same way, but for http transformer intead of model one
+ * @see AsModelTransformer To annotate the method instead
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class CallbackModelTransformer implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * Method name use to define the unified transformer method
+ * If defined, the other parameters will be ignored
+ *
+ * @var non-empty-string|null
+ * @readonly
+ */
+ private ?string $callback = null,
+ /**
+ * Method name use to define the transformation process from input value to the entity
+ *
+ * @var non-empty-string|null
+ * @readonly
+ */
+ private ?string $toEntity = null,
+ /**
+ * Method name use to define the transformation process from entity value to input
+ *
+ * @var non-empty-string|null
+ * @readonly
+ */
+ private ?string $toInput = null,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ if ($this->callback !== null) {
+ $builder->modelTransformer([$form, $this->callback]);
+ return;
+ }
+
+ $builder->modelTransformer(new class ($form, $this->toInput, $this->toEntity) implements TransformerInterface {
+ public function __construct(
+ private AttributeForm $form,
+ private ?string $toInput,
+ private ?string $toEntity,
+ ) {
+ }
+
+ #[Override]
+ public function transformToHttp(mixed $value, ElementInterface $input): mixed
+ {
+ if ($this->toInput === null) {
+ return $value;
+ }
+
+ return $this->form->{$this->toInput}($value, $input);
+ }
+
+ #[Override]
+ public function transformFromHttp(mixed $value, ElementInterface $input): mixed
+ {
+ if ($this->toEntity === null) {
+ return $value;
+ }
+
+ return $this->form->{$this->toEntity}($value, $input);
+ }
+ });
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ if ($this->callback !== null) {
+ $generator->line('$?->modelTransformer([$form, ?]);', [$name, $this->callback]);
+ return;
+ }
+
+ $transformer = new TransformerClassGenerator($generator->namespace(), $generator->printer());
+
+ $transformer->withPromotedProperty('form')->setPrivate();
+
+ if ($this->toInput !== null) {
+ $transformer->toHttp()->setBody('return $this->form->?($value, $input);', [$this->toInput]);
+ } else {
+ $transformer->toHttp()->setBody('return $value;');
+ }
+
+ if ($this->toEntity !== null) {
+ $transformer->fromHttp()->setBody('return $this->form->?($value, $input);', [$this->toEntity]);
+ } else {
+ $transformer->fromHttp()->setBody('return $value;');
+ }
+
+ $generator->line(
+ '$?->modelTransformer(new class ($form) ?);',
+ [$name, new Literal($transformer->generateClass())]
+ );
+ }
+}
diff --git a/src/Attribute/Child/Configure.php b/src/Attribute/Child/Configure.php
new file mode 100644
index 0000000..26baaa1
--- /dev/null
+++ b/src/Attribute/Child/Configure.php
@@ -0,0 +1,90 @@
+
+ * class MyForm extends AttributeForm
+ * {
+ * #[Configure('configureFoo')]
+ * private IntegerElement $foo;
+ *
+ * public function configureFoo(ChildBuilderInterface $builder): void
+ * {
+ * $builder->min(5);
+ * }
+ *
+ * // Or directly on the method (same as above)
+ * #[Configure('foo')]
+ * public function otherConfiguration(ChildBuilderInterface $builder): void
+ * {
+ * $builder->min(5);
+ * }
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ * @implements MethodChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
+class Configure implements ChildBuilderAttributeInterface, MethodChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * Thet target of the configuration.
+ *
+ * In case of a property attribute, define the method name to use as configurator.
+ * The method should follow the prototype `public function (ChildBuilderInterface $builder): void`
+ *
+ * In case of a method attribute, define the property name to configure.
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private readonly string $target,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $form->{$this->target}($builder);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$form->?($?);', [$this->target, $name]);
+ }
+
+ #[Override]
+ public function targetElements(): array
+ {
+ return [$this->target];
+ }
+
+ #[Override]
+ public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface
+ {
+ return new self($method->getName());
+ }
+}
diff --git a/src/Attribute/Child/DefaultValue.php b/src/Attribute/Child/DefaultValue.php
new file mode 100644
index 0000000..bd5dcee
--- /dev/null
+++ b/src/Attribute/Child/DefaultValue.php
@@ -0,0 +1,61 @@
+
+ * $builder->float('foo')->default(12.3);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[DefaultValue(12.3)]
+ * private FloatElement $foo;
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilderInterface::default() The called method
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class DefaultValue implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * @readonly
+ */
+ private mixed $default
+ ) {
+ }
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->default($this->default);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->default(?);', [$name, $this->default]);
+ }
+}
diff --git a/src/Attribute/Child/Dependencies.php b/src/Attribute/Child/Dependencies.php
new file mode 100644
index 0000000..a8d9bda
--- /dev/null
+++ b/src/Attribute/Child/Dependencies.php
@@ -0,0 +1,67 @@
+
+ * $builder->float('foo')->depends('bar', 'baz');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Dependencies('bar', 'rab')]
+ * private FloatElement $foo;
+ * private IntegerElement $bar;
+ * private StringElement $rab;
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilderInterface::depends() The called method
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class Dependencies implements ChildBuilderAttributeInterface
+{
+ /**
+ * @var list
+ * @readonly
+ */
+ private array $dependencies;
+
+ /**
+ * @param string ...$dependencies List of inputs names
+ * @no-named-arguments
+ */
+ public function __construct(string ...$dependencies)
+ {
+ $this->dependencies = $dependencies;
+ }
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->depends(...$this->dependencies);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->depends(...?);', [$name, $this->dependencies]);
+ }
+}
diff --git a/src/Attribute/Child/GetSet.php b/src/Attribute/Child/GetSet.php
new file mode 100644
index 0000000..1380a93
--- /dev/null
+++ b/src/Attribute/Child/GetSet.php
@@ -0,0 +1,73 @@
+
+ * $builder->float('foo')->getter('bar')->setter('bar');
+ * $builder->float('foo')->hydrator(new Setter('bar'))->extract(new Getter('bar'));
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[GetSet('bar')]
+ * private FloatElement $foo;
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilder::getter()
+ * @see ChildBuilder::setter()
+ * @see ChildBuilderInterface::hydrator()
+ * @see ChildBuilderInterface::extractor()
+ *
+ * @see Getter For define only the extractor
+ * @see Setter For define only the hydrator
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class GetSet implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The property name to use
+ * This can be a public property, or public accessor method
+ * (optionally starting with get for the getter, and starting with set for the setter)
+ *
+ * If not provided, the input name will be used as property name
+ *
+ * @readonly
+ */
+ private ?string $propertyName = null,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->hydrator(new Setter($this->propertyName))->extractor(new Getter($this->propertyName));
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->use(Setter::class)->use(Getter::class);
+ $generator->line('$?->hydrator(new Setter(?))->extractor(new Getter(?));', [$name, $this->propertyName, $this->propertyName]);
+ }
+}
diff --git a/src/Attribute/Child/HttpField.php b/src/Attribute/Child/HttpField.php
new file mode 100644
index 0000000..4c28ed9
--- /dev/null
+++ b/src/Attribute/Child/HttpField.php
@@ -0,0 +1,74 @@
+
+ * $builder->float('foo')->httpFields(new ArrayOffsetHttpFields('_foo'));
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[HttpField('package_length')]
+ * private FloatElement $packageLength;
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilder::httpFields() The called method
+ * @see ArrayOffsetHttpFields The used HTTP fields implementation
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class HttpField implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The HTTP field name
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $name,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ if (!$builder instanceof ChildBuilder) {
+ throw new LogicException('The HttpField attribute can only be used on a ChildBuilder instance');
+ }
+
+ $builder->httpFields(new ArrayOffsetHttpFields($this->name));
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->use(ArrayOffsetHttpFields::class);
+ $generator->line('$?->httpFields(new ArrayOffsetHttpFields(?));', [$name, $this->name]);
+ }
+}
diff --git a/src/Attribute/Child/ModelTransformer.php b/src/Attribute/Child/ModelTransformer.php
new file mode 100644
index 0000000..2fcfe4c
--- /dev/null
+++ b/src/Attribute/Child/ModelTransformer.php
@@ -0,0 +1,72 @@
+
+ * $builder->string('foo')->modelTransformer(new MyTransformer(...$arguments));
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[ModelTransformer(MyTransformer::class, ['foo', 'bar'])]
+ * private IntegerElement $foo;
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChildBuilderInterface::modelTransformer() The called method
+ * @see CallbackModelTransformer For use custom methods as transformer instead of class
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class ModelTransformer implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The transformer class name
+ *
+ * @var class-string
+ * @readonly
+ */
+ private string $transformerClass,
+ /**
+ * Arguments to provide on the transformer constructor
+ *
+ * @var array
+ * @readonly
+ */
+ private array $constructorArguments = [],
+ ) {
+ }
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->modelTransformer(new $this->transformerClass(...$this->constructorArguments));
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $transformer = $generator->useAndSimplifyType($this->transformerClass);
+ $generator->line('$?->modelTransformer(new ?(...?));', [$name, new Literal($transformer), $this->constructorArguments]);
+ }
+}
diff --git a/src/Attribute/ChildBuilderAttributeInterface.php b/src/Attribute/ChildBuilderAttributeInterface.php
new file mode 100644
index 0000000..02aee07
--- /dev/null
+++ b/src/Attribute/ChildBuilderAttributeInterface.php
@@ -0,0 +1,37 @@
+ $builder The builder to configure
+ */
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void;
+
+ /**
+ * Generate the code corresponding to the attribute
+ * The generated code must perform same action as `applyOnChildBuilder()`
+ *
+ * @param non-empty-string $name The variable name without $
+ * @param AttributesProcessorGenerator $generator Code generator for the "configureBuilder" method
+ * @param AttributeForm $form The current form instance
+ *
+ * @return void
+ */
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void;
+}
diff --git a/src/Attribute/Constraint/AsConstraint.php b/src/Attribute/Constraint/AsConstraint.php
new file mode 100644
index 0000000..f3d9bf0
--- /dev/null
+++ b/src/Attribute/Constraint/AsConstraint.php
@@ -0,0 +1,88 @@
+
+ * $builder->integer('foo')->satisfy([$this, 'validateFoo'], 'Foo is invalid');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[AsConstraint('foo', message: 'Foo is invalid')]
+ * public function validateFoo($value, ElementInterface $input): bool
+ * {
+ * return $value % 5 > 2;
+ * }
+ * }
+ *
+ *
+ * @implements MethodChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ElementBuilderInterface::satisfy() The called method
+ * @see Constraint
+ * @see Closure The used constraint
+ * @see CallbackConstraint For annotate the property instead of the method
+ * @see AsArrayConstraint For use a constraint on an array element
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
+final readonly class AsConstraint implements MethodChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The element property name to which the constraint is applied
+ *
+ * @var literal-string
+ * @readonly
+ */
+ private string $target,
+ /**
+ * The error message to use
+ * This option is used only if the validator return false, in other cases,
+ * the message returned by the validator will be used
+ *
+ * @var string|null
+ * @readonly
+ */
+ private ?string $message = null,
+ ) {}
+
+ #[Override]
+ public function targetElements(): array
+ {
+ /** @var list */
+ return [$this->target];
+ }
+
+ #[Override]
+ public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface
+ {
+ return new CallbackConstraint($method->getName(), $this->message);
+ }
+}
diff --git a/src/Attribute/Constraint/CallbackConstraint.php b/src/Attribute/Constraint/CallbackConstraint.php
new file mode 100644
index 0000000..7b99553
--- /dev/null
+++ b/src/Attribute/Constraint/CallbackConstraint.php
@@ -0,0 +1,101 @@
+
+ * $builder->integer('foo')->satisfy([$this, 'validateFoo'], 'Foo is invalid');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[CustomConstraint('validateFoo', message: 'Foo is invalid')]
+ * private IntegerElement $foo;
+ *
+ * public function validateFoo($value, ElementInterface $input): bool
+ * {
+ * return $value % 5 > 2;
+ * }
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ElementBuilderInterface::satisfy() The called method
+ * @see Constraint
+ * @see Closure The used constraint
+ * @see AsConstraint To annotate the method instead of the property
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class CallbackConstraint implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The method name to use as validator
+ * Must be a public method declared on the form class
+ *
+ * Its prototype should be :
+ * `public function ($value, ElementInterface $input): bool|string|array{code: string message: string}|null`
+ *
+ * - Return true, or null (nothing) for a valid input
+ * - Return false for invalid input, with the default error message (or the declared one)
+ * - Return string for a custom error message
+ * - Return array with error message and code
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $methodName,
+ /**
+ * The error message to use
+ * This option is used only if the validator return false, in other cases,
+ * the message returned by the validator will be used
+ *
+ * @var string|null
+ * @readonly
+ */
+ private ?string $message = null,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $constraint = new Closure($form->{$this->methodName}(...), $this->message);
+
+ $builder->satisfy($constraint);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->use(Closure::class, 'ClosureConstraint');
+
+ $parameters = $this->message !== null
+ ? new Literal('$form->?(...), ?', [$this->methodName, $this->message])
+ : new Literal('$form->?(...)', [$this->methodName])
+ ;
+
+ $generator->line('$?->satisfy(new ClosureConstraint(?));', [$name, $parameters]);
+ }
+}
diff --git a/src/Attribute/Constraint/Satisfy.php b/src/Attribute/Constraint/Satisfy.php
new file mode 100644
index 0000000..81f6892
--- /dev/null
+++ b/src/Attribute/Constraint/Satisfy.php
@@ -0,0 +1,74 @@
+
+ * $builder->integer('foo')->satisfy(MyConstraint::class, $options);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Satisfy(MyConstraint::class, ['foo' => 'bar'])]
+ * private IntegerElement $foo;
+ *
+ * // or on PHP 8.1
+ * #[Satisfy(new MyConstraint(['foo' => 'bar']))]
+ * private IntegerElement $foo;
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ElementBuilderInterface::satisfy() The called method
+ * @see Constraint
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class Satisfy implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The constraint
+ *
+ * The constructor parameters will be deduced from public properties of the constraint.
+ * This may not work if the constraint has a complex constructor.
+ *
+ * @var Constraint
+ */
+ private Constraint $constraint,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->satisfy($this->constraint);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $constraint = ObjectInstantiation::promotedProperties($this->constraint)->render($generator);
+ $generator->line('$?->satisfy(?);', [$name, $constraint]);
+ }
+}
diff --git a/src/Attribute/Element/AsTransformer.php b/src/Attribute/Element/AsTransformer.php
new file mode 100644
index 0000000..0b78d39
--- /dev/null
+++ b/src/Attribute/Element/AsTransformer.php
@@ -0,0 +1,79 @@
+
+ * $builder->string('foo')->transformer([$this, 'myTransformer']);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * private IntegerElement $bar;
+ *
+ * #[AsTransformer('bar')]
+ * public function barTransformer($value, IntegerElement $input, bool $toPhp)
+ * {
+ * return $toPhp ? hexdec($value) : dechex($value);
+ * }
+ * }
+ *
+ *
+ * @implements MethodChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ElementBuilderInterface::transformer() The called method
+ * @see Transformer For use a transformer class as transformer
+ * @see AsModelTransformer For use transformer in same way, but for model transformer intead of http one
+ * @see CallbackTransformer To annotate the property instead
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_METHOD)]
+final readonly class AsTransformer implements MethodChildBuilderAttributeInterface
+{
+ /**
+ * @var list
+ * @readonly
+ */
+ private array $targets;
+
+ /**
+ * @param non-empty-string ...$targets Target elements properties names
+ * @no-named-arguments
+ */
+ public function __construct(string ...$targets)
+ {
+ $this->targets = $targets;
+ }
+
+ #[Override]
+ public function targetElements(): array
+ {
+ return $this->targets;
+ }
+
+ #[Override]
+ public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface
+ {
+ return new CallbackTransformer($method->getName());
+ }
+}
diff --git a/src/Attribute/Element/CallbackTransformer.php b/src/Attribute/Element/CallbackTransformer.php
new file mode 100644
index 0000000..0814916
--- /dev/null
+++ b/src/Attribute/Element/CallbackTransformer.php
@@ -0,0 +1,175 @@
+
+ * // For unified callback
+ * $builder->string('foo')->transformer([$this, 'myTransformer']);
+ *
+ * // When using two methods (toHttp: 'transformFooToHttp', fromHttp: 'transformFooFromHttp')
+ * $builder->string('foo')->transformer(function ($value, ElementInterface $input, bool $toPhp) {
+ * return $toPhp ? $this->transformFooFromHttp($value, $input) : $this->transformFooToHttp($value, $input);
+ * });
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[CallbackTransformer(fromHttp: 'fooFromHttp', toHttp: 'fooToHttp')]
+ * private IntegerElement $foo;
+ *
+ * // With unified transformer (same as above)
+ * #[CallbackTransformer('barTransformer')]
+ * private IntegerElement $bar;
+ *
+ * public function fooFromHttp(string $value, IntegerElement $input): int
+ * {
+ * return hexdec($value);
+ * }
+ *
+ * public function fooToHttp(int $value, IntegerElement $input): string
+ * {
+ * return dechex($value);
+ * }
+ *
+ * public function barTransformer($value, IntegerElement $input, bool $toPhp)
+ * {
+ * return $toPhp ? hexdec($value) : dechex($value);
+ * }
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ElementBuilderInterface::transformer() The called method
+ * @see Transformer For use a transformer class as transformer
+ * @see CallbackModelTransformer For use transformer in same way, but for model transformer intead of http one
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class CallbackTransformer implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * Method name use to define the unified transformer method
+ * If defined, the other parameters will be ignored
+ *
+ * @var non-empty-string|null
+ * @readonly
+ */
+ private ?string $callback = null,
+ /**
+ * Method name use to define the transformation process from http value to the input
+ *
+ * @var non-empty-string|null
+ * @readonly
+ */
+ private ?string $fromHttp = null,
+ /**
+ * Method name use to define the transformation process from input value to http format
+ *
+ * @var non-empty-string|null
+ * @readonly
+ */
+ private ?string $toHttp = null,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ if ($this->callback !== null) {
+ $builder->transformer([$form, $this->callback]);
+ return;
+ }
+
+ $builder->transformer(new class ($form, $this->fromHttp, $this->toHttp) implements TransformerInterface {
+ public function __construct(
+ private AttributeForm $form,
+ private ?string $fromHttp,
+ private ?string $toHttp,
+ ) {
+ }
+
+ #[Override]
+ public function transformToHttp(mixed $value, ElementInterface $input): mixed
+ {
+ if ($this->toHttp === null) {
+ return $value;
+ }
+
+ return $this->form->{$this->toHttp}($value, $input);
+ }
+
+ #[Override]
+ public function transformFromHttp(mixed $value, ElementInterface $input): mixed
+ {
+ if ($this->fromHttp === null) {
+ return $value;
+ }
+
+ return $this->form->{$this->fromHttp}($value, $input);
+ }
+ });
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ if ($this->callback !== null) {
+ $generator->line('$?->transformer([$form, ?]);', [$name, $this->callback]);
+ return;
+ }
+
+ $transformer = new TransformerClassGenerator($generator->namespace(), $generator->printer());
+
+ $transformer->withPromotedProperty('form')->setPrivate();
+
+ if ($this->toHttp !== null) {
+ $transformer->toHttp()->setBody('return $this->form->?($value, $input);', [$this->toHttp]);
+ } else {
+ $transformer->toHttp()->setBody('return $value;');
+ }
+
+ if ($this->fromHttp !== null) {
+ $transformer->fromHttp()->setBody('return $this->form->?($value, $input);', [$this->fromHttp]);
+ } else {
+ $transformer->fromHttp()->setBody('return $value;');
+ }
+
+ $generator->line(
+ '$?->transformer(new class ($form) ?);',
+ [$name, new Literal($transformer->generateClass())]
+ );
+ }
+}
diff --git a/src/Attribute/Element/Choices.php b/src/Attribute/Element/Choices.php
new file mode 100644
index 0000000..333925a
--- /dev/null
+++ b/src/Attribute/Element/Choices.php
@@ -0,0 +1,142 @@
+
+ * $builder->string('foo')->choices(['bar', 'baz']);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Choices(['bar', 'rab'])]
+ * private StringElement $foo;
+ *
+ * #[Choices(['My label' => 'v1', 'Other label' => 'v2'])]
+ * private StringElement $bar;
+ *
+ * #[Choices('loadBazValues', 'Invalid value')]
+ * private StringElement $baz;
+ *
+ * // For dynamic choices, or with complex logic
+ * public function loadBazValues(): array
+ * {
+ * $values = [];
+ *
+ * foreach (BazEntity::all() as $baz) {
+ * $values[$baz->label()] = $baz->id();
+ * }
+ *
+ * return $values;
+ * }
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ChoiceBuilderTrait::choices() The called method
+ * @see Choiceable Supported element type
+ * @see ArrayChoice Used when an array is given as parameter
+ * @see LazyChoice Used when a method name is given as parameter
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class Choices implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * Choice provider
+ *
+ * Can be a method name for load choices. The method must be public and declared on the form class,
+ * with the prototype `public function (): array`
+ *
+ * If the value is an array, the key will be used as label (displayed value), and the value as real value
+ * The label is not required.
+ *
+ * @var literal-string|array
+ * @readonly
+ */
+ private string|array $choices,
+ /**
+ * The error message
+ * If not provided, a default message will be used
+ *
+ * @var string|null
+ * @readonly
+ */
+ private ?string $message = null,
+ /**
+ * Extra constraint options
+ *
+ * @var array
+ * @readonly
+ */
+ private array $options = [],
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $options = $this->options;
+
+ if ($this->message !== null) {
+ $options['message'] = $this->message;
+ }
+
+ // Q&D fix for psalm because it does not recognize trait as type
+ /** @var StringElementBuilder $builder */
+ $builder->choices(
+ is_string($this->choices) ? new LazyChoice($form->{$this->choices}(...)) : $this->choices,
+ ...$options
+ );
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $options = $this->options;
+
+ if ($this->message !== null) {
+ $options['message'] = $this->message;
+ }
+
+ if (is_string($this->choices)) {
+ $generator->use(LazyChoice::class);
+ $choices = new Literal('new LazyChoice($form->?(...))', [$this->choices]);
+ } else {
+ $choices = $this->choices;
+ }
+
+ $generator->line('$?->choices(?, ...?:);', [$name, $choices, $options]);
+ }
+}
diff --git a/src/Attribute/Element/Date/AfterField.php b/src/Attribute/Element/Date/AfterField.php
new file mode 100644
index 0000000..8c8e5b5
--- /dev/null
+++ b/src/Attribute/Element/Date/AfterField.php
@@ -0,0 +1,77 @@
+
+ * $builder->dateTime('dateEnd')->afterField('dateStart');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * private DateTimeElement $dateStart;
+ *
+ * #[Dependencies('dateStart'), AfterField('dateStart')]
+ * private DateTimeElement $dateEnd;
+ * }
+ *
+ *
+ * @see \Bdf\Form\Leaf\Date\DateTimeElementBuilder::afterField() The called method
+ * @see BeforeField For the opposite constraint
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\Leaf\Date\DateTimeElementBuilder>
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class AfterField implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The field name to compare
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $field,
+ /**
+ * The error message.
+ * If not set, the default message will be used
+ *
+ * @var string|null
+ */
+ private ?string $message = null,
+ /**
+ * If true, will allow the date to be equal to the other field
+ *
+ * @var bool
+ */
+ private bool $orEqual = false,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->afterField($this->field, $this->message, $this->orEqual);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->afterField(?, ?, ?);', [$name, $this->field, $this->message, $this->orEqual]);
+ }
+}
diff --git a/src/Attribute/Element/Date/BeforeField.php b/src/Attribute/Element/Date/BeforeField.php
new file mode 100644
index 0000000..74f4911
--- /dev/null
+++ b/src/Attribute/Element/Date/BeforeField.php
@@ -0,0 +1,77 @@
+
+ * $builder->dateTime('dateEnd')->beforeField('dateStart');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * private DateTimeElement $dateStart;
+ *
+ * #[Dependencies('dateEnd'), BeforeField('dateEnd')]
+ * private DateTimeElement $dateStart;
+ * }
+ *
+ *
+ * @see \Bdf\Form\Leaf\Date\DateTimeElementBuilder::beforeField() The called method
+ * @see AfterField For the opposite constraint
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\Leaf\Date\DateTimeElementBuilder>
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class BeforeField implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The field name to compare
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $field,
+ /**
+ * The error message.
+ * If not set, the default message will be used
+ *
+ * @var string|null
+ */
+ private ?string $message = null,
+ /**
+ * If true, will allow the date to be equal to the other field
+ *
+ * @var bool
+ */
+ private bool $orEqual = false,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->beforeField($this->field, $this->message, $this->orEqual);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->beforeField(?, ?, ?);', [$name, $this->field, $this->message, $this->orEqual]);
+ }
+}
diff --git a/src/Attribute/Element/Date/DateFormat.php b/src/Attribute/Element/Date/DateFormat.php
new file mode 100644
index 0000000..fb7713d
--- /dev/null
+++ b/src/Attribute/Element/Date/DateFormat.php
@@ -0,0 +1,63 @@
+
+ * $builder->dateTime('date')->format('d/m/Y H:i');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[DateFormat('d/m/Y H:i')]
+ * private DateTimeElement $foo;
+ * }
+ *
+ *
+ * @see \Bdf\Form\Leaf\Date\DateTimeElementBuilder::format() The called method
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\Leaf\Date\DateTimeElementBuilder>
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class DateFormat implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The date format
+ *
+ * @var non-empty-string
+ * @readonly
+ *
+ * @see https://www.php.net/manual/en/datetime.createfromformat.php#refsect1-datetime.createfromformat-parameters For the format
+ */
+ private string $format,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->format($this->format);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->format(?);', [$name, $this->format]);
+ }
+}
diff --git a/src/Attribute/Element/Date/DateTimeClass.php b/src/Attribute/Element/Date/DateTimeClass.php
new file mode 100644
index 0000000..774884c
--- /dev/null
+++ b/src/Attribute/Element/Date/DateTimeClass.php
@@ -0,0 +1,62 @@
+
+ * $builder->dateTime('date')->className(Carbon::class);
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[DateTimeClass(Carbon::class)]
+ * private DateTimeElement $foo;
+ * }
+ *
+ *
+ * @see \Bdf\Form\Leaf\Date\DateTimeElementBuilder::className() The called method
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\Leaf\Date\DateTimeElementBuilder>
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class DateTimeClass implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The datetime class to use
+ *
+ * @var class-string<\DateTimeInterface>
+ * @readonly
+ */
+ private string $className,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->className($this->className);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->className(?::class);', [$name, new Literal($generator->useAndSimplifyType($this->className))]);
+ }
+}
diff --git a/src/Attribute/Element/Date/ImmutableDateTime.php b/src/Attribute/Element/Date/ImmutableDateTime.php
new file mode 100644
index 0000000..0d781eb
--- /dev/null
+++ b/src/Attribute/Element/Date/ImmutableDateTime.php
@@ -0,0 +1,52 @@
+
+ * $builder->dateTime('date')->immutable();
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[ImmutableDateTime]
+ * private DateTimeElement $foo;
+ * }
+ *
+ *
+ * @see \Bdf\Form\Leaf\Date\DateTimeElementBuilder::immutable() The called method
+ * @see DateTimeClass For use a custom class name
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\Leaf\Date\DateTimeElementBuilder>
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class ImmutableDateTime implements ChildBuilderAttributeInterface
+{
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->immutable();
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->immutable();', [$name]);
+ }
+}
diff --git a/src/Attribute/Element/Date/Timezone.php b/src/Attribute/Element/Date/Timezone.php
new file mode 100644
index 0000000..58ac15f
--- /dev/null
+++ b/src/Attribute/Element/Date/Timezone.php
@@ -0,0 +1,61 @@
+
+ * $builder->dateTime('date')->timezone('Europe/Paris');
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Timezone('Europe/Paris')]
+ * private DateTimeElement $foo;
+ * }
+ *
+ *
+ * @see \Bdf\Form\Leaf\Date\DateTimeElementBuilder::timezone() The called method
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\Leaf\Date\DateTimeElementBuilder>
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class Timezone implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The timezone name or offset
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $timezone,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->timezone($this->timezone);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->timezone(?);', [$name, $this->timezone]);
+ }
+}
diff --git a/src/Attribute/Element/IgnoreTransformerException.php b/src/Attribute/Element/IgnoreTransformerException.php
new file mode 100644
index 0000000..1e825ee
--- /dev/null
+++ b/src/Attribute/Element/IgnoreTransformerException.php
@@ -0,0 +1,66 @@
+
+ * $builder->string('foo')->ignoreTransformerException();
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[MyTransformer, IgnoreTransformerException]
+ * private StringElement $foo;
+ * }
+ *
+ *
+ * @see ValidatorBuilderTrait::ignoreTransformerException() The called method
+ *
+ * @implements ChildBuilderAttributeInterface
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class IgnoreTransformerException implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * Ignore or not transformer errors
+ *
+ * @readonly
+ */
+ private bool $ignore = true,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->ignoreTransformerException($this->ignore);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->ignoreTransformerException(?);', [$name, $this->ignore]);
+ }
+}
diff --git a/src/Attribute/Element/Raw.php b/src/Attribute/Element/Raw.php
new file mode 100644
index 0000000..3683bb9
--- /dev/null
+++ b/src/Attribute/Element/Raw.php
@@ -0,0 +1,61 @@
+
+ * $builder->float('foo')->raw();
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Raw]
+ * private IntegerElement $foo;
+ * }
+ *
+ *
+ * @see NumberElementBuilder::raw() The called method
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\Leaf\NumberElementBuilder>
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class Raw implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * Enable or disable raw mode for parsing numbers
+ *
+ * @readonly
+ */
+ private bool $flag = true,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->raw($this->flag);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->raw(?);', [$name, $this->flag]);
+ }
+}
diff --git a/src/Attribute/Element/Required.php b/src/Attribute/Element/Required.php
new file mode 100644
index 0000000..3d4668c
--- /dev/null
+++ b/src/Attribute/Element/Required.php
@@ -0,0 +1,62 @@
+
+ * $builder->float('foo')->required();
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Required]
+ * private IntegerElement $foo;
+ * }
+ *
+ *
+ * @see AbstractElementBuilder::required() The called method
+ *
+ * @implements ChildBuilderAttributeInterface
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY)]
+final readonly class Required implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * Define the message to display if the element is not filled
+ * If not set, the default message will be used
+ *
+ * @readonly
+ */
+ private ?string $message = null,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $builder->required($this->message);
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?->required(?);', [$name, $this->message]);
+ }
+}
diff --git a/src/Attribute/Element/Transformer.php b/src/Attribute/Element/Transformer.php
new file mode 100644
index 0000000..01767f1
--- /dev/null
+++ b/src/Attribute/Element/Transformer.php
@@ -0,0 +1,100 @@
+
+ * $builder->string('foo')->transformer(new MyTransformer(...$arguments));
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[Transformer(MyTransformer::class, ['foo', 'bar'])]
+ * private IntegerElement $foo;
+ * }
+ *
+ *
+ * @implements ChildBuilderAttributeInterface<\Bdf\Form\ElementBuilderInterface>
+ *
+ * @see ElementBuilderInterface::transformer() The called method
+ * @see ArrayElementBuilder::arrayTransformer() The called method if array flag is set
+ * @see CallbackTransformer For use custom methods as transformer instead of class
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+class Transformer implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The transformer class name
+ *
+ * @var class-string
+ * @readonly
+ */
+ private readonly string $transformerClass,
+ /**
+ * Arguments to provide on the transformer constructor
+ *
+ * @var array
+ * @readonly
+ */
+ private readonly array $constructorArguments = [],
+ /**
+ * Apply the transformer on the whole array element
+ * instead of each element
+ *
+ * If set to true, {@see ArrayElementBuilder::arrayTransformer()} will be used
+ *
+ * Note: this flag can be used only on array element
+ *
+ * @var bool
+ * @readonly
+ *
+ * @see ArrayTransformer Prefer use this attribute for array element, instead of manually set this flag
+ */
+ private readonly bool $array = false,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ $transformer = new $this->transformerClass(...$this->constructorArguments);
+
+ if ($this->array) {
+ /** @var ChildBuilderInterface $builder */
+ $builder->arrayTransformer($transformer);
+ } else {
+ $builder->transformer($transformer);
+ }
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $transformer = $generator->useAndSimplifyType($this->transformerClass);
+ $code = $this->array ? '$?->arrayTransformer(new ?(...?));' : '$?->transformer(new ?(...?));';
+
+ $generator->line($code, [$name, new Literal($transformer), $this->constructorArguments]);
+ }
+}
diff --git a/src/Attribute/Element/TransformerError.php b/src/Attribute/Element/TransformerError.php
new file mode 100644
index 0000000..3051690
--- /dev/null
+++ b/src/Attribute/Element/TransformerError.php
@@ -0,0 +1,110 @@
+
+ * $builder->string('foo')
+ * ->transformerErrorMessage('Foo is in invalid format')
+ * ->transformerErrorCode('FOO_FORMAT_ERROR')
+ * ->transformerExceptionValidation([$this, 'fooTransformerExceptionValidation'])
+ * ;
+ *
+ *
+ * Usage:
+ *
+ * class MyForm extends AttributeForm
+ * {
+ * #[MyTransformer, TransformerError(message: 'Foo is in invalid format', code: 'FOO_FORMAT_ERROR')]
+ * private StringElement $foo;
+ * }
+ *
+ *
+ * @see ValidatorBuilderTrait::transformerErrorMessage() The called method when message parameter is provided
+ * @see ValidatorBuilderTrait::transformerErrorCode() The called method when code parameter is provided
+ * @see ValidatorBuilderTrait::transformerExceptionValidation() The called method when validationCallback parameter is provided
+ *
+ * @implements ChildBuilderAttributeInterface
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final readonly class TransformerError implements ChildBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The error message to show when transformer fail
+ *
+ * @readonly
+ */
+ private ?string $message = null,
+ /**
+ * The error code to provide when transformer fail
+ *
+ * @readonly
+ */
+ private ?string $code = null,
+ /**
+ * Method name to use for validate the transformer exception
+ *
+ * This method must be public and declared on the form class, and follow the prototype :
+ * `public function ($value, TransformerExceptionConstraint $constraint, ElementInterface $element): bool`
+ *
+ * If the method return false, the exception will be ignored
+ * Else, the method should fill `TransformerExceptionConstraint` with error message and code to provide the custom error
+ *
+ * @var literal-string|null
+ * @readonly
+ */
+ private ?string $validationCallback = null,
+ ) {}
+
+ #[Override]
+ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void
+ {
+ if ($this->message !== null) {
+ $builder->transformerErrorMessage($this->message);
+ }
+
+ if ($this->code !== null) {
+ $builder->transformerErrorCode($this->code);
+ }
+
+ if ($this->validationCallback !== null) {
+ $builder->transformerExceptionValidation([$form, $this->validationCallback]);
+ }
+ }
+
+ #[Override]
+ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$?', [$name]);
+
+ if ($this->message !== null) {
+ $generator->line(' ->transformerErrorMessage(?)', [$this->message]);
+ }
+
+ if ($this->code !== null) {
+ $generator->line(' ->transformerErrorCode(?)', [$this->code]);
+ }
+
+ if ($this->validationCallback !== null) {
+ $generator->line(' ->transformerExceptionValidation([$form, ?])', [$this->validationCallback]);
+ }
+
+ $generator->line(';');
+ }
+}
diff --git a/src/Attribute/Form/CallbackGenerator.php b/src/Attribute/Form/CallbackGenerator.php
new file mode 100644
index 0000000..386b2b9
--- /dev/null
+++ b/src/Attribute/Form/CallbackGenerator.php
@@ -0,0 +1,67 @@
+
+ * $builder->generates([$this, 'generateValue']);
+ *
+ *
+ * Usage:
+ *
+ * #[CallbackGenerator('generateValue')]
+ * class MyForm extends AttributeForm
+ * {
+ * public function generateValue(FormInterface $form)
+ * {
+ * return new Foo();
+ * }
+ * }
+ *
+ *
+ * @see FormBuilderInterface::generates() The called method
+ * @see ValueGenerator
+ * @see Generates For generate with a simple class name
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_CLASS)]
+final readonly class CallbackGenerator implements FormBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The method name use for generate the form value
+ * This method should be public, and declared on the form class, following the prototype :
+ * `public function (FormInterface $form): mixed`
+ *
+ * @readonly
+ */
+ private string $callback,
+ ) {}
+
+ #[Override]
+ public function applyOnFormBuilder(AttributeForm $form, FormBuilderInterface $builder): void
+ {
+ $builder->generates([$form, $this->callback]);
+ }
+
+ #[Override]
+ public function generateCodeForFormBuilder(AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line('$builder->generates([$this, ?]);', [$this->callback]);
+ }
+}
diff --git a/src/Attribute/Form/Csrf.php b/src/Attribute/Form/Csrf.php
new file mode 100644
index 0000000..5e5c25c
--- /dev/null
+++ b/src/Attribute/Form/Csrf.php
@@ -0,0 +1,123 @@
+
+ * $builder->csrf('csrf')->message('Token invalide');
+ *
+ *
+ * Usage:
+ *
+ * #[Csrf(name: 'csrf', message: 'Token invalide')]
+ * class MyForm extends AttributeForm
+ * {
+ * }
+ *
+ *
+ * @see FormBuilderInterface::csrf() The called method
+ * @see CsrfElementBuilder
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_CLASS)]
+final readonly class Csrf implements FormBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The token input name
+ *
+ * @var non-empty-string
+ * @readonly
+ */
+ private string $name = '_token',
+ /**
+ * The token id
+ * By default is value is the class name of `CsrfElement`
+ *
+ * @var string|null
+ * @readonly
+ *
+ * @see CsrfTokenManagerInterface::getToken() The parameter tokenId will be used as parameter of this method
+ * @see CsrfElementBuilder::tokenId() The called method if defined
+ */
+ private ?string $tokenId = null,
+ /**
+ * The error message to display if the token do not correspond
+ *
+ * @var string|null
+ * @readonly
+ *
+ * @see CsrfElementBuilder::message() The called method if defined
+ */
+ private ?string $message = null,
+ /**
+ * Always invalidate the CSRF token after submission
+ *
+ * @var bool|null
+ * @readonly
+ *
+ * @see CsrfElementBuilder::invalidate() The called method if defined
+ */
+ private ?bool $invalidate = null,
+ ) {}
+
+ #[Override]
+ public function applyOnFormBuilder(AttributeForm $form, FormBuilderInterface $builder): void
+ {
+ $csrf = $builder->csrf($this->name);
+
+ if ($this->tokenId !== null) {
+ $csrf->tokenId($this->tokenId);
+ }
+
+ if ($this->message !== null) {
+ $csrf->message($this->message);
+ }
+
+ if ($this->invalidate !== null) {
+ $csrf->invalidate($this->invalidate);
+ }
+ }
+
+ #[Override]
+ public function generateCodeForFormBuilder(AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $parameters = [$this->name];
+ $line = '$builder->csrf(?)';
+
+ if ($this->tokenId !== null) {
+ $line .= '->tokenId(?)';
+ $parameters[] = $this->tokenId;
+ }
+
+ if ($this->message !== null) {
+ $line .= '->message(?)';
+ $parameters[] = $this->message;
+ }
+
+ if ($this->invalidate !== null) {
+ $line .= '->invalidate(?)';
+ $parameters[] = $this->invalidate;
+ }
+
+ $line .= ';';
+
+ $generator->line($line, $parameters);
+ }
+}
diff --git a/src/Attribute/Form/FormBuilderAttributeInterface.php b/src/Attribute/Form/FormBuilderAttributeInterface.php
new file mode 100644
index 0000000..240dba8
--- /dev/null
+++ b/src/Attribute/Form/FormBuilderAttributeInterface.php
@@ -0,0 +1,37 @@
+
+ * $builder->generates(MyEntity::class);
+ *
+ *
+ * Usage:
+ *
+ * #[Generates(MyEntity::class)]
+ * class MyForm extends AttributeForm
+ * {
+ * // ...
+ * }
+ *
+ *
+ * @see FormBuilderInterface::generates() The called method
+ * @see ValueGenerator
+ * @see CallbackGenerator For generate using a custom method
+ *
+ * @api
+ */
+#[Attribute(Attribute::TARGET_CLASS)]
+final readonly class Generates implements FormBuilderAttributeInterface
+{
+ public function __construct(
+ /**
+ * The entity class name to generate
+ *
+ * @var class-string
+ * @readonly
+ */
+ private string $className,
+ ) {}
+
+ #[Override]
+ public function applyOnFormBuilder(AttributeForm $form, FormBuilderInterface $builder): void
+ {
+ $builder->generates($this->className);
+ }
+
+ #[Override]
+ public function generateCodeForFormBuilder(AttributesProcessorGenerator $generator, AttributeForm $form): void
+ {
+ $generator->line(
+ '$builder->generates(?::class);',
+ [
+ new Literal($generator->useAndSimplifyType($this->className))
+ ]
+ );
+ }
+}
diff --git a/src/Attribute/Processor/AttributesProcessorInterface.php b/src/Attribute/Processor/AttributesProcessorInterface.php
new file mode 100644
index 0000000..9de13b1
--- /dev/null
+++ b/src/Attribute/Processor/AttributesProcessorInterface.php
@@ -0,0 +1,26 @@
+addClass(substr($className, $classNamePos + 1))
+ );
+
+ $this->implements(AttributesProcessorInterface::class);
+
+ $this->method = $this->implementsMethod(AttributesProcessorInterface::class, 'configureBuilder');
+ }
+
+ /**
+ * Add a line on the body method of "configureBuilder"
+ *
+ * @param string $line Line to add. Set empty string (default parameter) to simply add empty new line
+ * @param array $args Placeholder arguments
+ *
+ * @return self
+ */
+ public function line(string $line = '', array $args = []): self
+ {
+ $this->method->addBody($line, $args);
+
+ return $this;
+ }
+
+ /**
+ * Create a new expression, with use class name
+ *
+ * @param class-string $className The class to create
+ * @param array $parameters The constructor parameters
+ * @param string|null $classAlias Class alias to use
+ *
+ * @return Literal
+ */
+ public function new(string $className, array $parameters, ?string $classAlias = null): Literal
+ {
+ $className = $this->useAndSimplifyType($className, $classAlias);
+
+ return new Literal('new ?(...?:)', [new Literal($className), $parameters]);
+ }
+
+ /**
+ * Print the class code
+ *
+ * @return string
+ */
+ public function print(): string
+ {
+ return $this->printer()->printNamespace($this->namespace());
+ }
+}
diff --git a/src/Attribute/Processor/CodeGenerator/ClassGenerator.php b/src/Attribute/Processor/CodeGenerator/ClassGenerator.php
new file mode 100644
index 0000000..521c0c1
--- /dev/null
+++ b/src/Attribute/Processor/CodeGenerator/ClassGenerator.php
@@ -0,0 +1,153 @@
+namespace = $namespace;
+ $this->class = $class;
+ $this->printer = $printer ?? new PsrPrinter();
+ }
+
+ /**
+ * Get the namespace object
+ *
+ * @return PhpNamespace
+ */
+ final public function namespace(): PhpNamespace
+ {
+ return $this->namespace;
+ }
+
+ /**
+ * Get the current class instance
+ *
+ * @return ClassType
+ */
+ final public function class(): ClassType
+ {
+ return $this->class;
+ }
+
+ /**
+ * Add an implemented interface on the class, and add use statement
+ *
+ * @param class-string $interface
+ *
+ * @return self
+ */
+ final public function implements(string $interface): self
+ {
+ $this->class->addImplement($interface);
+ $this->namespace->addUse($interface);
+
+ return $this;
+ }
+
+ /**
+ * Implements a method of an interface, and auto-use all declared types (parameters and return type)
+ *
+ * @param class-string $interface The interface where the method is declared
+ * @param literal-string $methodName The method name to implements
+ *
+ * @return Method
+ */
+ final public function implementsMethod(string $interface, string $methodName): Method
+ {
+ $method = Method::from([$interface, $methodName]);
+ $method->setComment('{@inheritdoc}');
+ $method->setBody(''); // Ensure that the body is not null
+
+ foreach ($method->getParameters() as $parameter) {
+ /** @var Type|null $type */
+ $type = $parameter->getType(true);
+
+ if ($type && $type->isClass()) {
+ /** @psalm-suppress PossiblyNullArgument */
+ $this->namespace->addUse($type->getSingleName());
+ }
+ }
+
+ /** @var Type|null $returnType */
+ $returnType = $method->getReturnType(true);
+
+ if ($returnType && $returnType->isClass()) {
+ /** @psalm-suppress PossiblyNullArgument */
+ $this->namespace->addUse($returnType->getSingleName());
+ }
+
+ if ($returnType !== null) {
+ $method->setReturnType((string) $returnType);
+ } else {
+ $method->setReturnType('mixed');
+ }
+
+ $this->class->addMember($method);
+
+ return $method;
+ }
+
+ /**
+ * Add use statement and simplify it
+ *
+ * @param class-string $type Type to use and simplify
+ * @param string|null $alias Use alias
+ *
+ * @return string
+ */
+ final public function useAndSimplifyType(string $type, ?string $alias = null): string
+ {
+ return $this->namespace->addUse($type, $alias)->simplifyType($type);
+ }
+
+ /**
+ * Add use statement
+ *
+ * @param class-string $type Type to use
+ * @param string|null $alias Use alias
+ *
+ * @return self
+ */
+ final public function use(string $type, ?string $alias = null): self
+ {
+ $this->namespace->addUse($type, $alias);
+
+ return $this;
+ }
+
+ /**
+ * Generate the class code
+ *
+ * @return string
+ */
+ final public function generateClass(): string
+ {
+ return $this->printer->printClass($this->class, $this->namespace);
+ }
+
+ /**
+ * Get the related printer instance
+ *
+ * @return Printer
+ */
+ final public function printer(): Printer
+ {
+ return $this->printer;
+ }
+}
diff --git a/src/Attribute/Processor/CodeGenerator/ObjectInstantiation.php b/src/Attribute/Processor/CodeGenerator/ObjectInstantiation.php
new file mode 100644
index 0000000..c51ac85
--- /dev/null
+++ b/src/Attribute/Processor/CodeGenerator/ObjectInstantiation.php
@@ -0,0 +1,103 @@
+useAndSimplifyType($this->className) : $this->className;
+
+ return Literal::new($className, $this->constructorParameters);
+ }
+
+ /**
+ * Configure the ObjectInstantiation utility to generate
+ * the constructor call using promoted properties of the given object.
+ *
+ * This method should be used for symfony constraints.
+ * Properties with default value will be ignored.
+ *
+ * @param object $object
+ * @return self
+ */
+ public static function promotedProperties(object $object): self
+ {
+ return new self(
+ get_class($object),
+ self::extractPromotedProperties($object),
+ );
+ }
+
+ /**
+ * Extract all public properties of the object, ignoring default values
+ *
+ * @param object $object
+ *
+ * @return array
+ * @psalm-suppress MixedAssignment
+ */
+ private static function extractPromotedProperties(object $object): array
+ {
+ $reflectionObject = new ReflectionObject($object);
+ $parameters = [];
+
+ foreach ($reflectionObject->getConstructor()?->getParameters() ?? [] as $param) {
+ $value = $object->{$param->name} ?? null;
+
+ // Compatibility with SF 6: ignore options parameter
+ if ($param->name === 'options' && $object instanceof Constraint) {
+ continue;
+ }
+
+ if ($param->isDefaultValueAvailable() && $value === $param->getDefaultValue()) {
+ continue;
+ }
+
+ if ($reflectionObject->hasProperty($param->name)) {
+ $property = $reflectionObject->getProperty($param->name);
+
+ if ($property->hasDefaultValue() && $value === $property->getDefaultValue()) {
+ continue;
+ }
+ }
+
+ $parameters[$param->name] = $value;
+ }
+
+ return $parameters;
+ }
+}
diff --git a/src/Attribute/Processor/CodeGenerator/TransformerClassGenerator.php b/src/Attribute/Processor/CodeGenerator/TransformerClassGenerator.php
new file mode 100644
index 0000000..f0e31f8
--- /dev/null
+++ b/src/Attribute/Processor/CodeGenerator/TransformerClassGenerator.php
@@ -0,0 +1,77 @@
+implements(TransformerInterface::class);
+
+ $this->toHttp = $this->implementsMethod(TransformerInterface::class, 'transformToHttp');
+ $this->fromHttp = $this->implementsMethod(TransformerInterface::class, 'transformFromHttp');
+ }
+
+ /**
+ * Add a new promoted property on constructor
+ *
+ * @param string $name The property name, without $
+ *
+ * @return PromotedParameter
+ */
+ public function withPromotedProperty(string $name): PromotedParameter
+ {
+ if (!$this->constructor) {
+ $this->constructor = $this->class()->addMethod('__construct');
+ }
+
+ return $this->constructor->addPromotedParameter($name);
+ }
+
+ /**
+ * Get the transformToHttp method builder
+ *
+ * @return Method
+ *
+ * @see TransformerInterface::transformToHttp()
+ */
+ public function toHttp(): Method
+ {
+ return $this->toHttp;
+ }
+
+ /**
+ * Get the transformFromHttp method builder
+ *
+ * @return Method
+ *
+ * @see TransformerInterface::transformFromHttp()
+ */
+ public function fromHttp(): Method
+ {
+ return $this->fromHttp;
+ }
+}
diff --git a/src/Attribute/Processor/CompileAttributesProcessor.php b/src/Attribute/Processor/CompileAttributesProcessor.php
new file mode 100644
index 0000000..addb6f3
--- /dev/null
+++ b/src/Attribute/Processor/CompileAttributesProcessor.php
@@ -0,0 +1,134 @@
+):non-empty-string
+ */
+ private mixed $fileNameResolver,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ *
+ * @psalm-suppress PossiblyUnusedReturnValue
+ */
+ #[Override]
+ public function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): PostConfigureInterface
+ {
+ /** @var class-string $className */
+ $className = ($this->classNameResolver)($form);
+
+ if (!class_exists($className)) {
+ /** @psalm-suppress ArgumentTypeCoercion */
+ $this->loadProcessor($className, $form, $builder);
+ }
+
+ $generated = new $className();
+ $generated->configureBuilder($form, $builder);
+
+ return $generated;
+ }
+
+ /**
+ * Generate the configurator for the given form
+ * Unlike `configureBuilder()` process, the class will be regenerated if already exists,
+ * and the class will not be included
+ *
+ * @param AttributeForm $form Form to generate
+ *
+ * @return void
+ */
+ public function generate(AttributeForm $form): void
+ {
+ /** @var class-string $className */
+ $className = ($this->classNameResolver)($form);
+ $fileName = ($this->fileNameResolver)($className);
+
+ $this->generateProcessor($fileName, $className, $form, new FormBuilder());
+ }
+
+ /**
+ * Try to load the processor from its file
+ *
+ * @param class-string $className Generated processor class name
+ * @param AttributeForm $form Form to build
+ * @param FormBuilderInterface $builder Builder to configure
+ *
+ * @return void
+ */
+ private function loadProcessor(string $className, AttributeForm $form, FormBuilderInterface $builder): void
+ {
+ $fileName = ($this->fileNameResolver)($className);
+
+ if (!file_exists($fileName)) {
+ $this->generateProcessor($fileName, $className, $form, $builder);
+ }
+
+ require_once $fileName;
+
+ if (!class_exists($className) || !is_subclass_of($className, AttributesProcessorInterface::class)) {
+ throw new LogicException('Invalid generated class "' . $className . '" in file "' . $fileName . '"');
+ }
+ }
+
+ /**
+ * Generate the processor class and save it into the given file
+ *
+ * @param string $fileName Target file
+ * @param class-string $className Generated processor class name
+ * @param AttributeForm $form Form to build
+ * @param FormBuilderInterface $builder Builder to configure
+ *
+ * @return void
+ */
+ private function generateProcessor(string $fileName, string $className, AttributeForm $form, FormBuilderInterface $builder): void
+ {
+ $generator = new GenerateConfiguratorStrategy($className);
+ $processor = new ReflectionProcessor($generator);
+
+ $processor->configureBuilder($form, $builder);
+
+ $code = $generator->code();
+
+ $dirname = dirname($fileName);
+
+ if (!is_dir($dirname)) {
+ mkdir($dirname, 0777, true);
+ }
+
+ file_put_contents($fileName, '
+ */
+ private array $elementProcessors = [];
+
+ public function __construct()
+ {
+ $this->registerElementAttributeProcessor(new ConstraintAttributeProcessor());
+ $this->registerElementAttributeProcessor(new FilterAttributeProcessor());
+ $this->registerElementAttributeProcessor(new TransformerAttributeProcessor());
+ $this->registerElementAttributeProcessor(new HydratorAttributeProcessor());
+ $this->registerElementAttributeProcessor(new ExtractorAttributeProcessor());
+ }
+
+ #[Override]
+ public function onFormClass(ReflectionClass $formClass, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void
+ {
+ foreach ($formClass->getAttributes(FormBuilderAttributeInterface::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
+ $attribute->newInstance()->applyOnFormBuilder($form, $builder);
+ }
+ }
+
+ #[Override]
+ public function onButtonProperty(ReflectionProperty $property, string $name, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void
+ {
+ $submitBuilder = $builder->submit($name);
+
+ foreach ($property->getAttributes(ButtonBuilderAttributeInterface::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
+ $attribute->newInstance()->applyOnButtonBuilder($form, $submitBuilder);
+ }
+ }
+
+ #[Override]
+ public function onElementProperty(ReflectionProperty $property, string $name, string $elementType, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void
+ {
+ $elementBuilder = $builder->add($name, $elementType);
+
+ foreach ($property->getAttributes() as $attribute) {
+ $attributeInstance = $attribute->newInstance();
+
+ if ($attributeInstance instanceof ChildBuilderAttributeInterface) {
+ $attributeInstance->applyOnChildBuilder($form, $elementBuilder);
+ continue;
+ }
+
+ foreach ($this->elementProcessors as $configurator) {
+ if ($attributeInstance instanceof ($configurator->type())) {
+ $configurator->process($elementBuilder, $attributeInstance);
+ }
+ }
+ }
+
+ foreach ($metadata->registeredChildAttributes($name) as $attribute) {
+ $attribute->applyOnChildBuilder($form, $elementBuilder);
+ }
+ }
+
+ #[Override]
+ public function onPostConfigure(ProcessorMetadata $metadata, AttributeForm $form): ?PostConfigureInterface
+ {
+ return new PostConfigureReflectionSetProperties($metadata->elementProperties(), $metadata->buttonProperties());
+ }
+
+ /**
+ * Register a new processor for element attributes
+ *
+ * @param ElementAttributeProcessorInterface $processor
+ *
+ * @return void
+ *
+ * @template T as object
+ */
+ private function registerElementAttributeProcessor(ElementAttributeProcessorInterface $processor): void
+ {
+ $this->elementProcessors[] = $processor;
+ }
+}
diff --git a/src/Attribute/Processor/Element/ConstraintAttributeProcessor.php b/src/Attribute/Processor/Element/ConstraintAttributeProcessor.php
new file mode 100644
index 0000000..a91e385
--- /dev/null
+++ b/src/Attribute/Processor/Element/ConstraintAttributeProcessor.php
@@ -0,0 +1,39 @@
+
+ */
+final class ConstraintAttributeProcessor implements ElementAttributeProcessorInterface
+{
+ use SimpleMethodCallGeneratorTrait;
+
+ #[Override]
+ public function type(): string
+ {
+ return Constraint::class;
+ }
+
+ #[Override]
+ public function process(ChildBuilderInterface $builder, object $attribute): void
+ {
+ $builder->satisfy($attribute);
+ }
+
+ #[Override]
+ private function methodName(): string
+ {
+ return 'satisfy';
+ }
+}
diff --git a/src/Attribute/Processor/Element/ElementAttributeProcessorInterface.php b/src/Attribute/Processor/Element/ElementAttributeProcessorInterface.php
new file mode 100644
index 0000000..6d1cc55
--- /dev/null
+++ b/src/Attribute/Processor/Element/ElementAttributeProcessorInterface.php
@@ -0,0 +1,51 @@
+
+ */
+ public function type(): string;
+
+ /**
+ * Apply the attribute on the child builder
+ *
+ * @param ChildBuilderInterface<\Bdf\Form\ElementBuilderInterface> $builder The element builder
+ * @param T $attribute The attribute instance
+ *
+ * @return void
+ *
+ * @see \ReflectionAttribute::newInstance() $attribute is created using this method
+ */
+ public function process(ChildBuilderInterface $builder, object $attribute): void;
+
+ /**
+ * Generate the code corresponding to the attribute
+ * The generated code must perform same action as `process()`
+ *
+ * @param non-empty-string $name The variable name without $
+ * @param AttributesProcessorGenerator $generator Code generator for the "configureBuilder" method
+ * @param ReflectionAttribute $attribute Attribute to use
+ *
+ * @return void
+ */
+ public function generateCode(string $name, AttributesProcessorGenerator $generator, ReflectionAttribute $attribute): void;
+}
diff --git a/src/Attribute/Processor/Element/ExtractorAttributeProcessor.php b/src/Attribute/Processor/Element/ExtractorAttributeProcessor.php
new file mode 100644
index 0000000..900a296
--- /dev/null
+++ b/src/Attribute/Processor/Element/ExtractorAttributeProcessor.php
@@ -0,0 +1,38 @@
+
+ */
+final class ExtractorAttributeProcessor implements ElementAttributeProcessorInterface
+{
+ use SimpleMethodCallGeneratorTrait;
+
+ #[Override]
+ public function type(): string
+ {
+ return ExtractorInterface::class;
+ }
+
+ #[Override]
+ public function process(ChildBuilderInterface $builder, object $attribute): void
+ {
+ $builder->extractor($attribute);
+ }
+
+ #[Override]
+ private function methodName(): string
+ {
+ return 'extractor';
+ }
+}
diff --git a/src/Attribute/Processor/Element/FilterAttributeProcessor.php b/src/Attribute/Processor/Element/FilterAttributeProcessor.php
new file mode 100644
index 0000000..46a997f
--- /dev/null
+++ b/src/Attribute/Processor/Element/FilterAttributeProcessor.php
@@ -0,0 +1,38 @@
+
+ */
+final class FilterAttributeProcessor implements ElementAttributeProcessorInterface
+{
+ use SimpleMethodCallGeneratorTrait;
+
+ #[Override]
+ public function type(): string
+ {
+ return FilterInterface::class;
+ }
+
+ #[Override]
+ public function process(ChildBuilderInterface $builder, object $attribute): void
+ {
+ $builder->filter($attribute);
+ }
+
+ #[Override]
+ private function methodName(): string
+ {
+ return 'filter';
+ }
+}
diff --git a/src/Attribute/Processor/Element/HydratorAttributeProcessor.php b/src/Attribute/Processor/Element/HydratorAttributeProcessor.php
new file mode 100644
index 0000000..7f83fda
--- /dev/null
+++ b/src/Attribute/Processor/Element/HydratorAttributeProcessor.php
@@ -0,0 +1,38 @@
+
+ */
+final class HydratorAttributeProcessor implements ElementAttributeProcessorInterface
+{
+ use SimpleMethodCallGeneratorTrait;
+
+ #[Override]
+ public function type(): string
+ {
+ return HydratorInterface::class;
+ }
+
+ #[Override]
+ public function process(ChildBuilderInterface $builder, object $attribute): void
+ {
+ $builder->hydrator($attribute);
+ }
+
+ #[Override]
+ private function methodName(): string
+ {
+ return 'hydrator';
+ }
+}
diff --git a/src/Attribute/Processor/Element/SimpleMethodCallGeneratorTrait.php b/src/Attribute/Processor/Element/SimpleMethodCallGeneratorTrait.php
new file mode 100644
index 0000000..7930a1f
--- /dev/null
+++ b/src/Attribute/Processor/Element/SimpleMethodCallGeneratorTrait.php
@@ -0,0 +1,32 @@
+getName();
+ $generator->line('$?->?(?);', [$name, $this->methodName(), $generator->new($constraint, $attribute->getArguments())]);
+ }
+
+ /**
+ * Called method name
+ *
+ * @return literal-string
+ */
+ abstract private function methodName(): string;
+}
diff --git a/src/Attribute/Processor/Element/TransformerAttributeProcessor.php b/src/Attribute/Processor/Element/TransformerAttributeProcessor.php
new file mode 100644
index 0000000..4e74542
--- /dev/null
+++ b/src/Attribute/Processor/Element/TransformerAttributeProcessor.php
@@ -0,0 +1,39 @@
+
+ */
+final class TransformerAttributeProcessor implements ElementAttributeProcessorInterface
+{
+ use SimpleMethodCallGeneratorTrait;
+
+ #[Override]
+ public function type(): string
+ {
+ return TransformerInterface::class;
+ }
+
+ #[Override]
+ public function process(ChildBuilderInterface $builder, object $attribute): void
+ {
+ $builder->transformer($attribute);
+ }
+
+ #[Override]
+ private function methodName(): string
+ {
+ return 'transformer';
+ }
+}
diff --git a/src/Attribute/Processor/GenerateConfiguratorStrategy.php b/src/Attribute/Processor/GenerateConfiguratorStrategy.php
new file mode 100644
index 0000000..283cdf8
--- /dev/null
+++ b/src/Attribute/Processor/GenerateConfiguratorStrategy.php
@@ -0,0 +1,189 @@
+
+ */
+ private array $elementProcessors = [];
+
+ /**
+ * @param non-empty-string $className The class name to generate. Must have a namespace
+ * @throws \InvalidArgumentException If a namespace is not provided, or if the class name is not valid
+ */
+ public function __construct(string $className)
+ {
+ $this->generator = new AttributesProcessorGenerator($className);
+
+ $this->registerElementAttributeProcessor(new ConstraintAttributeProcessor());
+ $this->registerElementAttributeProcessor(new FilterAttributeProcessor());
+ $this->registerElementAttributeProcessor(new TransformerAttributeProcessor());
+ $this->registerElementAttributeProcessor(new HydratorAttributeProcessor());
+ $this->registerElementAttributeProcessor(new ExtractorAttributeProcessor());
+ }
+
+ #[Override]
+ public function onFormClass(ReflectionClass $formClass, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void
+ {
+ $empty = true;
+
+ foreach ($formClass->getAttributes(FormBuilderAttributeInterface::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
+ $attribute->newInstance()->generateCodeForFormBuilder($this->generator, $form);
+ $empty = false;
+ }
+
+ if (!$empty) {
+ $this->generator->line();
+ }
+ }
+
+ #[Override]
+ public function onButtonProperty(ReflectionProperty $property, string $name, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void
+ {
+ $this->generator->line('$builder->submit(?)', [$name]);
+
+ foreach ($property->getAttributes(ButtonBuilderAttributeInterface::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
+ $attribute->newInstance()->generateCodeForButtonBuilder($this->generator, $form);
+ }
+
+ $this->generator->line(";\n");
+ }
+
+ #[Override]
+ public function onElementProperty(ReflectionProperty $property, string $name, string $elementType, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void
+ {
+ $elementType = $this->generator->useAndSimplifyType($elementType);
+ $this->generator->line('$? = $builder->add(?, ?::class);', [$name, $name, new Literal($elementType)]);
+
+ foreach ($property->getAttributes() as $attribute) {
+ if (is_subclass_of($attribute->getName(), ChildBuilderAttributeInterface::class)) {
+ /** @var ChildBuilderAttributeInterface $attributeInstance */
+ $attributeInstance = $attribute->newInstance();
+ $attributeInstance->generateCodeForChildBuilder($name, $this->generator, $form);
+ continue;
+ }
+
+ foreach ($this->elementProcessors as $configurator) {
+ if (is_subclass_of($attribute->getName(), $configurator->type())) {
+ $configurator->generateCode($name, $this->generator, $attribute);
+ }
+ }
+ }
+
+ foreach ($metadata->registeredChildAttributes($name) as $attribute) {
+ $attribute->generateCodeForChildBuilder($name, $this->generator, $form);
+ }
+
+ $this->generator->line(); // Add empty line
+ }
+
+ #[Override]
+ public function onPostConfigure(ProcessorMetadata $metadata, AttributeForm $form): ?PostConfigureInterface
+ {
+ $this->generator->line('return $this;');
+
+ $method = $this->generator
+ ->implements(PostConfigureInterface::class)
+ ->implementsMethod(PostConfigureInterface::class, 'postConfigure')
+ ;
+
+ $elementProperties = $metadata->elementProperties();
+ $buttonProperties = $metadata->buttonProperties();
+
+ if (!empty($buttonProperties)) {
+ $method->addBody('$root = $form->root();');
+ }
+
+ $scopedProperties = [];
+
+ foreach ($elementProperties as $name => $property) {
+ if ($property->isPublic()) {
+ $method->addBody('$form->? = $inner[?]->element();', [$name, $name]);
+ } else {
+ $scopedProperties[$property->getDeclaringClass()->getName()][$name] = ['$form->? = $inner[?]->element();', [$name, $name]];
+ }
+ }
+
+ foreach ($buttonProperties as $name => $property) {
+ if ($property->isPublic()) {
+ $method->addBody('$form->? = $root->button(?);', [$name, $name]);
+ } else {
+ $scopedProperties[$property->getDeclaringClass()->getName()][$name] = ['$form->? = $root->button(?);', [$name, $name]];
+ }
+ }
+
+ foreach ($scopedProperties as $className => $lines) {
+ $closure = new Closure();
+ $closure->addUse('inner');
+ $closure->addUse('form');
+
+ if (!empty($buttonProperties)) {
+ $closure->addUse('root');
+ }
+
+ array_map(fn ($line) => $closure->addBody(...$line), $lines);
+
+ $method->addBody(
+ '(\Closure::bind(?, null, ?::class))();',
+ [
+ new Literal($this->generator->printer()->printClosure($closure)),
+ new Literal($this->generator->useAndSimplifyType($className)),
+ ]
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Print the generated class code
+ *
+ * @return string
+ *
+ * @see AttributesProcessorGenerator::print()
+ */
+ public function code(): string
+ {
+ return $this->generator->print();
+ }
+
+ /**
+ * Register a new processor for element attributes
+ *
+ * @param ElementAttributeProcessorInterface $processor
+ *
+ * @return void
+ *
+ * @template T as object
+ */
+ private function registerElementAttributeProcessor(ElementAttributeProcessorInterface $processor): void
+ {
+ $this->elementProcessors[] = $processor;
+ }
+}
diff --git a/src/Attribute/Processor/MethodChildBuilderAttributeInterface.php b/src/Attribute/Processor/MethodChildBuilderAttributeInterface.php
new file mode 100644
index 0000000..1e15a40
--- /dev/null
+++ b/src/Attribute/Processor/MethodChildBuilderAttributeInterface.php
@@ -0,0 +1,33 @@
+
+ */
+ public function targetElements(): array;
+
+ /**
+ * The actual object which will be applied on the child builder
+ *
+ * @param ReflectionMethod $method The method where the attribute is located
+ * @return ChildBuilderAttributeInterface
+ */
+ public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface;
+}
diff --git a/src/Attribute/Processor/PostConfigureInterface.php b/src/Attribute/Processor/PostConfigureInterface.php
new file mode 100644
index 0000000..8c0f953
--- /dev/null
+++ b/src/Attribute/Processor/PostConfigureInterface.php
@@ -0,0 +1,24 @@
+
+ */
+ private array $elementProperties,
+ /**
+ * Properties which store form buttons
+ * The key is the button name, and value is the reflection property
+ *
+ * @var array
+ */
+ private array $buttonProperties,
+ ) {}
+
+ #[Override]
+ public function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ foreach ($this->elementProperties as $name => $reflection) {
+ $reflection->setValue($form, $inner[$name]->element());
+ }
+
+ foreach ($this->buttonProperties as $name => $reflection) {
+ $reflection->setValue($form, $form->root()->button($name));
+ }
+ }
+}
diff --git a/src/Attribute/Processor/ProcessorMetadata.php b/src/Attribute/Processor/ProcessorMetadata.php
new file mode 100644
index 0000000..4ccf720
--- /dev/null
+++ b/src/Attribute/Processor/ProcessorMetadata.php
@@ -0,0 +1,93 @@
+
+ */
+ private array $buttonProperties = [];
+
+ /**
+ * @var array
+ */
+ private array $elementProperties = [];
+
+ /**
+ * @var array>
+ */
+ private array $childAttributes = [];
+
+ /**
+ * @param non-empty-string $name
+ * @param ReflectionProperty $property
+ * @return void
+ */
+ public function addButtonProperty(string $name, ReflectionProperty $property): void
+ {
+ $this->buttonProperties[$name] = $property;
+ }
+
+ /**
+ * @param non-empty-string $name
+ * @param ReflectionProperty $property
+ * @return void
+ */
+ public function addElementProperty(string $name, ReflectionProperty $property): void
+ {
+ $this->elementProperties[$name] = $property;
+ }
+
+ public function addChildAttribute(string $elementName, ChildBuilderAttributeInterface $attribute): void
+ {
+ $this->childAttributes[$elementName][] = $attribute;
+ }
+
+ /**
+ * @return array
+ */
+ public function buttonProperties(): array
+ {
+ return $this->buttonProperties;
+ }
+
+ /**
+ * @return array
+ */
+ public function elementProperties(): array
+ {
+ return $this->elementProperties;
+ }
+
+ /**
+ * Check if the given property has already been registered
+ *
+ * @param string $name The property name
+ * @return bool
+ */
+ public function hasProperty(string $name): bool
+ {
+ return isset($this->buttonProperties[$name]) || isset($this->elementProperties[$name]);
+ }
+
+ /**
+ * Get child attributes manually registered for the given element name
+ * Those attributes are generally registered by the {@see MethodChildBuilderAttributeInterface} attributes on methods
+ *
+ * @param string $name
+ * @return list
+ */
+ public function registeredChildAttributes(string $name): array
+ {
+ return $this->childAttributes[$name] ?? [];
+ }
+}
diff --git a/src/Attribute/Processor/ReflectionProcessor.php b/src/Attribute/Processor/ReflectionProcessor.php
new file mode 100644
index 0000000..b0f9311
--- /dev/null
+++ b/src/Attribute/Processor/ReflectionProcessor.php
@@ -0,0 +1,115 @@
+strategy = $strategy;
+ }
+
+ #[Override]
+ public function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $metadata = new ProcessorMetadata();
+
+ // First iterate over methods to build the metadata
+ $this->registerMethodsMetadata($form, $metadata);
+
+ foreach ($this->iterateClassHierarchy($form) as $formClass) {
+ $this->strategy->onFormClass($formClass, $form, $builder, $metadata);
+
+ foreach ($formClass->getProperties() as $property) {
+ $name = $property->getName();
+
+ if (
+ !$property->hasType()
+ || !$property->getType() instanceof ReflectionNamedType
+ || $metadata->hasProperty($name)
+ ) {
+ continue;
+ }
+
+ $elementType = $property->getType()->getName();
+
+ if ($elementType === ButtonInterface::class) {
+ $metadata->addButtonProperty($name, $property);
+ $this->strategy->onButtonProperty($property, $name, $form, $builder, $metadata);
+ } elseif (is_subclass_of($elementType, ElementInterface::class)) {
+ $metadata->addElementProperty($name, $property);
+ $this->strategy->onElementProperty($property, $name, $elementType, $form, $builder, $metadata);
+ }
+ }
+ }
+
+ return $this->strategy->onPostConfigure($metadata, $form);
+ }
+
+ /**
+ * Iterate over the class hierarchy of the annotation form
+ * The iteration will start with the form class, and end with the AttributeForm class (excluded)
+ *
+ * @param AttributeForm $form
+ *
+ * @return iterable>
+ *
+ * @psalm-suppress MoreSpecificReturnType
+ */
+ private function iterateClassHierarchy(AttributeForm $form): iterable
+ {
+ for ($reflection = new ReflectionClass($form); $reflection->getName() !== AttributeForm::class; $reflection = $reflection->getParentClass()) {
+ yield $reflection;
+ }
+ }
+
+ /**
+ * Fill the metadata from methods attributes
+ *
+ * @param AttributeForm $form
+ * @param ProcessorMetadata $metadata
+ *
+ * @return void
+ */
+ private function registerMethodsMetadata(AttributeForm $form, ProcessorMetadata $metadata): void
+ {
+ foreach (new ReflectionClass($form)->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+ foreach ($method->getAttributes(MethodChildBuilderAttributeInterface::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
+ /** @var MethodChildBuilderAttributeInterface $attributeInstance */
+ $attributeInstance = $attribute->newInstance();
+
+ foreach ($attributeInstance->targetElements() as $target) {
+ $metadata->addChildAttribute($target, $attributeInstance->attribute($method));
+ }
+ }
+ }
+ }
+}
diff --git a/src/Attribute/Processor/ReflectionStrategyInterface.php b/src/Attribute/Processor/ReflectionStrategyInterface.php
new file mode 100644
index 0000000..dd72a91
--- /dev/null
+++ b/src/Attribute/Processor/ReflectionStrategyInterface.php
@@ -0,0 +1,72 @@
+ $formClass Form class to use
+ * @param AttributeForm $form The current form instance
+ * @param FormBuilderInterface $builder Builder to configure
+ * @param ProcessorMetadata $metadata Metadata for the current form
+ *
+ * @return void
+ */
+ public function onFormClass(ReflectionClass $formClass, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void;
+
+ /**
+ * Configure a button following the declared property
+ * This method is only called one, even if the property is declared multiple times on ancestors
+ * Only the child declaration will be processed
+ *
+ * @param ReflectionProperty $property The property to process
+ * @param non-empty-string $name The button name
+ * @param AttributeForm $form The current form instance
+ * @param FormBuilderInterface $builder Builder to configure
+ * @param ProcessorMetadata $metadata Metadata for the current form
+ *
+ * @return void
+ */
+ public function onButtonProperty(ReflectionProperty $property, string $name, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void;
+
+ /**
+ * Configure an element following the declared property
+ * This method is only called one, even if the property is declared multiple times on ancestors
+ * Only the child declaration will be processed
+ *
+ * @param ReflectionProperty $property The property to process
+ * @param non-empty-string $name The element name
+ * @param class-string $elementType The element type (i.e. the property type)
+ * @param AttributeForm $form The current form instance
+ * @param FormBuilderInterface $builder Builder to configure
+ * @param ProcessorMetadata $metadata Metadata for the current form
+ *
+ * @return void
+ */
+ public function onElementProperty(ReflectionProperty $property, string $name, string $elementType, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void;
+
+ /**
+ * @param ProcessorMetadata $metadata Metadata for the current form
+ * @param AttributeForm $form The current form instance
+ *
+ * @return PostConfigureInterface|null
+ */
+ public function onPostConfigure(ProcessorMetadata $metadata, AttributeForm $form): ?PostConfigureInterface;
+}
diff --git a/tests/Attribute/Aggregate/ArrayConstraintTest.php b/tests/Attribute/Aggregate/ArrayConstraintTest.php
new file mode 100644
index 0000000..335ae09
--- /dev/null
+++ b/tests/Attribute/Aggregate/ArrayConstraintTest.php
@@ -0,0 +1,80 @@
+submit(['values' => ['aaa', 'aaa']]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['values' => 'Not unique'], $form->error()->toArray());
+
+ $form->submit(['values' => ['aaa', 'bbb']]);
+ $this->assertTrue($form->valid());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[ArrayConstraint(new Unique(message: 'Not unique'))]
+ public ArrayElement $values;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Symfony\Component\Validator\Constraints\Unique;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $values = $builder->add('values', ArrayElement::class);
+ $values->arrayConstraint(new Unique(message: 'Not unique', groups: ['Default']));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->values = $inner['values']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Aggregate/ArrayTransformerTest.php b/tests/Attribute/Aggregate/ArrayTransformerTest.php
new file mode 100644
index 0000000..7ada9f3
--- /dev/null
+++ b/tests/Attribute/Aggregate/ArrayTransformerTest.php
@@ -0,0 +1,93 @@
+submit(['foo' => ['_', '-']]);
+ $this->assertEquals(['A_', 'A-'], $form->foo->value());
+
+ $view = $form->view();
+ $this->assertEquals(['A_A', 'A-A'], $view['foo']->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[ArrayTransformer(AArrayTransformer::class, ['A'])]
+ public ArrayElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Tests\Form\Attribute\Aggregate\AArrayTransformer;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', ArrayElement::class);
+ $foo->arrayTransformer(new AArrayTransformer('A'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
+
+class AArrayTransformer implements TransformerInterface
+{
+ public function __construct(
+ public string $c
+ ) {
+ }
+
+ public function transformToHttp($value, ElementInterface $input)
+ {
+ return array_map(fn($v) => $v . $this->c, $value);
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return array_map(fn($v) => $this->c . $v, $value);
+ }
+}
diff --git a/tests/Attribute/Aggregate/AsArrayConstraintTest.php b/tests/Attribute/Aggregate/AsArrayConstraintTest.php
new file mode 100644
index 0000000..3a41782
--- /dev/null
+++ b/tests/Attribute/Aggregate/AsArrayConstraintTest.php
@@ -0,0 +1,107 @@
+submit(['foo' => ['a']]);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('Foo size must be a multiple of 2', $form->foo->error()->global());
+
+ $form->submit(['foo' => ['a', 'b']]);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->foo->error()->global());
+
+ $form->submit(['bar' => ['a']]);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('The value is invalid', $form->bar->error()->global());
+
+ $form->submit(['bar' => ['a', 'b']]);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->bar->error()->global());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ public ArrayElement $foo;
+
+ public ArrayElement $bar;
+
+ #[AsArrayConstraint('foo', message: 'Foo size must be a multiple of 2')]
+ #[AsArrayConstraint('bar')]
+ public function validateFoo(array $value): bool
+ {
+ return count($value) % 2 === 0;
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Constraint\Closure as ClosureConstraint;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', ArrayElement::class);
+ $foo->arrayConstraint(new ClosureConstraint($form->validateFoo(...), 'Foo size must be a multiple of 2'));
+
+ $bar = $builder->add('bar', ArrayElement::class);
+ $bar->arrayConstraint(new ClosureConstraint($form->validateFoo(...)));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
diff --git a/tests/Attribute/Aggregate/CallbackArrayConstraintTest.php b/tests/Attribute/Aggregate/CallbackArrayConstraintTest.php
new file mode 100644
index 0000000..77bc0ae
--- /dev/null
+++ b/tests/Attribute/Aggregate/CallbackArrayConstraintTest.php
@@ -0,0 +1,107 @@
+submit(['foo' => ['a']]);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('Foo size must be a multiple of 2', $form->foo->error()->global());
+
+ $form->submit(['foo' => ['a', 'b']]);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->foo->error()->global());
+
+ $form->submit(['bar' => ['a']]);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('The value is invalid', $form->bar->error()->global());
+
+ $form->submit(['bar' => ['a', 'b']]);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->bar->error()->global());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[CallbackArrayConstraint('validateFoo', message: 'Foo size must be a multiple of 2')]
+ public ArrayElement $foo;
+
+ #[CallbackArrayConstraint('validateFoo')]
+ public ArrayElement $bar;
+
+ public function validateFoo(array $value): bool
+ {
+ return count($value) % 2 === 0;
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Constraint\Closure as ClosureConstraint;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', ArrayElement::class);
+ $foo->arrayConstraint(new ClosureConstraint($form->validateFoo(...), 'Foo size must be a multiple of 2'));
+
+ $bar = $builder->add('bar', ArrayElement::class);
+ $bar->arrayConstraint(new ClosureConstraint($form->validateFoo(...)));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
diff --git a/tests/Attribute/Aggregate/CountTest.php b/tests/Attribute/Aggregate/CountTest.php
new file mode 100644
index 0000000..25946d1
--- /dev/null
+++ b/tests/Attribute/Aggregate/CountTest.php
@@ -0,0 +1,83 @@
+submit([]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['values' => 'This collection should contain 3 elements or more.'], $form->error()->toArray());
+
+ $form->submit(['values' => ['aaa', 'bbb', 'ccc', 'ddd', 'eee', 'fff']]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['values' => 'This collection should contain 5 elements or less.'], $form->error()->toArray());
+
+ $form->submit(['values' => ['aaa', 'bbb', 'ccc']]);
+ $this->assertTrue($form->valid());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Count(min: 3, max: 5)]
+ public ArrayElement $values;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Symfony\Component\Validator\Constraints\Count;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $values = $builder->add('values', ArrayElement::class);
+ $values->arrayConstraint(new Count(min: 3, max: 5));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->values = $inner['values']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Aggregate/ElementTypeTest.php b/tests/Attribute/Aggregate/ElementTypeTest.php
new file mode 100644
index 0000000..97bda4c
--- /dev/null
+++ b/tests/Attribute/Aggregate/ElementTypeTest.php
@@ -0,0 +1,139 @@
+submit(['values' => ['123', '456', '789']]);
+ $this->assertTrue($form->valid());
+
+ $this->assertSame(['values' => [123, 456, 789]], $form->value());
+ }
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_configurator(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[ElementType(IntegerElement::class, "configureField"), Setter]
+ public ArrayElement $values;
+
+ public function configureField(IntegerElementBuilder $builder): void
+ {
+ $builder->min(200);
+ }
+ };
+
+ $form->submit(['values' => ['123', '456', '789']]);
+ $this->assertFalse($form->valid());
+
+ $this->assertEquals(['values' => [0 => 'This value should be greater than or equal to 200.']], $form->error()->toArray());
+ }
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_embedded(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[ElementType(EmbeddedForm::class), Setter]
+ public ArrayElement $values;
+ };
+
+ $form->submit(['values' => [['a' => 'az', 'b' => 'er'], ['a' => 'ty', 'b' => 'ui']]]);
+ $this->assertTrue($form->valid());
+
+ $this->assertEquals(['values' => [new Struct('az', 'er'), new Struct('ty', 'ui')]], $form->value());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[ElementType(IntegerElement::class, "configureField"), Setter]
+ public ArrayElement $values;
+
+ public function configureField(IntegerElementBuilder $builder): void
+ {
+ $builder->min(200);
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\IntegerElement;
+use Bdf\Form\PropertyAccess\Setter;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $values = $builder->add('values', ArrayElement::class);
+ $values->element(IntegerElement::class, [$form, 'configureField']);
+ $values->hydrator(new Setter());
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->values = $inner['values']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
+
+#[Generates(Struct::class)]
+class EmbeddedForm extends AttributeForm
+{
+ #[Setter]
+ public StringElement $a;
+ #[Setter]
+ public StringElement $b;
+}
+
+class Struct
+{
+ public function __construct(
+ public ?string $a = null,
+ public ?string $b = null,
+ ) {}
+}
diff --git a/tests/Attribute/Button/GroupsTest.php b/tests/Attribute/Button/GroupsTest.php
new file mode 100644
index 0000000..49dba86
--- /dev/null
+++ b/tests/Attribute/Button/GroupsTest.php
@@ -0,0 +1,78 @@
+submit([]);
+ $this->assertEquals(['Default'], $form->root()->constraintGroups());
+
+ $form->submit(['btn' => 'ok']);
+ $this->assertEquals(['foo', 'bar'], $form->root()->constraintGroups());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Groups('foo', 'bar')]
+ public ButtonInterface $btn;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->submit('btn')
+ ->groups(['foo', 'bar'])
+ ;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $root = $form->root();
+ $form->btn = $root->button('btn');
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Button/ValueTest.php b/tests/Attribute/Button/ValueTest.php
new file mode 100644
index 0000000..884d280
--- /dev/null
+++ b/tests/Attribute/Button/ValueTest.php
@@ -0,0 +1,76 @@
+assertFalse($form->submit(['button' => 'ok'])->button->clicked());
+ $this->assertTrue($form->submit(['button' => 'foo'])->button->clicked());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Value('foo')]
+ public ButtonInterface $button;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->submit('button')
+ ->value('foo')
+ ;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $root = $form->root();
+ $form->button = $root->button('button');
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Child/AsFilterTest.php b/tests/Attribute/Child/AsFilterTest.php
new file mode 100644
index 0000000..bf02108
--- /dev/null
+++ b/tests/Attribute/Child/AsFilterTest.php
@@ -0,0 +1,97 @@
+submit(['a' => 'Zm9v']);
+ $this->assertEquals('foo', $form->a->value());
+ }
+
+ /**
+ *
+ */
+ public function test_code_generator()
+ {
+ $form = new class() extends AttributeForm {
+ #[Getter, Setter]
+ public StringElement $foo;
+ public StringElement $bar;
+
+ #[AsFilter('foo', 'bar')]
+ public function aFilter($value, Child $input, $default)
+ {
+ return base64_decode($value);
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\PropertyAccess\Getter;
+use Bdf\Form\PropertyAccess\Setter;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->extractor(new Getter());
+ $foo->hydrator(new Setter());
+ $foo->filter([$form, 'aFilter']);
+
+ $bar = $builder->add('bar', StringElement::class);
+ $bar->filter([$form, 'aFilter']);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Child/AsModelTransformerTest.php b/tests/Attribute/Child/AsModelTransformerTest.php
new file mode 100644
index 0000000..a6cd85c
--- /dev/null
+++ b/tests/Attribute/Child/AsModelTransformerTest.php
@@ -0,0 +1,95 @@
+submit(['a' => 'foo']);
+ $this->assertEquals(new Struct(a: 'Zm9v'), $form->value());
+
+ $form->import(new Struct(a: 'SGVsbG8gV29ybGQgIQ=='));
+ $this->assertEquals('Hello World !', $form->a->value());
+ }
+
+ /**
+ *
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Getter, Setter]
+ public IntegerElement $foo;
+
+ #[AsModelTransformer('foo')]
+ public function t($value, $input)
+ {
+ return $value + 1;
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\IntegerElement;
+use Bdf\Form\PropertyAccess\Getter;
+use Bdf\Form\PropertyAccess\Setter;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', IntegerElement::class);
+ $foo->extractor(new Getter());
+ $foo->hydrator(new Setter());
+ $foo->modelTransformer([$form, 't']);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Child/CallbackFilterTest.php b/tests/Attribute/Child/CallbackFilterTest.php
new file mode 100644
index 0000000..5fda5d1
--- /dev/null
+++ b/tests/Attribute/Child/CallbackFilterTest.php
@@ -0,0 +1,89 @@
+submit(['a' => 'Zm9v']);
+ $this->assertEquals('foo', $form->a->value());
+ }
+
+ /**
+ *
+ */
+ public function test_code_generator()
+ {
+ $form = new class() extends AttributeForm {
+ #[CallbackFilter('aFilter'), Getter, Setter]
+ public StringElement $foo;
+
+ public function aFilter($value, Child $input, $default)
+ {
+ return base64_decode($value);
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\PropertyAccess\Getter;
+use Bdf\Form\PropertyAccess\Setter;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->filter([$form, 'aFilter']);
+ $foo->extractor(new Getter());
+ $foo->hydrator(new Setter());
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Child/CallbackModelTransformerTest.php b/tests/Attribute/Child/CallbackModelTransformerTest.php
new file mode 100644
index 0000000..2dd7d48
--- /dev/null
+++ b/tests/Attribute/Child/CallbackModelTransformerTest.php
@@ -0,0 +1,184 @@
+submit(['a' => 'foo', 'b' => '15']);
+ $this->assertEquals(new Struct(a: 'Zm9v', b: 'f'), $form->value());
+
+ $form->import(new Struct(a: 'SGVsbG8gV29ybGQgIQ==', b: 'a'));
+ $this->assertEquals('Hello World !', $form->a->value());
+ $this->assertEquals(10, $form->b->value());
+ }
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_only_one_transformation_method(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[CallbackModelTransformer(toEntity: 't'), Getter, Setter]
+ public IntegerElement $foo;
+ #[CallbackModelTransformer(toInput: 't'), Getter, Setter]
+ public IntegerElement $bar;
+
+ public function t($value, $input)
+ {
+ return $value + 1;
+ }
+ };
+
+ $form->submit(['foo' => '5', 'bar' => '5']);
+ $this->assertSame([
+ 'foo' => 6,
+ 'bar' => 5
+ ], $form->value());
+
+ $form->import(['foo' => 5, 'bar' => 5]);
+ $this->assertSame(5, $form->foo->value());
+ $this->assertSame(6, $form->bar->value());
+ }
+
+ /**
+ *
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[CallbackModelTransformer(toEntity: 't'), Getter, Setter]
+ public IntegerElement $foo;
+ #[CallbackModelTransformer(toInput: 't'), Getter, Setter]
+ public IntegerElement $bar;
+
+ public function t($value, $input)
+ {
+ return $value + 1;
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\ElementInterface;
+use Bdf\Form\Leaf\IntegerElement;
+use Bdf\Form\PropertyAccess\Getter;
+use Bdf\Form\PropertyAccess\Setter;
+use Bdf\Form\Transformer\TransformerInterface;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', IntegerElement::class);
+ $foo->modelTransformer(new class ($form) implements TransformerInterface {
+ /**
+ * {@inheritdoc}
+ */
+ function transformToHttp(mixed $value, ElementInterface $input): mixed
+ {
+ return $value;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function transformFromHttp(mixed $value, ElementInterface $input): mixed
+ {
+ return $this->form->t($value, $input);
+ }
+
+ public function __construct(
+ private $form,
+ ) {
+ }
+ });
+ $foo->extractor(new Getter());
+ $foo->hydrator(new Setter());
+
+ $bar = $builder->add('bar', IntegerElement::class);
+ $bar->modelTransformer(new class ($form) implements TransformerInterface {
+ /**
+ * {@inheritdoc}
+ */
+ function transformToHttp(mixed $value, ElementInterface $input): mixed
+ {
+ return $this->form->t($value, $input);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function transformFromHttp(mixed $value, ElementInterface $input): mixed
+ {
+ return $value;
+ }
+
+ public function __construct(
+ private $form,
+ ) {
+ }
+ });
+ $bar->extractor(new Getter());
+ $bar->hydrator(new Setter());
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Child/ConfigureTest.php b/tests/Attribute/Child/ConfigureTest.php
new file mode 100644
index 0000000..502f942
--- /dev/null
+++ b/tests/Attribute/Child/ConfigureTest.php
@@ -0,0 +1,167 @@
+length(min: 3);
+ }
+ };
+
+ $form->submit(['foo' => 'a']);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['foo' => 'This value is too short. It should have 3 characters or more.'], $form->error()->toArray());
+
+ $form->submit(['foo' => 'abc']);
+ $this->assertTrue($form->valid());
+ }
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_on_method(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ public StringElement $foo;
+
+ /**
+ * @param ChildBuilderInterface|StringElementBuilder $builder
+ */
+ #[Configure('foo')]
+ public function configureFoo(ChildBuilderInterface $builder): void
+ {
+ $builder->length(min: 3);
+ }
+ };
+
+ $form->submit(['foo' => 'a']);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['foo' => 'This value is too short. It should have 3 characters or more.'], $form->error()->toArray());
+
+ $form->submit(['foo' => 'abc']);
+ $this->assertTrue($form->valid());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Configure('configureFoo')]
+ public StringElement $foo;
+
+ /**
+ * @param ChildBuilderInterface|StringElementBuilder $builder
+ */
+ public function configureFoo(ChildBuilderInterface $builder): void
+ {
+ $builder->length(min: 3);
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $form->configureFoo($foo);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+
+ public function test_code_generator_on_method()
+ {
+ $form = new class extends AttributeForm {
+ public StringElement $foo;
+
+ /**
+ * @param ChildBuilderInterface|StringElementBuilder $builder
+ */
+ #[Configure('foo')]
+ public function configureFoo(ChildBuilderInterface $builder): void
+ {
+ $builder->length(['min' => 3]);
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $form->configureFoo($foo);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Child/DefaultValueTest.php b/tests/Attribute/Child/DefaultValueTest.php
new file mode 100644
index 0000000..2bced97
--- /dev/null
+++ b/tests/Attribute/Child/DefaultValueTest.php
@@ -0,0 +1,78 @@
+submit([]);
+ $this->assertSame(42, $form->v->value());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[DefaultValue(42)]
+ public IntegerElement $v;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\IntegerElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $v = $builder->add('v', IntegerElement::class);
+ $v->default(42);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->v = $inner['v']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Child/DependenciesTest.php b/tests/Attribute/Child/DependenciesTest.php
new file mode 100644
index 0000000..407010b
--- /dev/null
+++ b/tests/Attribute/Child/DependenciesTest.php
@@ -0,0 +1,95 @@
+foo->value() . $value . $this->bar->value();
+ }
+ };
+
+ $form->submit(['foo' => 'a', 'bar' => 'b', 'baz' => 'c']);
+ $this->assertSame('acb', $form->baz->value());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ public StringElement $foo;
+ #[Dependencies('foo', 'bar')]
+ public StringElement $baz;
+ public StringElement $bar;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+
+ $baz = $builder->add('baz', StringElement::class);
+ $baz->depends('foo', 'bar');
+
+ $bar = $builder->add('bar', StringElement::class);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->baz = $inner['baz']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Child/GetSetTest.php b/tests/Attribute/Child/GetSetTest.php
new file mode 100644
index 0000000..68b106b
--- /dev/null
+++ b/tests/Attribute/Child/GetSetTest.php
@@ -0,0 +1,88 @@
+submit(['a' => 'z', 'b' => 'e']);
+ $this->assertSame(['a' => 'z', 'c' => 'e'], $form->value());
+
+ $form->import(['a' => 'aaa', 'c' => 'ccc']);
+ $this->assertSame('aaa', $form->a->value());
+ $this->assertSame('ccc', $form->b->value());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[GetSet('c')]
+ public StringElement $b;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\PropertyAccess\Getter;
+use Bdf\Form\PropertyAccess\Setter;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $b = $builder->add('b', StringElement::class);
+ $b->hydrator(new Setter('c'))->extractor(new Getter('c'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->b = $inner['b']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Child/HttpFieldTest.php b/tests/Attribute/Child/HttpFieldTest.php
new file mode 100644
index 0000000..2a3884a
--- /dev/null
+++ b/tests/Attribute/Child/HttpFieldTest.php
@@ -0,0 +1,81 @@
+submit(['_v_' => 42]);
+ $this->assertSame(42, $form->v->value());
+ $this->assertSame(['_v_' => '42'], $form->httpValue());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[HttpField('_v_')]
+ public IntegerElement $v;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Child\Http\ArrayOffsetHttpFields;
+use Bdf\Form\Leaf\IntegerElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $v = $builder->add('v', IntegerElement::class);
+ $v->httpFields(new ArrayOffsetHttpFields('_v_'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->v = $inner['v']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Child/ModelTransformerTest.php b/tests/Attribute/Child/ModelTransformerTest.php
new file mode 100644
index 0000000..1917aff
--- /dev/null
+++ b/tests/Attribute/Child/ModelTransformerTest.php
@@ -0,0 +1,168 @@
+submit(['a' => 'foo', 'b' => '15']);
+ $this->assertEquals(new Struct(a: 'Zm9v', b: 'f'), $form->value());
+
+ $form->import(new Struct(a: 'SGVsbG8gV29ybGQgIQ==', b: 'a'));
+ $this->assertEquals('Hello World !', $form->a->value());
+ $this->assertEquals(10, $form->b->value());
+
+ $form->submit(['c' => 'bar']);
+ $this->assertEquals('foo_bar', $form->value()->c);
+
+ $form->import(new Struct(c: 'foo_abc'));
+ $this->assertEquals('abc', $form->c->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new #[Generates(Struct::class)] class extends AttributeForm {
+ #[ModelTransformer(ATransformer::class), Getter, Setter]
+ public StringElement $a;
+ #[ModelTransformer(BTransformer::class), Getter, Setter]
+ public IntegerElement $b;
+
+ #[ModelTransformer(TransformerWithArguments::class, ['foo_']), GetSet]
+ public StringElement $c;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\IntegerElement;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\PropertyAccess\Getter;
+use Bdf\Form\PropertyAccess\Setter;
+use Tests\Form\Attribute\Child\ATransformer;
+use Tests\Form\Attribute\Child\BTransformer;
+use Tests\Form\Attribute\Child\Struct;
+use Tests\Form\Attribute\Child\TransformerWithArguments;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->generates(Struct::class);
+
+ $a = $builder->add('a', StringElement::class);
+ $a->modelTransformer(new ATransformer());
+ $a->extractor(new Getter());
+ $a->hydrator(new Setter());
+
+ $b = $builder->add('b', IntegerElement::class);
+ $b->modelTransformer(new BTransformer());
+ $b->extractor(new Getter());
+ $b->hydrator(new Setter());
+
+ $c = $builder->add('c', StringElement::class);
+ $c->modelTransformer(new TransformerWithArguments('foo_'));
+ $c->hydrator(new Setter(null))->extractor(new Getter(null));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->a = $inner['a']->element();
+ $form->b = $inner['b']->element();
+ $form->c = $inner['c']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
+
+class Struct
+{
+ public function __construct(
+ public ?string $a = null,
+ public ?string $b = null,
+ public ?string $c = 'foo_',
+ ) {}
+}
+
+class ATransformer implements TransformerInterface
+{
+ public function transformToHttp($value, ElementInterface $input)
+ {
+ return base64_decode((string) $value);
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return base64_encode((string) $value);
+ }
+}
+
+class BTransformer implements TransformerInterface
+{
+ public function transformToHttp($value, ElementInterface $input)
+ {
+ return $value ? hexdec($value) : 0;
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return $value ? dechex($value) : '';
+ }
+}
+
+class TransformerWithArguments implements TransformerInterface
+{
+ public function __construct(public string $prefix) {}
+
+ public function transformToHttp($value, ElementInterface $input)
+ {
+ return substr($value, strlen((string) $this->prefix));
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return $this->prefix . $value;
+ }
+}
diff --git a/tests/Attribute/Constraint/AsConstraintTest.php b/tests/Attribute/Constraint/AsConstraintTest.php
new file mode 100644
index 0000000..389e8a9
--- /dev/null
+++ b/tests/Attribute/Constraint/AsConstraintTest.php
@@ -0,0 +1,99 @@
+submit(['foo' => 'a']);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('Foo length must be a multiple of 2', $form->foo->error()->global());
+
+ $form->submit(['foo' => 'abcd']);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->foo->error()->global());
+
+ $form->submit(['bar' => 'a']);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('The value is invalid', $form->bar->error()->global());
+
+ $form->submit(['bar' => 'abcd']);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->bar->error()->global());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ public StringElement $foo;
+
+ #[AsConstraint('foo', message: 'Foo length must be a multiple of 2')]
+ public function validateFoo($value): bool
+ {
+ return strlen($value) % 2 === 0;
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Constraint\Closure as ClosureConstraint;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->satisfy(new ClosureConstraint($form->validateFoo(...), 'Foo length must be a multiple of 2'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Constraint/CallbackConstraintTest.php b/tests/Attribute/Constraint/CallbackConstraintTest.php
new file mode 100644
index 0000000..ab44e4c
--- /dev/null
+++ b/tests/Attribute/Constraint/CallbackConstraintTest.php
@@ -0,0 +1,100 @@
+submit(['foo' => 'a']);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('Foo length must be a multiple of 2', $form->foo->error()->global());
+
+ $form->submit(['foo' => 'abcd']);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->foo->error()->global());
+
+ $form->submit(['bar' => 'a']);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('The value is invalid', $form->bar->error()->global());
+
+ $form->submit(['bar' => 'abcd']);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->bar->error()->global());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[CallbackConstraint('validateFoo', message: 'Foo length must be a multiple of 2')]
+ public StringElement $foo;
+
+ public function validateFoo($value): bool
+ {
+ return strlen($value) % 2 === 0;
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Constraint\Closure as ClosureConstraint;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->satisfy(new ClosureConstraint($form->validateFoo(...), 'Foo length must be a multiple of 2'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Constraint/SatisfyTest.php b/tests/Attribute/Constraint/SatisfyTest.php
new file mode 100644
index 0000000..66255fa
--- /dev/null
+++ b/tests/Attribute/Constraint/SatisfyTest.php
@@ -0,0 +1,75 @@
+submit(['foo' => 'ab']);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['foo' => 'This value is too short. It should have 3 characters or more.'], $form->error()->toArray());
+
+ $form->submit(['foo' => 'abc']);
+ $this->assertTrue($form->valid());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Satisfy(new Length(min: 3))]
+ public StringElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Symfony\Component\Validator\Constraints\Length;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->satisfy(new Length(min: 3, groups: ['Default']));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Element/AsTransformerTest.php b/tests/Attribute/Element/AsTransformerTest.php
new file mode 100644
index 0000000..3dfb156
--- /dev/null
+++ b/tests/Attribute/Element/AsTransformerTest.php
@@ -0,0 +1,84 @@
+submit(['foo' => 'a']);
+
+ $this->assertEquals('["a",true]', $form->foo->value());
+
+ $view = $form->view();
+
+ $this->assertEquals('["[\"a\",true]",false]', $view['foo']->value());
+ }
+
+
+ public function test_code_generator()
+ {
+ $form = new class() extends AttributeForm {
+ public StringElement $foo;
+
+ #[AsTransformer('foo')]
+ public function fooTransformer($value, StringElement $input, bool $toPhp)
+ {
+ return json_encode([$value, $toPhp]);
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->transformer([$form, 'fooTransformer']);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Element/CallbackTransformerTest.php b/tests/Attribute/Element/CallbackTransformerTest.php
new file mode 100644
index 0000000..04c5c12
--- /dev/null
+++ b/tests/Attribute/Element/CallbackTransformerTest.php
@@ -0,0 +1,168 @@
+submit(['foo' => 'a', 'bar' => 'b']);
+
+ $this->assertEquals('["a",true]', $form->foo->value());
+ $this->assertEquals('["in","b"]', $form->bar->value());
+
+ $view = $form->view();
+
+ $this->assertEquals('["[\"a\",true]",false]', $view['foo']->value());
+ $this->assertEquals('["out","[\"in\",\"b\"]"]', $view['bar']->value());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_only_one_transformation_method(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[CallbackTransformer(fromHttp: 't'), GetSet]
+ public IntegerElement $foo;
+ #[CallbackTransformer(toHttp: 't'), GetSet]
+ public IntegerElement $bar;
+
+ public function t($value, $input)
+ {
+ return $value + 1;
+ }
+ };
+
+ $form->submit(['foo' => '5', 'bar' => '5']);
+ $this->assertSame(6, $form->foo->value());
+ $this->assertSame(5, $form->bar->value());
+
+ $form->foo->import(5);
+ $form->bar->import(5);
+
+ $view = $form->view();
+ $this->assertEquals(5, $view['foo']->value());
+ $this->assertEquals(6, $view['bar']->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class() extends AttributeForm {
+ #[CallbackTransformer('fooTransformer')]
+ public StringElement $foo;
+
+ #[CallbackTransformer(fromHttp: 'inTransformer', toHttp: 'outTransformer')]
+ public StringElement $bar;
+
+ public function fooTransformer($value, StringElement $input, bool $toPhp)
+ {
+ return json_encode([$value, $toPhp]);
+ }
+
+ public function inTransformer($value, StringElement $input)
+ {
+ return json_encode(['in', $value]);
+ }
+
+ public function outTransformer($value, StringElement $input)
+ {
+ return json_encode(['out', $value]);
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\ElementInterface;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\Transformer\TransformerInterface;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->transformer([$form, 'fooTransformer']);
+
+ $bar = $builder->add('bar', StringElement::class);
+ $bar->transformer(new class ($form) implements TransformerInterface {
+ /**
+ * {@inheritdoc}
+ */
+ function transformToHttp(mixed $value, ElementInterface $input): mixed
+ {
+ return $this->form->outTransformer($value, $input);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function transformFromHttp(mixed $value, ElementInterface $input): mixed
+ {
+ return $this->form->inTransformer($value, $input);
+ }
+
+ public function __construct(
+ private $form,
+ ) {
+ }
+ });
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Element/ChoicesTest.php b/tests/Attribute/Element/ChoicesTest.php
new file mode 100644
index 0000000..aa393a7
--- /dev/null
+++ b/tests/Attribute/Element/ChoicesTest.php
@@ -0,0 +1,113 @@
+ 2])]
+ public ArrayElement $bar;
+
+ #[Choices('generateChoices')]
+ public StringElement $baz;
+
+ public function generateChoices()
+ {
+ return ['aaa', 'bbb', 'ccc'];
+ }
+ };
+
+ $form->submit(['foo' => 'a', 'bar' => ['b'], 'baz' => 'c']);
+
+ $this->assertEquals(new ArrayChoice(['foo', 'bar']), $form->foo->choices());
+ $this->assertEquals(new ArrayChoice(['foo', 'bar', 'baz']), $form->bar->choices());
+ $this->assertEquals(['aaa', 'bbb', 'ccc'], $form->baz->choices()->values());
+ $this->assertEquals(['foo' => 'my error', 'bar' => 'my error', 'baz' => 'The value you selected is not a valid choice.'], $form->error()->toArray());
+
+ $form->submit(['foo' => 'bar', 'bar' => ['bar', 'foo'], 'baz' => 'ccc']);
+ $this->assertTrue($form->valid());
+
+ $form->submit(['foo' => 'bar', 'bar' => ['bar'], 'baz' => 'ccc']);
+ $this->assertEquals(['bar' => 'You must select at least 2 choices.'], $form->error()->toArray());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Choices(choices: ['foo', 'bar'], message: 'my error')]
+ public StringElement $foo;
+
+ #[Choices(choices: ['foo', 'bar', 'baz'], message: 'my error', options: ['min' => 2])]
+ public ArrayElement $bar;
+
+ #[Choices('generateChoices')]
+ public StringElement $baz;
+
+ public function generateChoices()
+ {
+ return ['aaa', 'bbb', 'ccc'];
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Choice\LazyChoice;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->choices(['foo', 'bar'], message: 'my error');
+
+ $bar = $builder->add('bar', ArrayElement::class);
+ $bar->choices(['foo', 'bar', 'baz'], min: 2, message: 'my error');
+
+ $baz = $builder->add('baz', StringElement::class);
+ $baz->choices(new LazyChoice($form->generateChoices(...)), );
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ $form->baz = $inner['baz']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Element/Date/AfterFieldTest.php b/tests/Attribute/Element/Date/AfterFieldTest.php
new file mode 100644
index 0000000..6db6219
--- /dev/null
+++ b/tests/Attribute/Element/Date/AfterFieldTest.php
@@ -0,0 +1,155 @@
+submit([
+ 'foo' => '2020-11-02T15:23:00Z',
+ 'bar' => '2020-11-02T15:21:00Z',
+ ]);
+
+ $this->assertTrue($form->valid());
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:20:50Z',
+ 'bar' => '2020-11-02T15:21:50Z',
+ ]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals([
+ 'foo' => 'This value should be greater than Nov 2, 2020, 3:21 PM.',
+ ], self::normalizeSpace($form->error()->toArray()));
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_message(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[Dependencies('bar'), AfterField('bar', message: 'my error')]
+ public DateTimeElement $foo;
+
+ public DateTimeElement $bar;
+ };
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:23:00Z',
+ 'bar' => '2020-11-02T15:21:00Z',
+ ]);
+
+ $this->assertTrue($form->valid());
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:20:50Z',
+ 'bar' => '2020-11-02T15:21:00Z',
+ ]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals([
+ 'foo' => 'my error',
+ ], $form->error()->toArray());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_or_equal(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[Dependencies('bar'), AfterField('bar', orEqual: true)]
+ public DateTimeElement $foo;
+
+ public DateTimeElement $bar;
+ };
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:21:00Z',
+ 'bar' => '2020-11-02T15:21:00Z',
+ ]);
+
+ $this->assertTrue($form->valid());
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:20:50Z',
+ 'bar' => '2020-11-02T15:21:00Z',
+ ]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals([
+ 'foo' => 'This value should be greater than or equal to Nov 2, 2020, 3:21 PM.',
+ ], self::normalizeSpace($form->error()->toArray()));
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Dependencies('bar'), AfterField('bar', 'my error', true)]
+ public DateTimeElement $foo;
+ public DateTimeElement $bar;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\Date\DateTimeElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', DateTimeElement::class);
+ $foo->depends('bar');
+ $foo->afterField('bar', 'my error', true);
+
+ $bar = $builder->add('bar', DateTimeElement::class);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+
+ public static function normalizeSpace(array|string $value): array|string
+ {
+ if (\is_array($value)) {
+ return \array_map([self::class, 'normalizeSpace'], $value);
+ }
+
+ return \preg_replace('/\p{Zs}+/u', ' ', $value);
+ }
+}
diff --git a/tests/Attribute/Element/Date/BeforeFieldTest.php b/tests/Attribute/Element/Date/BeforeFieldTest.php
new file mode 100644
index 0000000..d56ba6d
--- /dev/null
+++ b/tests/Attribute/Element/Date/BeforeFieldTest.php
@@ -0,0 +1,156 @@
+submit([
+ 'foo' => '2020-11-02T15:21:00Z',
+ 'bar' => '2020-11-02T15:23:00Z',
+ ]);
+
+ $this->assertTrue($form->valid());
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:21:00Z',
+ 'bar' => '2020-11-02T15:20:50Z',
+ ]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals([
+ 'foo' => 'This value should be less than Nov 2, 2020, 3:20 PM.',
+ ], self::normalizeSpace($form->error()->toArray()));
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_message(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[Dependencies('bar'), BeforeField('bar', message: 'my error')]
+ public DateTimeElement $foo;
+
+ public DateTimeElement $bar;
+ };
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:21:00Z',
+ 'bar' => '2020-11-02T15:23:00Z',
+ ]);
+
+ $this->assertTrue($form->valid());
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:21:00Z',
+ 'bar' => '2020-11-02T15:20:50Z',
+ ]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals([
+ 'foo' => 'my error',
+ ], $form->error()->toArray());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_or_equal(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[Dependencies('bar'), BeforeField('bar', orEqual: true)]
+ public DateTimeElement $foo;
+
+ public DateTimeElement $bar;
+ };
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:21:00Z',
+ 'bar' => '2020-11-02T15:21:00Z',
+ ]);
+
+ $this->assertTrue($form->valid());
+
+ $form->submit([
+ 'foo' => '2020-11-02T15:21:00Z',
+ 'bar' => '2020-11-02T15:20:50Z',
+ ]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals([
+ 'foo' => 'This value should be less than or equal to Nov 2, 2020, 3:20 PM.',
+ ], self::normalizeSpace($form->error()->toArray()));
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Dependencies('bar'), BeforeField('bar', 'my error', true)]
+ public DateTimeElement $foo;
+ public DateTimeElement $bar;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\Date\DateTimeElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', DateTimeElement::class);
+ $foo->depends('bar');
+ $foo->beforeField('bar', 'my error', true);
+
+ $bar = $builder->add('bar', DateTimeElement::class);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+
+ public static function normalizeSpace(array|string $value): array|string
+ {
+ if (\is_array($value)) {
+ return \array_map([self::class, 'normalizeSpace'], $value);
+ }
+
+ return \preg_replace('/\p{Zs}+/u', ' ', $value);
+ }
+}
diff --git a/tests/Attribute/Element/Date/DateFormatTest.php b/tests/Attribute/Element/Date/DateFormatTest.php
new file mode 100644
index 0000000..6a0d565
--- /dev/null
+++ b/tests/Attribute/Element/Date/DateFormatTest.php
@@ -0,0 +1,70 @@
+submit(['foo' => '02/11/2020 15:21']);
+
+ $this->assertEquals(new MyCustomDate('2020-11-02T15:21:00'), $form->foo->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[DateFormat('d/m/Y H:i')]
+ public DateTimeElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\Date\DateTimeElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', DateTimeElement::class);
+ $foo->format('d/m/Y H:i');
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
diff --git a/tests/Attribute/Element/Date/DateTimeClassTest.php b/tests/Attribute/Element/Date/DateTimeClassTest.php
new file mode 100644
index 0000000..0f8d6c9
--- /dev/null
+++ b/tests/Attribute/Element/Date/DateTimeClassTest.php
@@ -0,0 +1,78 @@
+submit(['foo' => '2020-11-02T15:21:31+0100']);
+
+ $this->assertEquals(new MyCustomDate('2020-11-02T15:21:31'), $form->foo->value());
+ $this->assertInstanceOf(MyCustomDate::class, $form->foo->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[DateTimeClass(MyCustomDate::class)]
+ public DateTimeElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\Date\DateTimeElement;
+use Tests\Form\Attribute\Element\Date\MyCustomDate;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', DateTimeElement::class);
+ $foo->className(MyCustomDate::class);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
+
+class MyCustomDate extends \DateTime
+{
+}
diff --git a/tests/Attribute/Element/Date/ImmtableDateTimeTest.php b/tests/Attribute/Element/Date/ImmtableDateTimeTest.php
new file mode 100644
index 0000000..09b3b58
--- /dev/null
+++ b/tests/Attribute/Element/Date/ImmtableDateTimeTest.php
@@ -0,0 +1,75 @@
+submit(['foo' => '2020-11-02T15:21:31+0100']);
+
+ $this->assertEquals(new DateTimeImmutable('2020-11-02T15:21:31'), $form->foo->value());
+ $this->assertInstanceOf(DateTimeImmutable::class, $form->foo->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[ImmutableDateTime]
+ public DateTimeElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\Date\DateTimeElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', DateTimeElement::class);
+ $foo->immutable();
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
diff --git a/tests/Attribute/Element/Date/TimezoneTest.php b/tests/Attribute/Element/Date/TimezoneTest.php
new file mode 100644
index 0000000..18b0af0
--- /dev/null
+++ b/tests/Attribute/Element/Date/TimezoneTest.php
@@ -0,0 +1,74 @@
+submit(['foo' => '2020-11-02T15:21:00+0200']);
+
+ $this->assertEquals(new DateTime('2020-11-02T18:21:00+0500'), $form->foo->value());
+ $this->assertEquals(new \DateTimeZone('+0500'), $form->foo->value()->getTimezone());
+ $this->assertEquals(5 * 3600, $form->foo->value()->getOffset());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Timezone('+0500')]
+ public DateTimeElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\Date\DateTimeElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', DateTimeElement::class);
+ $foo->timezone('+0500');
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
diff --git a/tests/Attribute/Element/IgnoreTransformerExceptionTest.php b/tests/Attribute/Element/IgnoreTransformerExceptionTest.php
new file mode 100644
index 0000000..24a1e54
--- /dev/null
+++ b/tests/Attribute/Element/IgnoreTransformerExceptionTest.php
@@ -0,0 +1,94 @@
+submit(['foo' => 'a', 'bar' => 'b']);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['bar' => 'My error'], $form->error()->toArray());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[IgnoreTransformerException, CallbackTransformer('transform')]
+ public StringElement $foo;
+
+ #[IgnoreTransformerException(false), CallbackTransformer('transform')]
+ public StringElement $bar;
+
+ public function transform()
+ {
+ throw new \Exception('My error');
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->ignoreTransformerException(true);
+ $foo->transformer([$form, 'transform']);
+
+ $bar = $builder->add('bar', StringElement::class);
+ $bar->ignoreTransformerException(false);
+ $bar->transformer([$form, 'transform']);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Element/RawTest.php b/tests/Attribute/Element/RawTest.php
new file mode 100644
index 0000000..33f3e9a
--- /dev/null
+++ b/tests/Attribute/Element/RawTest.php
@@ -0,0 +1,93 @@
+lastLocale = \Locale::getDefault();
+ \Locale::setDefault('FR_fr');
+ }
+
+ protected function tearDown(): void
+ {
+ \Locale::setDefault($this->lastLocale);
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[Raw]
+ public FloatElement $foo;
+ #[Raw(false)]
+ public FloatElement $bar;
+ };
+
+ $form->submit(['foo' => '1,23', 'bar' => '1,23']);
+
+ $this->assertSame(1.0, $form->foo->value());
+ $this->assertSame(1.23, $form->bar->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Raw]
+ public FloatElement $foo;
+ #[Raw(false)]
+ public FloatElement $bar;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\FloatElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', FloatElement::class);
+ $foo->raw(true);
+
+ $bar = $builder->add('bar', FloatElement::class);
+ $bar->raw(false);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
diff --git a/tests/Attribute/Element/RequiredTest.php b/tests/Attribute/Element/RequiredTest.php
new file mode 100644
index 0000000..002614f
--- /dev/null
+++ b/tests/Attribute/Element/RequiredTest.php
@@ -0,0 +1,84 @@
+submit(['foo' => '']);
+ $this->assertEquals([
+ 'foo' => 'This value should not be blank.',
+ 'bar' => 'my message',
+ ], $form->error()->toArray());
+
+ $form->submit(['foo' => '1.2', 'bar' => '4.5']);
+ $this->assertTrue($form->valid());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Required]
+ public FloatElement $foo;
+ #[Required('my message')]
+ public FloatElement $bar;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\FloatElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', FloatElement::class);
+ $foo->required(null);
+
+ $bar = $builder->add('bar', FloatElement::class);
+ $bar->required('my message');
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
diff --git a/tests/Attribute/Element/TransformerErrorTest.php b/tests/Attribute/Element/TransformerErrorTest.php
new file mode 100644
index 0000000..df91419
--- /dev/null
+++ b/tests/Attribute/Element/TransformerErrorTest.php
@@ -0,0 +1,133 @@
+submit(['foo' => 'a', 'bar' => 'b']);
+
+ $this->assertEquals(['foo' => 'my message', 'bar' => 'bar'], $form->error()->toArray());
+ $this->assertEquals('BAR_ERROR', $form->error()->children()['bar']->code());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_with_callback(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[CallbackTransformer('transformer'), TransformerError(validationCallback: 'handleError')]
+ public StringElement $foo;
+
+ public function transformer()
+ {
+ throw new \Exception('My error');
+ }
+
+ public function handleError($value, TransformerExceptionConstraint $constraint)
+ {
+ if ($value === 'a') {
+ return false;
+ }
+
+ $constraint->message = str_repeat($value, 5);
+ $constraint->code = 'FOO';
+
+ return true;
+ }
+ };
+
+ $form->submit(['foo' => 'a']);
+ $this->assertTrue($form->valid());
+
+ $form->submit(['foo' => 'b']);
+ $this->assertEquals(['foo' => 'bbbbb'], $form->error()->toArray());
+ $this->assertEquals('FOO', $form->error()->children()['foo']->code());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[CallbackTransformer('transformer'), TransformerError('my message')]
+ public StringElement $foo;
+ #[CallbackTransformer('transformer'), TransformerError(message: 'bar', code: 'BAR_ERROR')]
+ public StringElement $bar;
+
+ public function transformer()
+ {
+ throw new \Exception('My error');
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->transformer([$form, 'transformer']);
+ $foo
+ ->transformerErrorMessage('my message')
+ ;
+
+ $bar = $builder->add('bar', StringElement::class);
+ $bar->transformer([$form, 'transformer']);
+ $bar
+ ->transformerErrorMessage('bar')
+ ->transformerErrorCode('BAR_ERROR')
+ ;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Element/TransformerTest.php b/tests/Attribute/Element/TransformerTest.php
new file mode 100644
index 0000000..db220de
--- /dev/null
+++ b/tests/Attribute/Element/TransformerTest.php
@@ -0,0 +1,173 @@
+submit(['foo' => '_']);
+ $this->assertEquals('A_', $form->foo->value());
+
+ $view = $form->view();
+ $this->assertEquals('A_A', $view['foo']->value());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function testWithArray(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[Transformer(AArrayTransformer::class, ['A'], array: true)]
+ public ArrayElement $foo;
+ };
+
+ $form->submit(['foo' => ['_', '-']]);
+ $this->assertEquals(['A_', 'A-'], $form->foo->value());
+
+ $view = $form->view();
+ $this->assertEquals(['A_A', 'A-A'], $view['foo']->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Transformer(ATransformer::class, ['A'])]
+ public StringElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Tests\Form\Attribute\Element\ATransformer;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->transformer(new ATransformer('A'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+
+ public function test_code_generator_with_array()
+ {
+ $form = new class extends AttributeForm {
+ #[Transformer(AArrayTransformer::class, ['A'], array: true)]
+ public ArrayElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Tests\Form\Attribute\Element\AArrayTransformer;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', ArrayElement::class);
+ $foo->arrayTransformer(new AArrayTransformer('A'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
+
+class ATransformer implements TransformerInterface
+{
+ public function __construct(
+ public string $c
+ ) {
+ }
+
+ public function transformToHttp($value, ElementInterface $input)
+ {
+ return $value . $this->c;
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return $this->c . $value;
+ }
+}
+
+class AArrayTransformer implements TransformerInterface
+{
+ public function __construct(
+ public string $c
+ ) {
+ }
+
+ public function transformToHttp($value, ElementInterface $input)
+ {
+ return array_map(fn($v) => $v . $this->c, $value);
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return array_map(fn($v) => $this->c . $v, $value);
+ }
+}
diff --git a/tests/Attribute/Form/CallbackGeneratorTest.php b/tests/Attribute/Form/CallbackGeneratorTest.php
new file mode 100644
index 0000000..9611a84
--- /dev/null
+++ b/tests/Attribute/Form/CallbackGeneratorTest.php
@@ -0,0 +1,80 @@
+ null, 'bar' => 'a'];
+ }
+ };
+
+ $form->submit(['foo' => 'b']);
+ $this->assertEquals((object) ['foo' => 'b', 'bar' => 'a'], $form->value());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new #[CallbackGenerator('generate')] class extends AttributeForm {
+ public function generate(FormInterface $form)
+ {
+ return (object) ['foo' => null, 'bar' => 'a'];
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->generates([$this, 'generate']);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Form/CsrfTest.php b/tests/Attribute/Form/CsrfTest.php
new file mode 100644
index 0000000..083441f
--- /dev/null
+++ b/tests/Attribute/Form/CsrfTest.php
@@ -0,0 +1,127 @@
+submit([]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['_token' => 'The CSRF token is invalid.'], $form->error()->toArray());
+
+ $form->submit(['_token' => $form['_token']->view()->value()]);
+ $this->assertTrue($form->valid());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_message(AttributesProcessorInterface $processor)
+ {
+ $form = new #[Csrf(message: 'my error')] class(null, $processor) extends AttributeForm {
+ };
+
+ $form->submit([]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['_token' => 'my error'], $form->error()->toArray());
+
+ $form->submit(['_token' => $form['_token']->view()->value()]);
+ $this->assertTrue($form->valid());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_name(AttributesProcessorInterface $processor)
+ {
+ $form = new #[Csrf(name: 't')] class(null, $processor) extends AttributeForm {
+ };
+
+ $form->submit([]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['t' => 'The CSRF token is invalid.'], $form->error()->toArray());
+
+ $form->submit(['t' => $form['t']->view()->value()]);
+ $this->assertTrue($form->valid());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_invalidate(AttributesProcessorInterface $processor)
+ {
+ $form = new #[Csrf(invalidate: true)] class(null, $processor) extends AttributeForm {
+ };
+
+ $token = $form['_token']->view()->value();
+
+ $form->submit(['_token' => $token]);
+ $this->assertTrue($form->valid());
+
+ $form->submit(['_token' => $token]);
+ $this->assertFalse($form->valid());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_tokenId(AttributesProcessorInterface $processor)
+ {
+ $form = new #[Csrf(tokenId: 'my_token_id')] class(null, $processor) extends AttributeForm {
+ };
+
+ $token = $form['_token']->view()->value();
+
+ $this->assertSame('my_token_id', $token->getId());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new #[Csrf(tokenId: 'my_token', message: 'my error', invalidate: true)] class extends AttributeForm {
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->csrf('_token')->tokenId('my_token')->message('my error')->invalidate(true);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Form/GeneratesTest.php b/tests/Attribute/Form/GeneratesTest.php
new file mode 100644
index 0000000..a731fa9
--- /dev/null
+++ b/tests/Attribute/Form/GeneratesTest.php
@@ -0,0 +1,129 @@
+submit(['firstName' => 'John', 'lastName' => 'Doe', 'age' => '35']);
+
+ $expected = new Person();
+ $expected->firstName = 'John';
+ $expected->lastName = 'Doe';
+ $expected->age = 35;
+ $this->assertEquals($expected, $form->value());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new #[Generates(Person::class)] class extends AttributeForm {
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Tests\Form\Attribute\Form\Person;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->generates(Person::class);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ }
+}
+
+PHP
+ , $form);
+ }
+}
+
+class Person
+{
+ public string $firstName;
+ public string $lastName;
+ public int $age;
+}
+
+class MyForm extends CustomForm
+{
+ /**
+ * {@inheritdoc}
+ */
+ protected function configure(FormBuilderInterface $builder): void
+ {
+ $builder->add('custom', MyCustomElement::class)->satisfy(new NotEqualTo('15'));
+ }
+}
+
+class OrderForm extends AttributeForm
+{
+ #[Positive(message: 'Valeur incorrecte'), Getter, Setter]
+ public FloatElement $weight;
+
+ #[Positive(message: 'Valeur incorrecte'), Getter, Setter]
+ public FloatElement $length;
+
+ #[Positive(message: 'Valeur incorrecte'), Getter, Setter]
+ public FloatElement $width;
+
+ #[Positive(message: 'Valeur incorrecte'), Getter, Setter]
+ public FloatElement $height;
+
+ #[Positive(message: 'Valeur incorrecte'), Getter, Setter]
+ public FloatElement $volume;
+
+ #[Positive(message: 'Valeur incorrecte'), Getter, Setter]
+ public IntegerElement $palletsCount;
+
+ #[Positive(message: 'Valeur incorrecte'), Getter, Setter]
+ public IntegerElement $parcelsCount;
+}
diff --git a/tests/Attribute/FunctionalTest.php b/tests/Attribute/FunctionalTest.php
new file mode 100644
index 0000000..449963d
--- /dev/null
+++ b/tests/Attribute/FunctionalTest.php
@@ -0,0 +1,338 @@
+assertInstanceOf(ChildInterface::class, $form['firstName']);
+ $this->assertInstanceOf(ChildInterface::class, $form['lastName']);
+ $this->assertInstanceOf(ChildInterface::class, $form['age']);
+
+ $this->assertInstanceOf(StringElement::class, $form['firstName']->element());
+ $this->assertInstanceOf(StringElement::class, $form['lastName']->element());
+ $this->assertInstanceOf(IntegerElement::class, $form['age']->element());
+
+ $this->assertSame($form['firstName']->element(), $form->firstName);
+ $this->assertSame($form['lastName']->element(), $form->lastName);
+ $this->assertSame($form['age']->element(), $form->age);
+
+ $form->submit(['firstName' => 'John', 'lastName' => 'Doe', 'age' => '35']);
+ $this->assertTrue($form->valid());
+
+ $this->assertSame(['firstName' => 'John', 'lastName' => 'Doe', 'age' => 35], $form->value());
+
+ $form->submit(['firstName' => 'Foo', 'lastName' => 'B', 'age' => '-5']);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals([
+ 'lastName' => 'This value is too short. It should have 3 characters or more.',
+ 'age' => 'This value should be greater than 0.',
+ ], $form->error()->toArray());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_setter_with_name(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[Setter('bar')]
+ public StringElement $foo;
+ };
+
+ $form->submit(['foo' => 'azerty']);
+ $this->assertSame(['bar' => 'azerty'], $form->value());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_getter_with_name(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[Getter('bar')]
+ public StringElement $foo;
+ };
+
+ $form->import(['bar' => 'aqw']);
+ $this->assertSame('aqw', $form->foo->value());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_inheritance(AttributesProcessorInterface $processor)
+ {
+ $form = new ChildForm(null, $processor);
+
+ $this->assertInstanceOf(StringElement::class, $form['foo']->element());
+ $this->assertInstanceOf(IntegerElement::class, $form['bar']->element());
+
+ $form->submit([]);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['foo' => 'This value should not be blank.', 'bar' => 'This value should not be blank.'], $form->error()->toArray());
+
+ $form->submit(['foo' => 'azerty', 'bar' => '25']);
+ $this->assertTrue($form->valid());
+
+ $this->assertSame(['bar' => 25, 'foo' => 'azerty'], $form->value());
+ }
+
+ /**
+ *
+ */
+ public function test_inheritance_code_generator()
+ {
+ $form = new ChildForm();
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\IntegerElement;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\PropertyAccess\Getter;
+use Bdf\Form\PropertyAccess\Setter;
+use Symfony\Component\Validator\Constraints\GreaterThan;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Tests\Form\Attribute\BaseForm;
+use Tests\Form\Attribute\ChildForm;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $bar = $builder->add('bar', IntegerElement::class);
+ $bar->satisfy(new NotBlank());
+ $bar->extractor(new Getter());
+ $bar->hydrator(new Setter());
+ $bar->satisfy(new GreaterThan(5));
+
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->satisfy(new NotBlank());
+ $foo->extractor(new Getter());
+ $foo->hydrator(new Setter());
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ (\Closure::bind(function () use ($inner, $form) {
+ $form->bar = $inner['bar']->element();
+ }, null, ChildForm::class))();
+ (\Closure::bind(function () use ($inner, $form) {
+ $form->foo = $inner['foo']->element();
+ }, null, BaseForm::class))();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+
+ /**
+ *
+ */
+ public function test_inheritance_code_generator_with_method()
+ {
+ $form = new ChildFormWithMethod();
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\IntegerElement;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\PropertyAccess\Getter;
+use Bdf\Form\PropertyAccess\Setter;
+use Symfony\Component\Validator\Constraints\GreaterThan;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Tests\Form\Attribute\BaseFormWithMethod;
+use Tests\Form\Attribute\ChildFormWithMethod;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $bar = $builder->add('bar', IntegerElement::class);
+ $bar->satisfy(new NotBlank());
+ $bar->extractor(new Getter());
+ $bar->hydrator(new Setter());
+ $bar->satisfy(new GreaterThan(5));
+
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->extractor(new Getter());
+ $foo->hydrator(new Setter());
+ $foo->transformer([$form, 'transform']);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ (\Closure::bind(function () use ($inner, $form) {
+ $form->bar = $inner['bar']->element();
+ }, null, ChildFormWithMethod::class))();
+ (\Closure::bind(function () use ($inner, $form) {
+ $form->foo = $inner['foo']->element();
+ }, null, BaseFormWithMethod::class))();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_buttons(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ public ButtonInterface $foo;
+ public ButtonInterface $bar;
+ };
+
+ $form->submit([]);
+
+ $this->assertInstanceOf(SubmitButton::class, $form->foo);
+ $this->assertInstanceOf(SubmitButton::class, $form->bar);
+
+ $this->assertFalse($form->foo->clicked());
+ $this->assertFalse($form->bar->clicked());
+
+ $form->submit(['foo' => 'ok']);
+
+ $this->assertTrue($form->foo->clicked());
+ $this->assertFalse($form->bar->clicked());
+
+ $form->submit(['bar' => 'ok']);
+
+ $this->assertFalse($form->foo->clicked());
+ $this->assertTrue($form->bar->clicked());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_filter(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[FilterVar(FILTER_SANITIZE_FULL_SPECIAL_CHARS), Setter]
+ public StringElement $foo;
+ };
+
+ $form->submit(['foo' => '&world']);
+ $this->assertSame(['foo' => '<hello>&world'], $form->value());
+ }
+
+
+ #[DataProvider('provideAttributesProcessor')]
+ public function test_transformer(AttributesProcessorInterface $processor)
+ {
+ $form = new class(null, $processor) extends AttributeForm {
+ #[MyTransformer, Setter]
+ public StringElement $foo;
+ };
+
+ $form->submit(['foo' => 'aaa']);
+ $this->assertSame(['foo' => 'YWFh'], $form->value());
+ }
+}
+
+class BaseForm extends AttributeForm
+{
+ #[NotBlank, Getter, Setter]
+ private StringElement $foo;
+}
+
+class ChildForm extends BaseForm
+{
+ #[NotBlank, Getter, Setter, GreaterThan(5)]
+ private IntegerElement $bar;
+}
+
+#[Attribute(Attribute::TARGET_PROPERTY)]
+class MyTransformer implements TransformerInterface
+{
+ public function transformToHttp($value, ElementInterface $input)
+ {
+ return base64_decode($value);
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return base64_encode($value);
+ }
+}
+
+class BaseFormWithMethod extends AttributeForm
+{
+ #[Getter, Setter]
+ private StringElement $foo;
+
+ #[AsTransformer('foo')]
+ public function transform(string $value): string
+ {
+ return str_rot13($value);
+ }
+}
+
+class ChildFormWithMethod extends BaseFormWithMethod
+{
+ #[NotBlank, Getter, Setter, GreaterThan(5)]
+ private IntegerElement $bar;
+}
diff --git a/tests/Attribute/Php81/Aggregate/ArrayConstraintTest.php b/tests/Attribute/Php81/Aggregate/ArrayConstraintTest.php
new file mode 100644
index 0000000..6865919
--- /dev/null
+++ b/tests/Attribute/Php81/Aggregate/ArrayConstraintTest.php
@@ -0,0 +1,80 @@
+submit(['values' => ['aaa', 'aaa']]);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['values' => 'Not unique'], $form->error()->toArray());
+
+ $form->submit(['values' => ['aaa', 'bbb']]);
+ $this->assertTrue($form->valid());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[ArrayConstraint(new Unique(message: 'Not unique'))]
+ public ArrayElement $values;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Symfony\Component\Validator\Constraints\Unique;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $values = $builder->add('values', ArrayElement::class);
+ $values->arrayConstraint(new Unique(message: 'Not unique', groups: ['Default']));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->values = $inner['values']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Php81/Aggregate/CallbackArrayConstraintTest.php b/tests/Attribute/Php81/Aggregate/CallbackArrayConstraintTest.php
new file mode 100644
index 0000000..96dae0f
--- /dev/null
+++ b/tests/Attribute/Php81/Aggregate/CallbackArrayConstraintTest.php
@@ -0,0 +1,107 @@
+submit(['foo' => ['a']]);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('Foo size must be a multiple of 2', $form->foo->error()->global());
+
+ $form->submit(['foo' => ['a', 'b']]);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->foo->error()->global());
+
+ $form->submit(['bar' => ['a']]);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('The value is invalid', $form->bar->error()->global());
+
+ $form->submit(['bar' => ['a', 'b']]);
+
+ $this->assertTrue($form->valid());
+ $this->assertNull($form->bar->error()->global());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[CallbackArrayConstraint('validateFoo', message: 'Foo size must be a multiple of 2')]
+ public ArrayElement $foo;
+
+ #[CallbackArrayConstraint('validateFoo')]
+ public ArrayElement $bar;
+
+ public function validateFoo(array $value): bool
+ {
+ return count($value) % 2 === 0;
+ }
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\ArrayElement;
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Constraint\Closure as ClosureConstraint;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', ArrayElement::class);
+ $foo->arrayConstraint(new ClosureConstraint($form->validateFoo(...), 'Foo size must be a multiple of 2'));
+
+ $bar = $builder->add('bar', ArrayElement::class);
+ $bar->arrayConstraint(new ClosureConstraint($form->validateFoo(...)));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
diff --git a/tests/Attribute/Php81/Constraint/SatisfyTest.php b/tests/Attribute/Php81/Constraint/SatisfyTest.php
new file mode 100644
index 0000000..05585f0
--- /dev/null
+++ b/tests/Attribute/Php81/Constraint/SatisfyTest.php
@@ -0,0 +1,75 @@
+submit(['foo' => 'ab']);
+ $this->assertFalse($form->valid());
+ $this->assertEquals(['foo' => 'This value is too short. It should have 3 characters or more.'], $form->error()->toArray());
+
+ $form->submit(['foo' => 'abc']);
+ $this->assertTrue($form->valid());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Satisfy(new Length(min: 3))]
+ public StringElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Symfony\Component\Validator\Constraints\Length;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->satisfy(new Length(min: 3, groups: ['Default']));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Processor/CodeGenerator/AttributesProcessorGeneratorTest.php b/tests/Attribute/Processor/CodeGenerator/AttributesProcessorGeneratorTest.php
new file mode 100644
index 0000000..6be5591
--- /dev/null
+++ b/tests/Attribute/Processor/CodeGenerator/AttributesProcessorGeneratorTest.php
@@ -0,0 +1,121 @@
+expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('The class name must have a namespace');
+
+ new AttributesProcessorGenerator('MyClass');
+ }
+
+ /**
+ * @return void
+ */
+ public function test_empty()
+ {
+ $generator = new AttributesProcessorGenerator('Generated\Processor');
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+class Processor implements AttributesProcessorInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ }
+}
+
+PHP
+ , $generator->print()
+);
+ }
+
+ /**
+ * @return void
+ */
+ public function test_line()
+ {
+ $generator = new AttributesProcessorGenerator('Generated\Processor');
+
+ $generator->line('$?->?(...?);', ['foo', 'bar', [1, 2, 3]]);
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+class Processor implements AttributesProcessorInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo->bar(1, 2, 3);
+ }
+}
+
+PHP
+ , $generator->print()
+);
+ }
+
+ /**
+ * @return void
+ */
+ public function test_new()
+ {
+ $generator = new AttributesProcessorGenerator('Generated\Processor');
+ $expression = $generator->new(Count::class, ['min' => 3, 'max' => 6]);
+
+ $this->assertEquals('new Count(min: 3, max: 6)', (string) $expression);
+
+ $generator->line('$builder->satisfy(?);', [$expression]);
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use PHPUnit\Framework\Constraint\Count;
+
+class Processor implements AttributesProcessorInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->satisfy(new Count(min: 3, max: 6));
+ }
+}
+
+PHP
+ , $generator->print()
+);
+ }
+}
diff --git a/tests/Attribute/Processor/CodeGenerator/ClassGeneratorTest.php b/tests/Attribute/Processor/CodeGenerator/ClassGeneratorTest.php
new file mode 100644
index 0000000..02d8d48
--- /dev/null
+++ b/tests/Attribute/Processor/CodeGenerator/ClassGeneratorTest.php
@@ -0,0 +1,139 @@
+implements(PostConfigureInterface::class);
+
+ $this->assertEquals(<<<'PHP'
+class Foo implements PostConfigureInterface
+{
+}
+
+PHP
+ , $generator->generateClass()
+);
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+
+PHP
+ , $generator->printer()->printNamespace($generator->namespace())
+);
+ }
+
+ /**
+ * @return void
+ */
+ public function test_implementsMethod()
+ {
+ $generator = new ClassGenerator(new PhpNamespace('Generated'), new ClassType('Foo'));
+ $method = $generator->implementsMethod(PostConfigureInterface::class, 'postConfigure');
+
+ $this->assertEquals('postConfigure', $method->getName());
+
+ $this->assertEquals(<<<'PHP'
+class Foo
+{
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ }
+}
+
+PHP
+ , $generator->generateClass()
+);
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+
+
+PHP
+ , $generator->printer()->printNamespace($generator->namespace())
+);
+ }
+
+ /**
+ * @return void
+ */
+ public function test_useAndSimplifyType()
+ {
+ $generator = new ClassGenerator(new PhpNamespace('Generated'), new ClassType('Foo'));
+
+ $this->assertSame('StringElement', $generator->useAndSimplifyType(StringElement::class));
+ $this->assertSame('WithAlias', $generator->useAndSimplifyType(IntegerElement::class, 'WithAlias'));
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Leaf\IntegerElement as WithAlias;
+use Bdf\Form\Leaf\StringElement;
+
+
+PHP
+ , $generator->printer()->printNamespace($generator->namespace())
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_use()
+ {
+ $generator = new ClassGenerator(new PhpNamespace('Generated'), new ClassType('Foo'));
+
+ $generator->use(StringElement::class)->use(IntegerElement::class, 'WithAlias');
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Leaf\IntegerElement as WithAlias;
+use Bdf\Form\Leaf\StringElement;
+
+
+PHP
+ , $generator->printer()->printNamespace($generator->namespace())
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_getters()
+ {
+ $namespace = new PhpNamespace('Generated');
+ $class = new ClassType('Foo');
+ $printer = new PsrPrinter();
+
+ $generator = new ClassGenerator($namespace, $class, $printer);
+
+ $this->assertSame($namespace, $generator->namespace());
+ $this->assertSame($class, $generator->class());
+ $this->assertSame($printer, $generator->printer());
+ }
+}
diff --git a/tests/Attribute/Processor/CodeGenerator/ObjectInstantiationTest.php b/tests/Attribute/Processor/CodeGenerator/ObjectInstantiationTest.php
new file mode 100644
index 0000000..96b147f
--- /dev/null
+++ b/tests/Attribute/Processor/CodeGenerator/ObjectInstantiationTest.php
@@ -0,0 +1,40 @@
+assertEquals("new Symfony\Component\Validator\Constraints\NotBlank(groups: ['Default'])", (string) ObjectInstantiation::promotedProperties($o)->render());
+
+ $this->assertEquals($o, eval('return ' . ObjectInstantiation::promotedProperties($o)->render().';'));
+ }
+
+ public function test_with_parameters()
+ {
+ $o = new Range(min: 1, max: 10);
+
+ $this->assertEquals("new Symfony\Component\Validator\Constraints\Range(min: 1, max: 10, groups: ['Default'])", (string) ObjectInstantiation::promotedProperties($o)->render());
+
+ $this->assertEquals($o, eval('return ' . ObjectInstantiation::promotedProperties($o)->render().';'));
+ }
+
+ public function test_with_simplified_class_name()
+ {
+ $o = new Range(min: 1, max: 10);
+ $generator = new ClassGenerator(new PhpNamespace('Foo'), new ClassType('Bar'));
+
+ $this->assertEquals("new Range(min: 1, max: 10, groups: ['Default'])", (string) ObjectInstantiation::promotedProperties($o)->render($generator));
+ }
+}
diff --git a/tests/Attribute/Processor/CodeGenerator/TransformerClassGeneratorTest.php b/tests/Attribute/Processor/CodeGenerator/TransformerClassGeneratorTest.php
new file mode 100644
index 0000000..364e19e
--- /dev/null
+++ b/tests/Attribute/Processor/CodeGenerator/TransformerClassGeneratorTest.php
@@ -0,0 +1,139 @@
+assertEquals(<<<'PHP'
+implements TransformerInterface {
+ /**
+ * {@inheritdoc}
+ */
+ function transformToHttp(mixed $value, ElementInterface $input): mixed
+ {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function transformFromHttp(mixed $value, ElementInterface $input): mixed
+ {
+ }
+}
+PHP
+ , $generator->generateClass()
+);
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\ElementInterface;
+use Bdf\Form\Transformer\TransformerInterface;
+
+
+PHP
+ , $generator->printer()->printNamespace($generator->namespace())
+);
+ }
+
+ /**
+ * @return void
+ */
+ public function test_withPromotedProperty()
+ {
+ $generator = new TransformerClassGenerator(new PhpNamespace('Generated'));
+ $generator->withPromotedProperty('foo')->setPrivate()->setType(FormInterface::class);
+
+ $this->assertEquals(<<<'PHP'
+implements TransformerInterface {
+ /**
+ * {@inheritdoc}
+ */
+ function transformToHttp(mixed $value, ElementInterface $input): mixed
+ {
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function transformFromHttp(mixed $value, ElementInterface $input): mixed
+ {
+ }
+
+ public function __construct(
+ private \Bdf\Form\Aggregate\FormInterface $foo,
+ ) {
+ }
+}
+PHP
+ , $generator->generateClass()
+);
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\ElementInterface;
+use Bdf\Form\Transformer\TransformerInterface;
+
+
+PHP
+ , $generator->printer()->printNamespace($generator->namespace())
+);
+ }
+
+ /**
+ * @return void
+ */
+ public function test_with_methods_body()
+ {
+ $generator = new TransformerClassGenerator(new PhpNamespace('Generated'));
+
+ $generator->toHttp()->setBody('return $value + 2;');
+ $generator->fromHttp()->setBody('return $value - 2;');
+
+ $this->assertEquals(<<<'PHP'
+implements TransformerInterface {
+ /**
+ * {@inheritdoc}
+ */
+ function transformToHttp(mixed $value, ElementInterface $input): mixed
+ {
+ return $value + 2;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function transformFromHttp(mixed $value, ElementInterface $input): mixed
+ {
+ return $value - 2;
+ }
+}
+PHP
+ , $generator->generateClass()
+ );
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\ElementInterface;
+use Bdf\Form\Transformer\TransformerInterface;
+
+
+PHP
+ , $generator->printer()->printNamespace($generator->namespace())
+ );
+ }
+}
diff --git a/tests/Attribute/Processor/CompileAttributesProcessorTest.php b/tests/Attribute/Processor/CompileAttributesProcessorTest.php
new file mode 100644
index 0000000..39feed4
--- /dev/null
+++ b/tests/Attribute/Processor/CompileAttributesProcessorTest.php
@@ -0,0 +1,342 @@
+ 'Generated\\' . get_class($form) . 'Configurator',
+ fn (string $className) => '/tmp' . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $className) . '.php'
+ );
+
+ $form = new MyForm();
+
+ $postConfigure = $processor->configureBuilder($form, new FormBuilder());
+
+ $this->assertFileExists('/tmp/Generated/Tests/Form/Attribute/Processor/MyFormConfigurator.php');
+ $this->assertStringEqualsFile(
+ '/tmp/Generated/Tests/Form/Attribute/Processor/MyFormConfigurator.php',
+<<<'PHP'
+generates(Person::class);
+
+ $firstName = $builder->add('firstName', StringElement::class);
+ $firstName->satisfy(new NotBlank());
+ $firstName->satisfy(new ClosureConstraint($form->validateName(...)));
+ $firstName->hydrator(new Setter(null))->extractor(new Getter(null));
+
+ $lastName = $builder->add('lastName', StringElement::class);
+ $lastName->satisfy(new ClosureConstraint($form->validateName(...)));
+ $lastName->hydrator(new Setter(null))->extractor(new Getter(null));
+
+ $age = $builder->add('age', IntegerElement::class);
+ $age->satisfy(new Positive());
+ $age->satisfy(new LessThan(150));
+ $age->hydrator(new Setter(null))->extractor(new Getter(null));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ (\Closure::bind(function () use ($inner, $form) {
+ $form->firstName = $inner['firstName']->element();
+ $form->lastName = $inner['lastName']->element();
+ $form->age = $inner['age']->element();
+ }, null, MyForm::class))();
+ }
+}
+
+PHP
+ );
+
+ $this->assertTrue(class_exists('Generated\Tests\Form\Attribute\Processor\MyFormConfigurator', false));
+ $this->assertInstanceOf('Generated\Tests\Form\Attribute\Processor\MyFormConfigurator', $postConfigure);
+ }
+
+ public function test_file_already_exists_should_only_be_included()
+ {
+ $file = '/tmp/generated_configurator.php';
+
+ file_put_contents(
+ $file,
+$code = <<<'PHP'
+ 'Generated\\Configurator',
+ fn (string $className) => $file
+ );
+
+ $form = new MyForm();
+
+ $postConfigure = $processor->configureBuilder($form, new FormBuilder());
+
+ $this->assertStringEqualsFile($file, $code);
+
+ $this->assertTrue(class_exists('Generated\Configurator', false));
+ $this->assertInstanceOf('Generated\Configurator', $postConfigure);
+ }
+
+ public function test_file_already_exists_but_with_invalid_class_should_throw_error()
+ {
+ $file = '/tmp/invalid_class_configurator.php';
+
+ file_put_contents(
+ $file,
+$code = <<<'PHP'
+ 'Generated\\InvalidConfigurator',
+ fn (string $className) => $file
+ );
+
+ $form = new MyForm();
+
+ try {
+ $processor->configureBuilder($form, new FormBuilder());
+ $this->fail('expect LogicException');
+ } catch (\LogicException $e) {
+ $this->assertEquals('Invalid generated class "Generated\InvalidConfigurator" in file "/tmp/invalid_class_configurator.php"', $e->getMessage());
+ }
+
+ $this->assertStringEqualsFile($file, $code);
+ }
+
+ public function test_file_already_exists_but_without_class_on_file()
+ {
+ $file = '/tmp/invalid_class_configurator.php';
+
+ file_put_contents(
+ $file,
+$code = <<<'PHP'
+ 'Generated\\NotAClass',
+ fn (string $className) => $file
+ );
+
+ $form = new MyForm();
+
+ try {
+ $processor->configureBuilder($form, new FormBuilder());
+ $this->fail('expect LogicException');
+ } catch (\LogicException $e) {
+ $this->assertEquals('Invalid generated class "Generated\NotAClass" in file "/tmp/invalid_class_configurator.php"', $e->getMessage());
+ }
+
+ $this->assertStringEqualsFile($file, $code);
+ }
+
+ public function test_generate()
+ {
+ $filename = '/tmp/manual_generated_configurator.php';
+
+ file_put_contents($filename, 'invalid php file');
+
+ $processor = new CompileAttributesProcessor(
+ fn (AttributeForm $form) => 'Generated\ManualConfigurator',
+ fn (string $className) => $filename
+ );
+
+ $form = new MyForm();
+
+ $processor->generate($form);
+
+ $this->assertFileExists($filename);
+ $this->assertStringEqualsFile(
+ $filename,
+ <<<'PHP'
+generates(Person::class);
+
+ $firstName = $builder->add('firstName', StringElement::class);
+ $firstName->satisfy(new NotBlank());
+ $firstName->satisfy(new ClosureConstraint($form->validateName(...)));
+ $firstName->hydrator(new Setter(null))->extractor(new Getter(null));
+
+ $lastName = $builder->add('lastName', StringElement::class);
+ $lastName->satisfy(new ClosureConstraint($form->validateName(...)));
+ $lastName->hydrator(new Setter(null))->extractor(new Getter(null));
+
+ $age = $builder->add('age', IntegerElement::class);
+ $age->satisfy(new Positive());
+ $age->satisfy(new LessThan(150));
+ $age->hydrator(new Setter(null))->extractor(new Getter(null));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ (\Closure::bind(function () use ($inner, $form) {
+ $form->firstName = $inner['firstName']->element();
+ $form->lastName = $inner['lastName']->element();
+ $form->age = $inner['age']->element();
+ }, null, MyForm::class))();
+ }
+}
+
+PHP
+ );
+
+ $this->assertFalse(class_exists('Generated\ManualConfigurator', false));
+ }
+}
+
+#[Generates(Person::class)]
+class MyForm extends AttributeForm
+{
+ #[NotBlank, CallbackConstraint('validateName'), GetSet]
+ private StringElement $firstName;
+
+ #[CallbackConstraint('validateName'), GetSet]
+ private StringElement $lastName;
+
+ #[Positive, LessThan(150), GetSet]
+ private IntegerElement $age;
+
+ public function validateName(?string $value, StringElement $input): bool
+ {
+ if (!$value) {
+ return true;
+ }
+
+ return preg_match('#[a-z][a-z -]*#i', $value);
+ }
+}
+
+class Person
+{
+ public function __construct(
+ public string $firstName,
+ public ?string $lastName = null,
+ public ?int $age = null,
+ ) {
+ }
+}
diff --git a/tests/Attribute/Processor/Element/ConstraintAttributeProcessorTest.php b/tests/Attribute/Processor/Element/ConstraintAttributeProcessorTest.php
new file mode 100644
index 0000000..18de5f9
--- /dev/null
+++ b/tests/Attribute/Processor/Element/ConstraintAttributeProcessorTest.php
@@ -0,0 +1,83 @@
+submit(['foo' => 'bar']);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('This value is too short. It should have 5 characters or more.', $form->foo->error()->global());
+
+ $form->submit(['foo' => 'azerty']);
+
+ $this->assertFalse($form->valid());
+ $this->assertEquals('This value should not be equal to "azerty".', $form->foo->error()->global());
+
+ $form->submit(['foo' => 'aqwzsx']);
+ $this->assertTrue($form->valid());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Length(min: 5), NotEqualTo('azerty')]
+ public StringElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Symfony\Component\Validator\Constraints\Length;
+use Symfony\Component\Validator\Constraints\NotEqualTo;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->satisfy(new Length(min: 5));
+ $foo->satisfy(new NotEqualTo('azerty'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Processor/Element/ExtractorAttributeProcessorTest.php b/tests/Attribute/Processor/Element/ExtractorAttributeProcessorTest.php
new file mode 100644
index 0000000..516078f
--- /dev/null
+++ b/tests/Attribute/Processor/Element/ExtractorAttributeProcessorTest.php
@@ -0,0 +1,70 @@
+import(['bar' => 'azerty']);
+ $this->assertSame('azerty', $form->foo->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Getter('bar')]
+ public StringElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\PropertyAccess\Getter;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->extractor(new Getter('bar'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+);
+ }
+}
diff --git a/tests/Attribute/Processor/Element/FilterAttributeProcessorTest.php b/tests/Attribute/Processor/Element/FilterAttributeProcessorTest.php
new file mode 100644
index 0000000..88674a8
--- /dev/null
+++ b/tests/Attribute/Processor/Element/FilterAttributeProcessorTest.php
@@ -0,0 +1,93 @@
+submit(['foo' => 'bar']);
+ $this->assertEquals('barAB', $form->foo->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[AFilter, BFilter]
+ public StringElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Tests\Form\Attribute\Processor\Element\AFilter;
+use Tests\Form\Attribute\Processor\Element\BFilter;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->filter(new AFilter());
+ $foo->filter(new BFilter());
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form
+ );
+ }
+}
+
+#[Attribute(Attribute::TARGET_PROPERTY)]
+class AFilter implements FilterInterface
+{
+ public function filter($value, ChildInterface $input, $default)
+ {
+ return $value . 'A';
+ }
+}
+
+#[Attribute(Attribute::TARGET_PROPERTY)]
+class BFilter implements FilterInterface
+{
+ public function filter($value, ChildInterface $input, $default)
+ {
+ return $value . 'B';
+ }
+}
diff --git a/tests/Attribute/Processor/Element/HydratorAttributeProcessorTest.php b/tests/Attribute/Processor/Element/HydratorAttributeProcessorTest.php
new file mode 100644
index 0000000..3301a52
--- /dev/null
+++ b/tests/Attribute/Processor/Element/HydratorAttributeProcessorTest.php
@@ -0,0 +1,69 @@
+submit(['foo' => 'azerty']);
+ $this->assertSame(['bar' => 'azerty'], $form->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[Setter('bar')]
+ public StringElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Bdf\Form\PropertyAccess\Setter;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->hydrator(new Setter('bar'));
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
diff --git a/tests/Attribute/Processor/Element/TransformerAttributeProcessorTest.php b/tests/Attribute/Processor/Element/TransformerAttributeProcessorTest.php
new file mode 100644
index 0000000..9390485
--- /dev/null
+++ b/tests/Attribute/Processor/Element/TransformerAttributeProcessorTest.php
@@ -0,0 +1,102 @@
+submit(['foo' => 'azerty']);
+ $this->assertEquals('azertyBA', $form->foo->value());
+ }
+
+ public function test_code_generator()
+ {
+ $form = new class extends AttributeForm {
+ #[ATransformer, BTransformer]
+ public StringElement $foo;
+ };
+
+ $this->assertGenerated(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Tests\Form\Attribute\Processor\Element\ATransformer;
+use Tests\Form\Attribute\Processor\Element\BTransformer;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+ $foo->transformer(new ATransformer());
+ $foo->transformer(new BTransformer());
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ }
+}
+
+PHP
+ , $form);
+ }
+}
+
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
+class ATransformer implements TransformerInterface
+{
+ public function transformToHttp($value, ElementInterface $input)
+ {
+
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return $value . 'A';
+ }
+}
+
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
+class BTransformer implements TransformerInterface
+{
+ public function transformToHttp($value, ElementInterface $input)
+ {
+
+ }
+
+ public function transformFromHttp($value, ElementInterface $input)
+ {
+ return $value . 'B';
+ }
+}
diff --git a/tests/Attribute/Processor/GenerateConfiguratorStrategyTest.php b/tests/Attribute/Processor/GenerateConfiguratorStrategyTest.php
new file mode 100644
index 0000000..7ad7fc7
--- /dev/null
+++ b/tests/Attribute/Processor/GenerateConfiguratorStrategyTest.php
@@ -0,0 +1,259 @@
+configureBuilder($form, new FormBuilder());
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Aggregate\Value\MyEntity;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->generates(MyEntity::class);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ }
+}
+
+PHP
+ , $generator->code());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_button_properties()
+ {
+ $generator = new GenerateConfiguratorStrategy('Generated\GeneratedConfigurator');
+ $form = new class extends AttributeForm {
+ public ButtonInterface $foo;
+ #[Value('bar'), Groups('aaa', 'bbb')]
+ public ButtonInterface $bar;
+ };
+
+ (new ReflectionProcessor($generator))->configureBuilder($form, new FormBuilder());
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $builder->submit('foo')
+ ;
+
+ $builder->submit('bar')
+ ->value('bar')
+ ->groups(['aaa', 'bbb'])
+ ;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $root = $form->root();
+ $form->foo = $root->button('foo');
+ $form->bar = $root->button('bar');
+ }
+}
+
+PHP
+ , $generator->code());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_element_properties()
+ {
+ $generator = new GenerateConfiguratorStrategy('Generated\GeneratedConfigurator');
+ $form = new class extends AttributeForm {
+ public StringElement $foo;
+ #[NotBlank, Positive]
+ public IntegerElement $bar;
+ };
+
+ (new ReflectionProcessor($generator))->configureBuilder($form, new FormBuilder());
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\IntegerElement;
+use Bdf\Form\Leaf\StringElement;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\Positive;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $foo = $builder->add('foo', StringElement::class);
+
+ $bar = $builder->add('bar', IntegerElement::class);
+ $bar->satisfy(new NotBlank());
+ $bar->satisfy(new Positive());
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $form->foo = $inner['foo']->element();
+ $form->bar = $inner['bar']->element();
+ }
+}
+
+PHP
+ , $generator->code());
+ }
+
+ /**
+ * @return void
+ */
+ public function test_generate_post_configure_method_with_private_visiblity()
+ {
+ $generator = new GenerateConfiguratorStrategy('Generated\GeneratedConfigurator');
+
+ (new ReflectionProcessor($generator))->configureBuilder(new CForm(), new FormBuilder());
+
+ $this->assertEquals(<<<'PHP'
+namespace Generated;
+
+use Bdf\Form\Aggregate\FormBuilderInterface;
+use Bdf\Form\Aggregate\FormInterface;
+use Bdf\Form\Attribute\AttributeForm;
+use Bdf\Form\Attribute\Processor\AttributesProcessorInterface;
+use Bdf\Form\Attribute\Processor\PostConfigureInterface;
+use Bdf\Form\Leaf\StringElement;
+use Tests\Form\Attribute\Processor\AForm;
+use Tests\Form\Attribute\Processor\BForm;
+use Tests\Form\Attribute\Processor\CForm;
+
+class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface
+{
+ /**
+ * {@inheritdoc}
+ */
+ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface
+ {
+ $d = $builder->add('d', StringElement::class);
+
+ $builder->submit('b')
+ ;
+
+ $c = $builder->add('c', StringElement::class);
+
+ $a = $builder->add('a', StringElement::class);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ function postConfigure(AttributeForm $form, FormInterface $inner): void
+ {
+ $root = $form->root();
+ (\Closure::bind(function () use ($inner, $form, $root) {
+ $form->d = $inner['d']->element();
+ }, null, CForm::class))();
+ (\Closure::bind(function () use ($inner, $form, $root) {
+ $form->c = $inner['c']->element();
+ $form->b = $root->button('b');
+ }, null, BForm::class))();
+ (\Closure::bind(function () use ($inner, $form, $root) {
+ $form->a = $inner['a']->element();
+ }, null, AForm::class))();
+ }
+}
+
+PHP
+ , $generator->code());
+ }
+}
+
+class AForm extends AttributeForm
+{
+ private StringElement $a;
+}
+
+class BForm extends AForm
+{
+ private ButtonInterface $b;
+ private StringElement $c;
+}
+
+class CForm extends BForm
+{
+ private StringElement $d;
+}
diff --git a/tests/Attribute/Processor/ReflectionProcessorTest.php b/tests/Attribute/Processor/ReflectionProcessorTest.php
new file mode 100644
index 0000000..70f6b12
--- /dev/null
+++ b/tests/Attribute/Processor/ReflectionProcessorTest.php
@@ -0,0 +1,81 @@
+createMock(ReflectionStrategyInterface::class);
+ $processor = new ReflectionProcessor($strategy);
+
+ $form = new B();
+ $builder = new FormBuilder();
+
+ $strategy->expects($matcher = $this->exactly(2))->method('onFormClass')->willReturnCallback(function (...$args) use ($matcher, $form, $builder) {
+ match ($matcher->numberOfInvocations()) {
+ 1 => $this->assertEquals([new \ReflectionClass(B::class), $form, $builder, $args[3]], $args),
+ 2 => $this->assertEquals([new \ReflectionClass(A::class), $form, $builder, $args[3]], $args),
+ };
+ });
+
+ $processor->configureBuilder($form, $builder);
+ }
+
+ public function test_should_not_configure_twice_same_element_property()
+ {
+ $strategy = $this->createMock(ReflectionStrategyInterface::class);
+ $processor = new ReflectionProcessor($strategy);
+
+ $form = new B();
+ $builder = new FormBuilder();
+
+ $strategy->expects($this->once())->method('onElementProperty')
+ ->with(new \ReflectionProperty(B::class, 'foo'), 'foo', StringElement::class, $form, $builder)
+ ;
+
+ $processor->configureBuilder($form, $builder);
+ }
+
+ public function test_should_not_configure_twice_same_button_property()
+ {
+ $strategy = $this->createMock(ReflectionStrategyInterface::class);
+ $processor = new ReflectionProcessor($strategy);
+
+ $form = new B();
+ $builder = new FormBuilder();
+
+ $strategy->expects($this->once())->method('onButtonProperty')
+ ->with(new \ReflectionProperty(B::class, 'btn'), 'btn', $form, $builder)
+ ;
+
+ $processor->configureBuilder($form, $builder);
+ }
+}
+
+class A extends AttributeForm
+{
+ public StringElement $foo;
+ protected ButtonInterface $btn;
+
+ public ButtonInterface|StringElement $withUnionType;
+ public \ArrayObject $withInvalidType;
+}
+
+class B extends A
+{
+ public $withoutType;
+ public array $withNotObjectType;
+
+ public StringElement $foo;
+ protected ButtonInterface $btn;
+}
diff --git a/tests/Attribute/TestCase.php b/tests/Attribute/TestCase.php
new file mode 100644
index 0000000..c18deeb
--- /dev/null
+++ b/tests/Attribute/TestCase.php
@@ -0,0 +1,37 @@
+ [new ReflectionProcessor(new ConfigureFormBuilderStrategy())],
+ 'compile' => [new CompileAttributesProcessor(
+ fn ($form) => 'Generated\\G' . bin2hex(random_bytes(16)),
+ fn ($className) => sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'Generated_' . str_replace('\\', '_', $className) . '.php'
+ )],
+ ];
+ }
+
+ public function assertGenerated(string $expected, AttributeForm $form): void
+ {
+ $generator = new GenerateConfiguratorStrategy('Generated\GeneratedConfigurator');
+ $processor = new ReflectionProcessor($generator);
+
+ $processor->configureBuilder($form, new FormBuilder());
+ $this->assertEquals($expected, $generator->code());
+ }
+}