diff --git a/README.md b/README.md index 77b47ff..d5e310b 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,16 @@ $processor->generate(new MyOtherForm()); | [`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 | @@ -153,6 +163,8 @@ $processor->generate(new MyOtherForm()); | [`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. | @@ -162,13 +174,17 @@ $processor->generate(new MyOtherForm()); | [`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/phpunit.xml.dist b/phpunit.xml.dist index a9e870d..3b8f2ad 100755 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -21,6 +21,10 @@ ./tests + ./tests/Php81 + + + ./tests/Php81 diff --git a/src/Aggregate/ArrayConstraint.php b/src/Aggregate/ArrayConstraint.php index d73332f..f6117fd 100644 --- a/src/Aggregate/ArrayConstraint.php +++ b/src/Aggregate/ArrayConstraint.php @@ -8,11 +8,15 @@ use Bdf\Form\Attribute\ChildBuilderAttributeInterface; use Bdf\Form\Attribute\Constraint\Satisfy; use Bdf\Form\Attribute\Processor\CodeGenerator\AttributesProcessorGenerator; -use Bdf\Form\Attribute\Processor\GenerateConfiguratorStrategy; +use Bdf\Form\Attribute\Processor\CodeGenerator\ObjectInstantiation; use Bdf\Form\Child\ChildBuilderInterface; +use InvalidArgumentException; use Nette\PhpGenerator\Literal; use Symfony\Component\Validator\Constraint; +use function is_object; +use function is_string; + /** * Add a constraint on the whole array element * Use Satisfy, or directly the constraint as attribute for add a constraint on one array item @@ -28,6 +32,10 @@ * { * #[ArrayConstraint(Unique::class, ['message' => 'My error'])] * private ArrayElement $values; + * + * // or on PHP 8.1 + * #[ArrayConstraint(new Unique(['message' => 'My error']))] + * private ArrayElement $values; * } * * @@ -43,12 +51,19 @@ final class ArrayConstraint implements ChildBuilderAttributeInterface { public function __construct( /** - * The constraint class name + * The constraint * - * @var class-string + * 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 class-string|Constraint * @readonly */ - private string $constraint, + private string|Constraint $constraint, /** * Constraint's constructor options * @@ -57,6 +72,9 @@ public function __construct( */ private mixed $options = null ) { + if (is_object($constraint) && $options !== null) { + throw new InvalidArgumentException('Cannot use options with constraint instance'); + } } /** @@ -72,8 +90,12 @@ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $ */ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void { - $constraint = $generator->useAndSimplifyType($this->constraint); - - $generator->line('$?->arrayConstraint(?::class, ?);', [$name, new Literal($constraint), $this->options]); + if (is_string($this->constraint)) { + $constraint = $generator->useAndSimplifyType($this->constraint); + $generator->line('$?->arrayConstraint(?::class, ?);', [$name, new Literal($constraint), $this->options]); + } else { + $constraint = ObjectInstantiation::singleArrayParameter($this->constraint)->render($generator); + $generator->line('$?->arrayConstraint(?);', [$name, $constraint]); + } } } diff --git a/src/Aggregate/ArrayTransformer.php b/src/Aggregate/ArrayTransformer.php new file mode 100644 index 0000000..7dfef5f --- /dev/null +++ b/src/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/Aggregate/AsArrayConstraint.php b/src/Aggregate/AsArrayConstraint.php new file mode 100644 index 0000000..fc9e90c --- /dev/null +++ b/src/Aggregate/AsArrayConstraint.php @@ -0,0 +1,91 @@ + + * $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 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, + ) { + } + + /** + * {@inheritdoc} + */ + public function targetElements(): array + { + return [$this->target]; + } + + /** + * {@inheritdoc} + */ + public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface + { + return new CallbackArrayConstraint($method->getName(), $this->message); + } +} diff --git a/src/Aggregate/CallbackArrayConstraint.php b/src/Aggregate/CallbackArrayConstraint.php index 88986cb..bb83539 100644 --- a/src/Aggregate/CallbackArrayConstraint.php +++ b/src/Aggregate/CallbackArrayConstraint.php @@ -7,10 +7,8 @@ use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\ChildBuilderAttributeInterface; use Bdf\Form\Attribute\Processor\CodeGenerator\AttributesProcessorGenerator; -use Bdf\Form\Attribute\Processor\GenerateConfiguratorStrategy; use Bdf\Form\Child\ChildBuilderInterface; use Bdf\Form\Constraint\Closure; -use Bdf\Form\ElementBuilderInterface; use Nette\PhpGenerator\Literal; use Symfony\Component\Validator\Constraint; @@ -61,7 +59,7 @@ public function __construct( * - Return string for a custom error message * - Return array with error message and code * - * @var literal-string + * @var non-empty-string * @readonly */ private string $methodName, diff --git a/src/Child/AsFilter.php b/src/Child/AsFilter.php new file mode 100644 index 0000000..cdc704b --- /dev/null +++ b/src/Child/AsFilter.php @@ -0,0 +1,74 @@ + + * $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 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; + } + + /** + * {@inheritdoc} + */ + public function targetElements(): array + { + return $this->targets; + } + + /** + * {@inheritdoc} + */ + public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface + { + return new CallbackFilter($method->getName()); + } +} diff --git a/src/Child/AsModelTransformer.php b/src/Child/AsModelTransformer.php new file mode 100644 index 0000000..6a85f06 --- /dev/null +++ b/src/Child/AsModelTransformer.php @@ -0,0 +1,82 @@ + + * $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 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; + } + + /** + * {@inheritdoc} + */ + public function targetElements(): array + { + return $this->targets; + } + + /** + * {@inheritdoc} + */ + public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface + { + return new CallbackModelTransformer($method->getName()); + } +} diff --git a/src/Child/CallbackFilter.php b/src/Child/CallbackFilter.php new file mode 100644 index 0000000..3c0aace --- /dev/null +++ b/src/Child/CallbackFilter.php @@ -0,0 +1,74 @@ + + * $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 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, + ) { + } + + /** + * {@inheritdoc} + */ + public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void + { + $builder->filter([$form, $this->method]); + } + + /** + * {@inheritdoc} + */ + public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void + { + $generator->line('$?->filter([$form, ?]);', [$name, $this->method]); + } +} diff --git a/src/Child/CallbackModelTransformer.php b/src/Child/CallbackModelTransformer.php index 65f8e21..701d080 100644 --- a/src/Child/CallbackModelTransformer.php +++ b/src/Child/CallbackModelTransformer.php @@ -70,6 +70,7 @@ * @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 */ @@ -81,21 +82,21 @@ public function __construct( * Method name use to define the unified transformer method * If defined, the other parameters will be ignored * - * @var literal-string|null + * @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 literal-string|null + * @var non-empty-string|null * @readonly */ private ?string $toEntity = null, /** * Method name use to define the transformation process from entity value to input * - * @var literal-string|null + * @var non-empty-string|null * @readonly */ private ?string $toInput = null, diff --git a/src/Child/Configure.php b/src/Child/Configure.php index caab40a..c5f6a82 100644 --- a/src/Child/Configure.php +++ b/src/Child/Configure.php @@ -6,14 +6,18 @@ use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\ChildBuilderAttributeInterface; use Bdf\Form\Attribute\Processor\CodeGenerator\AttributesProcessorGenerator; -use Bdf\Form\Attribute\Processor\GenerateConfiguratorStrategy; +use Bdf\Form\Attribute\Processor\MethodChildBuilderAttributeInterface; use Bdf\Form\Child\ChildBuilderInterface; +use ReflectionMethod; /** * Define a custom configuration method for an element * * Use this attribute when other attributes cannot be used to configure the current element - * Takes as argument the method name to use. This method must be declared into the form class with public visibility + * Takes as argument the method name to use. This method must be declared into the form class with public visibility. + * + * It can also be used to directly annotate the configuration method. + * In this case, it will take as argument the property name to configure. * * Usage: * @@ -26,25 +30,37 @@ * { * $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::IS_REPEATABLE)] -class Configure implements ChildBuilderAttributeInterface +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Configure implements ChildBuilderAttributeInterface, MethodChildBuilderAttributeInterface { public function __construct( /** - * The method name to use as configurator + * 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` * - * @var literal-string + * In case of a method attribute, define the property name to configure. + * + * @var non-empty-string * @readonly */ - private string $callback + private string $target ) { } @@ -53,7 +69,7 @@ public function __construct( */ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void { - $form->{$this->callback}($builder); + $form->{$this->target}($builder); } /** @@ -61,6 +77,22 @@ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $ */ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void { - $generator->line('$form->?($?);', [$this->callback, $name]); + $generator->line('$form->?($?);', [$this->target, $name]); + } + + /** + * {@inheritdoc} + */ + public function targetElements(): array + { + return [$this->target]; + } + + /** + * {@inheritdoc} + */ + public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface + { + return new self($method->getName()); } } diff --git a/src/Child/HttpField.php b/src/Child/HttpField.php new file mode 100644 index 0000000..0d62df6 --- /dev/null +++ b/src/Child/HttpField.php @@ -0,0 +1,78 @@ + + * $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 class HttpField implements ChildBuilderAttributeInterface +{ + public function __construct( + /** + * The HTTP field name + * + * @var non-empty-string + * @readonly + */ + private string $name, + ) { + } + + /** + * {@inheritdoc} + */ + 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)); + } + + /** + * {@inheritdoc} + */ + 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/Constraint/AsConstraint.php b/src/Constraint/AsConstraint.php new file mode 100644 index 0000000..ef05d98 --- /dev/null +++ b/src/Constraint/AsConstraint.php @@ -0,0 +1,91 @@ + + * $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 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, + ) { + } + + /** + * {@inheritdoc} + */ + public function targetElements(): array + { + return [$this->target]; + } + + /** + * {@inheritdoc} + */ + public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface + { + return new CallbackConstraint($method->getName(), $this->message); + } +} diff --git a/src/Constraint/CallbackConstraint.php b/src/Constraint/CallbackConstraint.php index e28ee96..249bea6 100644 --- a/src/Constraint/CallbackConstraint.php +++ b/src/Constraint/CallbackConstraint.php @@ -42,6 +42,7 @@ * @see ElementBuilderInterface::satisfy() The called method * @see Constraint * @see Closure The used constraint + * @see AsConstraint To annotate the method instead of the property * * @api */ @@ -61,7 +62,7 @@ public function __construct( * - Return string for a custom error message * - Return array with error message and code * - * @var literal-string + * @var non-empty-string * @readonly */ private string $methodName, diff --git a/src/Constraint/Satisfy.php b/src/Constraint/Satisfy.php index fa046d4..83c7ab6 100644 --- a/src/Constraint/Satisfy.php +++ b/src/Constraint/Satisfy.php @@ -6,11 +6,15 @@ use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\ChildBuilderAttributeInterface; use Bdf\Form\Attribute\Processor\CodeGenerator\AttributesProcessorGenerator; -use Bdf\Form\Attribute\Processor\GenerateConfiguratorStrategy; +use Bdf\Form\Attribute\Processor\CodeGenerator\ObjectInstantiation; use Bdf\Form\Child\ChildBuilderInterface; +use InvalidArgumentException; use Nette\PhpGenerator\Literal; use Symfony\Component\Validator\Constraint; +use function is_object; +use function is_string; + /** * Define a custom constraint for an element, using a validation method * @@ -27,6 +31,10 @@ * { * #[Satisfy(MyConstraint::class, ['foo' => 'bar'])] * private IntegerElement $foo; + * + * // or on PHP 8.1 + * #[Satisfy(new MyConstraint(['foo' => 'bar']))] + * private IntegerElement $foo; * } * * @@ -42,12 +50,18 @@ class Satisfy implements ChildBuilderAttributeInterface { public function __construct( /** - * The constraint class name + * The constraint + * + * You can use a class name, and provider arguments on the next parameter, + * or directly use the constraint instance. * - * @var class-string + * 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 class-string|Constraint * @readonly */ - private string $constraint, + private string|Constraint $constraint, /** * Constraint's constructor options * @@ -56,6 +70,9 @@ public function __construct( */ private mixed $options = null ) { + if (is_object($constraint) && $options !== null) { + throw new InvalidArgumentException('Cannot use options with constraint instance'); + } } /** @@ -71,7 +88,12 @@ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $ */ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void { - $type = $generator->useAndSimplifyType($this->constraint); - $generator->line('$?->satisfy(?::class, ?);', [$name, new Literal($type), $this->options]); + if (is_string($this->constraint)) { + $type = $generator->useAndSimplifyType($this->constraint); + $generator->line('$?->satisfy(?::class, ?);', [$name, new Literal($type), $this->options]); + } else { + $constraint = ObjectInstantiation::singleArrayParameter($this->constraint)->render($generator); + $generator->line('$?->satisfy(?);', [$name, $constraint]); + } } } diff --git a/src/Element/AsTransformer.php b/src/Element/AsTransformer.php new file mode 100644 index 0000000..aea8c06 --- /dev/null +++ b/src/Element/AsTransformer.php @@ -0,0 +1,82 @@ + + * $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 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; + } + + /** + * {@inheritdoc} + */ + public function targetElements(): array + { + return $this->targets; + } + + /** + * {@inheritdoc} + */ + public function attribute(ReflectionMethod $method): ChildBuilderAttributeInterface + { + return new CallbackTransformer($method->getName()); + } +} diff --git a/src/Element/CallbackTransformer.php b/src/Element/CallbackTransformer.php index 330dfe2..310b166 100644 --- a/src/Element/CallbackTransformer.php +++ b/src/Element/CallbackTransformer.php @@ -84,21 +84,21 @@ public function __construct( * Method name use to define the unified transformer method * If defined, the other parameters will be ignored * - * @var literal-string|null + * @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 literal-string|null + * @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 literal-string|null + * @var non-empty-string|null * @readonly */ private ?string $toHttp = null, diff --git a/src/Element/Date/AfterField.php b/src/Element/Date/AfterField.php new file mode 100644 index 0000000..b231d6a --- /dev/null +++ b/src/Element/Date/AfterField.php @@ -0,0 +1,81 @@ + + * $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 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, + ) { + } + + /** + * {@inheritdoc} + */ + public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void + { + $builder->afterField($this->field, $this->message, $this->orEqual); + } + + /** + * {@inheritdoc} + */ + public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void + { + $generator->line('$?->afterField(?, ?, ?);', [$name, $this->field, $this->message, $this->orEqual]); + } +} diff --git a/src/Element/Date/BeforeField.php b/src/Element/Date/BeforeField.php new file mode 100644 index 0000000..504e059 --- /dev/null +++ b/src/Element/Date/BeforeField.php @@ -0,0 +1,81 @@ + + * $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 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, + ) { + } + + /** + * {@inheritdoc} + */ + public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void + { + $builder->beforeField($this->field, $this->message, $this->orEqual); + } + + /** + * {@inheritdoc} + */ + public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void + { + $generator->line('$?->beforeField(?, ?, ?);', [$name, $this->field, $this->message, $this->orEqual]); + } +} diff --git a/src/Element/Required.php b/src/Element/Required.php new file mode 100644 index 0000000..4f2629a --- /dev/null +++ b/src/Element/Required.php @@ -0,0 +1,66 @@ + + * $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 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, + ) { + } + + /** + * {@inheritdoc} + */ + public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void + { + $builder->required($this->message); + } + + /** + * {@inheritdoc} + */ + public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void + { + $generator->line('$?->required(?);', [$name, $this->message]); + } +} diff --git a/src/Element/Transformer.php b/src/Element/Transformer.php index 85a7aed..bf90a96 100644 --- a/src/Element/Transformer.php +++ b/src/Element/Transformer.php @@ -3,6 +3,8 @@ namespace Bdf\Form\Attribute\Element; use Attribute; +use Bdf\Form\Aggregate\ArrayElementBuilder; +use Bdf\Form\Attribute\Aggregate\ArrayTransformer; use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\ChildBuilderAttributeInterface; use Bdf\Form\Attribute\Processor\CodeGenerator\AttributesProcessorGenerator; @@ -34,6 +36,7 @@ * @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 @@ -56,6 +59,20 @@ public function __construct( * @readonly */ private 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 bool $array = false, ) { } @@ -64,7 +81,14 @@ public function __construct( */ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $builder): void { - $builder->transformer(new $this->transformerClass(...$this->constructorArguments)); + $transformer = new $this->transformerClass(...$this->constructorArguments); + + if ($this->array) { + /** @var ChildBuilderInterface $builder */ + $builder->arrayTransformer($transformer); + } else { + $builder->transformer($transformer); + } } /** @@ -73,6 +97,8 @@ public function applyOnChildBuilder(AttributeForm $form, ChildBuilderInterface $ public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, AttributeForm $form): void { $transformer = $generator->useAndSimplifyType($this->transformerClass); - $generator->line('$?->transformer(new ?(...?));', [$name, new Literal($transformer), $this->constructorArguments]); + $code = $this->array ? '$?->arrayTransformer(new ?(...?));' : '$?->transformer(new ?(...?));'; + + $generator->line($code, [$name, new Literal($transformer), $this->constructorArguments]); } } diff --git a/src/Processor/CodeGenerator/ObjectInstantiation.php b/src/Processor/CodeGenerator/ObjectInstantiation.php new file mode 100644 index 0000000..9d1630c --- /dev/null +++ b/src/Processor/CodeGenerator/ObjectInstantiation.php @@ -0,0 +1,92 @@ +useAndSimplifyType($this->className) : $this->className; + + return Literal::new($className, $this->constructorParameters); + } + + /** + * Configure the ObjectInstantiation utility to generate + * the constructor call with a single array parameter, + * use to inject all public properties of an object. + * + * This method should be used for symfony constraints. + * Properties with default value will be ignored. + * + * @param object $object + * @return self + */ + public static function singleArrayParameter(object $object): self + { + return new self( + get_class($object), + [self::extractPublicProperties($object)] + ); + } + + /** + * Extract all public properties of the object, ignoring default values + * + * @param object $object + * + * @return array + * @psalm-suppress MixedAssignment + */ + private static function extractPublicProperties(object $object): array + { + $reflectionObject = new ReflectionObject($object); + $properties = []; + + foreach ($reflectionObject->getProperties() as $property) { + if ($property->isPublic() && !$property->isStatic()) { + $property->setAccessible(true); + $value = $property->getValue($object); + + if ($property->getDefaultValue() !== $value) { + $properties[$property->getName()] = $value; + } + } + } + + return $properties; + } +} diff --git a/src/Processor/ConfigureFormBuilderStrategy.php b/src/Processor/ConfigureFormBuilderStrategy.php index cd081fa..62aece7 100644 --- a/src/Processor/ConfigureFormBuilderStrategy.php +++ b/src/Processor/ConfigureFormBuilderStrategy.php @@ -39,7 +39,7 @@ public function __construct() /** * {@inheritdoc} */ - public function onFormClass(ReflectionClass $formClass, AttributeForm $form, FormBuilderInterface $builder): void + 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); @@ -49,7 +49,7 @@ public function onFormClass(ReflectionClass $formClass, AttributeForm $form, For /** * {@inheritdoc} */ - public function onButtonProperty(ReflectionProperty $property, string $name, AttributeForm $form, FormBuilderInterface $builder): void + public function onButtonProperty(ReflectionProperty $property, string $name, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void { $submitBuilder = $builder->submit($name); @@ -61,7 +61,7 @@ public function onButtonProperty(ReflectionProperty $property, string $name, Att /** * {@inheritdoc} */ - public function onElementProperty(ReflectionProperty $property, string $name, string $elementType, AttributeForm $form, FormBuilderInterface $builder): void + public function onElementProperty(ReflectionProperty $property, string $name, string $elementType, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void { $elementBuilder = $builder->add($name, $elementType); @@ -80,14 +80,18 @@ public function onElementProperty(ReflectionProperty $property, string $name, st } } } + + foreach ($metadata->registeredChildAttributes($name) as $attribute) { + $attribute->applyOnChildBuilder($form, $elementBuilder); + } } /** * {@inheritdoc} */ - public function onPostConfigure(array $elementProperties, array $buttonProperties, AttributeForm $form): ?PostConfigureInterface + public function onPostConfigure(ProcessorMetadata $metadata, AttributeForm $form): ?PostConfigureInterface { - return new PostConfigureReflectionSetProperties($elementProperties, $buttonProperties); + return new PostConfigureReflectionSetProperties($metadata->elementProperties(), $metadata->buttonProperties()); } /** diff --git a/src/Processor/GenerateConfiguratorStrategy.php b/src/Processor/GenerateConfiguratorStrategy.php index 5e2ae5f..503a884 100644 --- a/src/Processor/GenerateConfiguratorStrategy.php +++ b/src/Processor/GenerateConfiguratorStrategy.php @@ -50,19 +50,24 @@ public function __construct(string $className) /** * {@inheritdoc} */ - public function onFormClass(ReflectionClass $formClass, AttributeForm $form, FormBuilderInterface $builder): void + 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; } - $this->generator->line(); + if (!$empty) { + $this->generator->line(); + } } /** * {@inheritdoc} */ - public function onButtonProperty(ReflectionProperty $property, string $name, AttributeForm $form, FormBuilderInterface $builder): void + public function onButtonProperty(ReflectionProperty $property, string $name, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void { $this->generator->line('$builder->submit(?)', [$name]); @@ -76,7 +81,7 @@ public function onButtonProperty(ReflectionProperty $property, string $name, Att /** * {@inheritdoc} */ - public function onElementProperty(ReflectionProperty $property, string $name, string $elementType, AttributeForm $form, FormBuilderInterface $builder): void + 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)]); @@ -96,13 +101,17 @@ public function onElementProperty(ReflectionProperty $property, string $name, st } } + foreach ($metadata->registeredChildAttributes($name) as $attribute) { + $attribute->generateCodeForChildBuilder($name, $this->generator, $form); + } + $this->generator->line(); // Add empty line } /** * {@inheritdoc} */ - public function onPostConfigure(array $elementProperties, array $buttonProperties, AttributeForm $form): ?PostConfigureInterface + public function onPostConfigure(ProcessorMetadata $metadata, AttributeForm $form): ?PostConfigureInterface { $this->generator->line('return $this;'); @@ -111,6 +120,9 @@ public function onPostConfigure(array $elementProperties, array $buttonPropertie ->implementsMethod(PostConfigureInterface::class, 'postConfigure') ; + $elementProperties = $metadata->elementProperties(); + $buttonProperties = $metadata->buttonProperties(); + if (!empty($buttonProperties)) { $method->addBody('$root = $form->root();'); } diff --git a/src/Processor/MethodChildBuilderAttributeInterface.php b/src/Processor/MethodChildBuilderAttributeInterface.php new file mode 100644 index 0000000..1e15a40 --- /dev/null +++ b/src/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/Processor/ProcessorMetadata.php b/src/Processor/ProcessorMetadata.php new file mode 100644 index 0000000..4ccf720 --- /dev/null +++ b/src/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/Processor/ReflectionProcessor.php b/src/Processor/ReflectionProcessor.php index e620d80..2dd97a2 100644 --- a/src/Processor/ReflectionProcessor.php +++ b/src/Processor/ReflectionProcessor.php @@ -6,7 +6,9 @@ use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Button\ButtonInterface; use Bdf\Form\ElementInterface; +use ReflectionAttribute; use ReflectionClass; +use ReflectionMethod; use ReflectionNamedType; /** @@ -39,11 +41,13 @@ public function __construct(ReflectionStrategyInterface $strategy) */ public function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ?PostConfigureInterface { - $elementProperties = []; - $buttonProperties = []; + $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); + $this->strategy->onFormClass($formClass, $form, $builder, $metadata); foreach ($formClass->getProperties() as $property) { $name = $property->getName(); @@ -51,8 +55,7 @@ public function configureBuilder(AttributeForm $form, FormBuilderInterface $buil if ( !$property->hasType() || !$property->getType() instanceof ReflectionNamedType - || isset($elementProperties[$name]) - || isset($buttonProperties[$name]) + || $metadata->hasProperty($name) ) { continue; } @@ -61,16 +64,16 @@ public function configureBuilder(AttributeForm $form, FormBuilderInterface $buil $property->setAccessible(true); if ($elementType === ButtonInterface::class) { - $buttonProperties[$name] = $property; - $this->strategy->onButtonProperty($property, $name, $form, $builder); + $metadata->addButtonProperty($name, $property); + $this->strategy->onButtonProperty($property, $name, $form, $builder, $metadata); } elseif (is_subclass_of($elementType, ElementInterface::class)) { - $elementProperties[$name] = $property; - $this->strategy->onElementProperty($property, $name, $elementType, $form, $builder); + $metadata->addElementProperty($name, $property); + $this->strategy->onElementProperty($property, $name, $elementType, $form, $builder, $metadata); } } } - return $this->strategy->onPostConfigure($elementProperties, $buttonProperties, $form); + return $this->strategy->onPostConfigure($metadata, $form); } /** @@ -89,4 +92,26 @@ private function iterateClassHierarchy(AttributeForm $form): iterable 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/Processor/ReflectionStrategyInterface.php b/src/Processor/ReflectionStrategyInterface.php index 719ae0b..dd72a91 100644 --- a/src/Processor/ReflectionStrategyInterface.php +++ b/src/Processor/ReflectionStrategyInterface.php @@ -25,10 +25,11 @@ interface ReflectionStrategyInterface * @param ReflectionClass $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): void; + public function onFormClass(ReflectionClass $formClass, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void; /** * Configure a button following the declared property @@ -39,10 +40,11 @@ public function onFormClass(ReflectionClass $formClass, AttributeForm $form, For * @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): void; + public function onButtonProperty(ReflectionProperty $property, string $name, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void; /** * Configure an element following the declared property @@ -54,16 +56,17 @@ public function onButtonProperty(ReflectionProperty $property, string $name, Att * @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): void; + public function onElementProperty(ReflectionProperty $property, string $name, string $elementType, AttributeForm $form, FormBuilderInterface $builder, ProcessorMetadata $metadata): void; /** - * @param array $elementProperties - * @param array $buttonProperties - * @param AttributeForm $form + * @param ProcessorMetadata $metadata Metadata for the current form + * @param AttributeForm $form The current form instance + * * @return PostConfigureInterface|null */ - public function onPostConfigure(array $elementProperties, array $buttonProperties, AttributeForm $form): ?PostConfigureInterface; + public function onPostConfigure(ProcessorMetadata $metadata, AttributeForm $form): ?PostConfigureInterface; } diff --git a/tests/Aggregate/ArrayTransformerTest.php b/tests/Aggregate/ArrayTransformerTest.php new file mode 100644 index 0000000..0f7c0d6 --- /dev/null +++ b/tests/Aggregate/ArrayTransformerTest.php @@ -0,0 +1,94 @@ +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/Aggregate/AsArrayConstraintTest.php b/tests/Aggregate/AsArrayConstraintTest.php new file mode 100644 index 0000000..baaae23 --- /dev/null +++ b/tests/Aggregate/AsArrayConstraintTest.php @@ -0,0 +1,108 @@ +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(['callback' => [$form, 'validateFoo'], 'message' => '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/Child/AsFilterTest.php b/tests/Child/AsFilterTest.php new file mode 100644 index 0000000..4471fad --- /dev/null +++ b/tests/Child/AsFilterTest.php @@ -0,0 +1,98 @@ +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/Child/AsModelTransformerTest.php b/tests/Child/AsModelTransformerTest.php new file mode 100644 index 0000000..53a292d --- /dev/null +++ b/tests/Child/AsModelTransformerTest.php @@ -0,0 +1,96 @@ +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/Child/CallbackFilterTest.php b/tests/Child/CallbackFilterTest.php new file mode 100644 index 0000000..fe82523 --- /dev/null +++ b/tests/Child/CallbackFilterTest.php @@ -0,0 +1,90 @@ +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/Child/ConfigureTest.php b/tests/Child/ConfigureTest.php index a557b54..aea2064 100644 --- a/tests/Child/ConfigureTest.php +++ b/tests/Child/ConfigureTest.php @@ -38,6 +38,32 @@ public function configureFoo(ChildBuilderInterface $builder): void $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 { @@ -85,6 +111,58 @@ function postConfigure(AttributeForm $form, FormInterface $inner): void } } +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/Child/HttpFieldTest.php b/tests/Child/HttpFieldTest.php new file mode 100644 index 0000000..41ffe08 --- /dev/null +++ b/tests/Child/HttpFieldTest.php @@ -0,0 +1,82 @@ +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/Constraint/AsConstraintTest.php b/tests/Constraint/AsConstraintTest.php new file mode 100644 index 0000000..6677a39 --- /dev/null +++ b/tests/Constraint/AsConstraintTest.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 { + 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(['callback' => [$form, 'validateFoo'], 'message' => '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/Element/AsTransformerTest.php b/tests/Element/AsTransformerTest.php new file mode 100644 index 0000000..c6161bf --- /dev/null +++ b/tests/Element/AsTransformerTest.php @@ -0,0 +1,85 @@ +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/Element/Date/AfterFieldTest.php b/tests/Element/Date/AfterFieldTest.php new file mode 100644 index 0000000..34e6be7 --- /dev/null +++ b/tests/Element/Date/AfterFieldTest.php @@ -0,0 +1,158 @@ +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/Element/Date/BeforeFieldTest.php b/tests/Element/Date/BeforeFieldTest.php new file mode 100644 index 0000000..180ba86 --- /dev/null +++ b/tests/Element/Date/BeforeFieldTest.php @@ -0,0 +1,159 @@ +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/Element/RequiredTest.php b/tests/Element/RequiredTest.php new file mode 100644 index 0000000..6df30e7 --- /dev/null +++ b/tests/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/Element/TransformerTest.php b/tests/Element/TransformerTest.php index 6da284c..8f24d2e 100644 --- a/tests/Element/TransformerTest.php +++ b/tests/Element/TransformerTest.php @@ -2,6 +2,7 @@ namespace Tests\Form\Attribute\Element; +use Bdf\Form\Aggregate\ArrayElement; use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Element\Transformer; use Bdf\Form\Attribute\Processor\AttributesProcessorInterface; @@ -29,6 +30,23 @@ public function test(AttributesProcessorInterface $processor) $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 { @@ -69,6 +87,51 @@ function postConfigure(AttributeForm $form, FormInterface $inner): void } } +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 ); @@ -92,3 +155,21 @@ 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/FunctionalTest.php b/tests/FunctionalTest.php index 987e262..e84898f 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -4,6 +4,7 @@ use Attribute; use Bdf\Form\Attribute\AttributeForm; +use Bdf\Form\Attribute\Element\AsTransformer; use Bdf\Form\Attribute\Processor\AttributesProcessorInterface; use Bdf\Form\Button\ButtonInterface; use Bdf\Form\Button\SubmitButton; @@ -150,7 +151,6 @@ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ? $bar->hydrator(new Setter()); $bar->satisfy(new GreaterThan(5)); - $foo = $builder->add('foo', StringElement::class); $foo->satisfy(new NotBlank()); $foo->extractor(new Getter()); @@ -178,6 +178,70 @@ function postConfigure(AttributeForm $form, FormInterface $inner): void ); } + /** + * + */ + 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 */ @@ -261,3 +325,21 @@ 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/Php81/Aggregate/ArrayConstraintTest.php b/tests/Php81/Aggregate/ArrayConstraintTest.php new file mode 100644 index 0000000..67c44c5 --- /dev/null +++ b/tests/Php81/Aggregate/ArrayConstraintTest.php @@ -0,0 +1,97 @@ + 'Not unique']))] + public ArrayElement $values; + }; + + $form->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()); + } + + /** + * @dataProvider provideAttributesProcessor + */ + public function test_disallow_constraint_instance_with_option_arg(AttributesProcessorInterface $processor) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot use options with constraint instance'); + + $form = new class(null, $processor) extends AttributeForm { + #[ArrayConstraint(new Unique(['message' => 'Not unique']), ['foo' => 'bar'])] + public ArrayElement $values; + }; + + $form->submit(['values' => ['aaa', 'aaa']]); + } + + /** + * @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/Php81/Aggregate/CallbackArrayConstraintTest.php b/tests/Php81/Aggregate/CallbackArrayConstraintTest.php new file mode 100644 index 0000000..7c64ffc --- /dev/null +++ b/tests/Php81/Aggregate/CallbackArrayConstraintTest.php @@ -0,0 +1,108 @@ +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(['callback' => [$form, 'validateFoo'], 'message' => '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/Php81/Constraint/SatisfyTest.php b/tests/Php81/Constraint/SatisfyTest.php new file mode 100644 index 0000000..61d8011 --- /dev/null +++ b/tests/Php81/Constraint/SatisfyTest.php @@ -0,0 +1,76 @@ + 3]))] + public StringElement $foo; + }; + + $form->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/Processor/CodeGenerator/ObjectInstantiationTest.php b/tests/Processor/CodeGenerator/ObjectInstantiationTest.php new file mode 100644 index 0000000..8d1c6be --- /dev/null +++ b/tests/Processor/CodeGenerator/ObjectInstantiationTest.php @@ -0,0 +1,40 @@ +assertEquals("new Symfony\Component\Validator\Constraints\NotBlank(['groups' => ['Default']])", ObjectInstantiation::singleArrayParameter($o)->render()); + + $this->assertEquals($o, eval('return ' . ObjectInstantiation::singleArrayParameter($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']])", ObjectInstantiation::singleArrayParameter($o)->render()); + + $this->assertEquals($o, eval('return ' . ObjectInstantiation::singleArrayParameter($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']])", ObjectInstantiation::singleArrayParameter($o)->render($generator)); + } +} diff --git a/tests/Processor/GenerateConfiguratorStrategyTest.php b/tests/Processor/GenerateConfiguratorStrategyTest.php index 2b6f92d..7ad7fc7 100644 --- a/tests/Processor/GenerateConfiguratorStrategyTest.php +++ b/tests/Processor/GenerateConfiguratorStrategyTest.php @@ -208,13 +208,11 @@ function configureBuilder(AttributeForm $form, FormBuilderInterface $builder): ? { $d = $builder->add('d', StringElement::class); - $builder->submit('b') ; $c = $builder->add('c', StringElement::class); - $a = $builder->add('a', StringElement::class); return $this;