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()); + } +}