diff --git a/src/Aggregate/ArrayElementBuilder.php b/src/Aggregate/ArrayElementBuilder.php index 23ce742..23cd23a 100644 --- a/src/Aggregate/ArrayElementBuilder.php +++ b/src/Aggregate/ArrayElementBuilder.php @@ -103,7 +103,7 @@ public function satisfy(Constraint|callable $constraint, ?string $message = null * Define a transformer on the inner element */ #[Override] - public function transformer(callable|TransformerInterface $transformer, bool $append = true): static + public function transformer(callable|TransformerInterface|string $transformer, bool $append = true): static { $this->getElementBuilder()->transformer($transformer, $append); @@ -440,14 +440,18 @@ final public function required(string|Constraint|null $message = null, ?bool $al */ final public function choices(ChoiceInterface|array|string|callable $choices, ?string $message = null, ?bool $multiple = null, ?bool $strict = null, ?int $min = null, ?int $max = null, ?string $minMessage = null, ?string $maxMessage = null): static { - /** @psalm-suppress MissingConstructor */ - $builder = new class { + /** @psalm-suppress PropertyNotSetInConstructor */ + $builder = new class($this->registry) { use ChoiceBuilderTrait { getChoices as public; } public ChoiceConstraint $constraint; + public function __construct( + private readonly RegistryInterface $registry, + ) {} + #[Override] public function satisfy(Constraint|callable $constraint, ?string $message = null, bool $append = true): static { @@ -455,6 +459,12 @@ public function satisfy(Constraint|callable $constraint, ?string $message = null $this->constraint = $constraint; return $this; } + + #[Override] + protected function registry(): RegistryInterface + { + return $this->registry; + } }; // Force the multiple option to true diff --git a/src/Attribute/Aggregate/ArrayTransformer.php b/src/Attribute/Aggregate/ArrayTransformer.php index 7dfef5f..87fa3cb 100644 --- a/src/Attribute/Aggregate/ArrayTransformer.php +++ b/src/Attribute/Aggregate/ArrayTransformer.php @@ -20,8 +20,12 @@ * * class MyForm extends AttributeForm * { - * #[ArrayTransformer(MyTransformer::class, ['foo', 'bar']), ElementType(IntegerElement::class)] + * #[ArrayTransformer(new MyTransformer(['foo', 'bar']), ElementType(IntegerElement::class)] * private ArrayElement $foo; + * + * // Load the transformer from the registry/container + * #[ArrayTransformer(TransformerService::class)] + * private ArrayElement $bar; * } * * @@ -35,11 +39,11 @@ class ArrayTransformer extends Transformer { /** - * @param class-string $transformerClass The transformer class name + * @param class-string|TransformerInterface $transformer The transformer class name or instance * @param array $constructorArguments Arguments to provide on the transformer constructor */ - public function __construct(string $transformerClass, array $constructorArguments = []) + public function __construct(string|TransformerInterface $transformer, array $constructorArguments = []) { - parent::__construct($transformerClass, $constructorArguments, true); + parent::__construct($transformer, $constructorArguments, true); } } diff --git a/src/Attribute/Constraint/Satisfy.php b/src/Attribute/Constraint/Satisfy.php index c8cbef1..46a4037 100644 --- a/src/Attribute/Constraint/Satisfy.php +++ b/src/Attribute/Constraint/Satisfy.php @@ -21,18 +21,14 @@ * * This attribute is equivalent to call : * - * $builder->integer('foo')->satisfy(MyConstraint::class, $options); + * $builder->integer('foo')->satisfy(new MyConstraint(foo: 'bar')); * * * Usage: * * class MyForm extends AttributeForm * { - * #[Satisfy(MyConstraint::class, ['foo' => 'bar'])] - * private IntegerElement $foo; - * - * // or on PHP 8.1 - * #[Satisfy(new MyConstraint(['foo' => 'bar']))] + * #[Satisfy(new MyConstraint(foo: 'bar'))] * private IntegerElement $foo; * } * diff --git a/src/Attribute/Element/Choices.php b/src/Attribute/Element/Choices.php index 21fbee4..f46ac07 100644 --- a/src/Attribute/Element/Choices.php +++ b/src/Attribute/Element/Choices.php @@ -29,6 +29,7 @@ * - a simple array of values (without labels) * - an associative array for provide a label (in key), and inner value (in value) * - a method name for resolving choices in lazy way + * - a choice class name to load from the registry/container * * Note: this attribute is not repeatable * @@ -50,6 +51,9 @@ * #[Choices('loadBazValues', 'Invalid value')] * private StringElement $baz; * + * #[Choices(MyChoices::class)] + * private StringElement $oof; + * * // For dynamic choices, or with complex logic * public function loadBazValues(): array * { @@ -86,7 +90,7 @@ public function __construct( * If the value is an array, the key will be used as label (displayed value), and the value as real value * The label is not required. * - * @var literal-string|array + * @var literal-string|class-string|array * @readonly */ private string|array $choices, @@ -118,7 +122,7 @@ public function applyOnChildBuilder(object|string $context, ChildBuilderInterfac $choices = $this->choices; - if (is_string($choices)) { + if (is_string($choices) && !class_exists($choices)) { $choices = is_object($context) ? new LazyChoice($context->{$this->choices}(...)) : new LazyChoice($context::{$this->choices}(...)) @@ -139,7 +143,7 @@ public function generateCodeForChildBuilder(string $name, AttributesProcessorGen $options['message'] = $this->message; } - if (is_string($this->choices)) { + if (is_string($this->choices) && !class_exists($this->choices)) { $generator->use(LazyChoice::class); if (is_object($context)) { diff --git a/src/Attribute/Element/Transformer.php b/src/Attribute/Element/Transformer.php index 9ecb12b..e4018f5 100644 --- a/src/Attribute/Element/Transformer.php +++ b/src/Attribute/Element/Transformer.php @@ -8,13 +8,19 @@ use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\ChildBuilderAttributeInterface; use Bdf\Form\Attribute\Processor\CodeGenerator\AttributesProcessorGenerator; +use Bdf\Form\Attribute\Processor\CodeGenerator\ObjectInstantiation; use Bdf\Form\Attribute\Processor\GenerateConfiguratorStrategy; use Bdf\Form\Child\ChildBuilderInterface; use Bdf\Form\ElementBuilderInterface; use Bdf\Form\Transformer\TransformerInterface; +use InvalidArgumentException; use Nette\PhpGenerator\Literal; use Override; +use function is_object; +use function is_string; +use function trigger_error; + /** * Add a transformer on the element, using a transformer class * @@ -29,8 +35,12 @@ * * class MyForm extends AttributeForm * { - * #[Transformer(MyTransformer::class, ['foo', 'bar'])] + * #[Transformer(new MyTransformer('foo', 'bar'))] * private IntegerElement $foo; + * + * // Use a transformer loaded from the registry/container + * #[Transformer(TransformerService::class)] + * private IntegerElement $bar; * } * * @@ -45,19 +55,30 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] class Transformer implements ChildBuilderAttributeInterface { + /** + * The transformer class name or instance + * + * @var class-string|TransformerInterface + */ + private readonly string|TransformerInterface $transformer; + + /** + * @param class-string|TransformerInterface|null $transformer + * @param class-string|TransformerInterface|null $transformerClass + */ public function __construct( /** - * The transformer class name + * The transformer class name or instance * - * @var class-string - * @readonly + * @var class-string|TransformerInterface|null */ - private readonly string $transformerClass, + string|TransformerInterface|null $transformer = null, /** - * Arguments to provide on the transformer constructor + * Arguments to provide on the transformer constructor. + * Only used if first parameter is a class name, and the transformer is instantiable * * @var array - * @readonly + * @deprecated Use object parameter instead */ private readonly array $constructorArguments = [], /** @@ -74,12 +95,47 @@ public function __construct( * @see ArrayTransformer Prefer use this attribute for array element, instead of manually set this flag */ private readonly bool $array = false, - ) {} + /** + * @var class-string|TransformerInterface + * @deprecated For compatiblity only. Use first parameter instead. + */ + string|TransformerInterface|null $transformerClass = null, + ) { + $transformer ??= $transformerClass; + + if ($transformerClass !== null) { + @trigger_error('The transformerClass parameter is deprecated since 2.0, use transformer parameter instead', E_USER_DEPRECATED); + } + + if ($transformer === null) { + throw new InvalidArgumentException('The transformer parameter must not be null.'); + } + + $this->transformer = $transformer; + + if ($this->constructorArguments) { + if (!is_string($this->transformer)) { + throw new \InvalidArgumentException('Constructor arguments can be used only with transformer class name'); + } + + @trigger_error('The constructorArguments parameter is deprecated since 2.0, use object parameter instead', E_USER_DEPRECATED); + } + } #[Override] public function applyOnChildBuilder(object|string $context, ChildBuilderInterface $builder): void { - $transformer = new $this->transformerClass(...$this->constructorArguments); + $transformer = $this->transformer; + + if (is_string($transformer)) { + $shouldBeInstantiated = !empty($this->constructorArguments) || self::canBeInstantiatedWithoutParameters($transformer); + + if ($shouldBeInstantiated) { + @trigger_error('Passing a transformer class instead of object for inline instantiation is deprecated since 2.0, will use the registry in 3.0. Use object instead.', E_USER_DEPRECATED); + + $transformer = new $transformer(...$this->constructorArguments); + } + } if ($this->array) { /** @var ChildBuilderInterface $builder */ @@ -92,9 +148,45 @@ public function applyOnChildBuilder(object|string $context, ChildBuilderInterfac #[Override] public function generateCodeForChildBuilder(string $name, AttributesProcessorGenerator $generator, object|string $context): void { - $transformer = $generator->useAndSimplifyType($this->transformerClass); - $code = $this->array ? '$?->arrayTransformer(new ?(...?));' : '$?->transformer(new ?(...?));'; + $transformer = $this->transformer; + + if (is_object($transformer)) { + $transformer = ObjectInstantiation::promotedProperties($transformer)->render($generator); + $code = $this->array ? '$?->arrayTransformer(?);' : '$?->transformer(?);'; + $generator->line($code, [$name, $transformer]); + return; + } + + // Transformer is the class name + $shouldBeInstantiated = !empty($this->constructorArguments) || self::canBeInstantiatedWithoutParameters($transformer); + $transformer = $generator->useAndSimplifyType($transformer); + + if ($shouldBeInstantiated) { + @trigger_error('Passing a transformer class instead of object for inline instantiation is deprecated since 2.0, will use the registry in 3.0. Use object instead.', E_USER_DEPRECATED); + + $code = $this->array ? '$?->arrayTransformer(new ?(...?));' : '$?->transformer(new ?(...?));'; + $generator->line($code, [$name, new Literal($transformer), $this->constructorArguments]); + return; + } + + $code = $this->array ? '$?->arrayTransformer(?::class);' : '$?->transformer(?::class);'; + $generator->line($code, [$name, new Literal($transformer)]); + } + + /** + * @param class-string $class + * @return bool + */ + private static function canBeInstantiatedWithoutParameters(string $class): bool + { + $r = new \ReflectionClass($class); + + if (!$r->isInstantiable()) { + return false; + } + + $constructor = $r->getConstructor(); - $generator->line($code, [$name, new Literal($transformer), $this->constructorArguments]); + return $constructor === null || $constructor->getNumberOfRequiredParameters() === 0; } } diff --git a/src/Child/ChildBuilder.php b/src/Child/ChildBuilder.php index c378109..d462bcb 100644 --- a/src/Child/ChildBuilder.php +++ b/src/Child/ChildBuilder.php @@ -433,13 +433,13 @@ final public function configure(callable $configurator): static /** * Forward call to element builder * - * @param callable|TransformerInterface $transformer + * @param callable|TransformerInterface|class-string $transformer * @param bool $append * @return $this * * @see ElementBuilderInterface::transformer() */ - public function transformer(callable|TransformerInterface $transformer, bool $append = true): static + public function transformer(callable|TransformerInterface|string $transformer, bool $append = true): static { $this->elementBuilder->transformer($transformer, $append); diff --git a/src/Child/ChildBuilderInterface.php b/src/Child/ChildBuilderInterface.php index 746c503..8e1cb94 100644 --- a/src/Child/ChildBuilderInterface.php +++ b/src/Child/ChildBuilderInterface.php @@ -160,14 +160,14 @@ public function depends(string ...$inputNames): static; * }); * * - * @param callable|TransformerInterface $transformer The transformer + * @param callable|TransformerInterface|class-string $transformer The transformer * @param bool $append Append the transformer. Prepend if false * * @return $this * * @see TransformerInterface */ - public function modelTransformer(callable|TransformerInterface $transformer, bool $append = true): static; + public function modelTransformer(callable|TransformerInterface|string $transformer, bool $append = true): static; /** * Creates the child instance diff --git a/src/Choice/ChoiceBuilderTrait.php b/src/Choice/ChoiceBuilderTrait.php index 65ec263..a6df4c6 100644 --- a/src/Choice/ChoiceBuilderTrait.php +++ b/src/Choice/ChoiceBuilderTrait.php @@ -3,7 +3,9 @@ namespace Bdf\Form\Choice; use BackedEnum; +use Bdf\Form\AbstractElementBuilder; use Bdf\Form\ElementBuilderInterface; +use Bdf\Form\Registry\RegistryInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Choice as ChoiceConstraint; @@ -45,11 +47,14 @@ trait ChoiceBuilderTrait * // Using enum * $builder->choices(MyEnum::class); * + * // Using choice from service (e.g. if a container is used, the choice will be loaded from the container) + * $builder->choices(MyChoice::class); + * * $builder->choices(['foo', 'bar'], 'my error'); // With message * $builder->choices(['foo', 'bar'], min: 2, max: 6); // With custom options * * - * @param ChoiceInterface|array|class-string|callable $choices The allowed values in PHP form. + * @param ChoiceInterface|array|class-string|callable $choices The allowed values in PHP form. * @param string|null $message The error message. * @param non-negative-int|null $min * @param positive-int|null $max @@ -63,6 +68,7 @@ final public function choices(ChoiceInterface|array|string|callable $choices, ?s $choices = match (true) { is_array($choices) => new ArrayChoice($choices), is_string($choices) && is_subclass_of($choices, BackedEnum::class) => new EnumChoice($choices), + is_string($choices) && is_subclass_of($choices, ChoiceInterface::class) => new LazyChoice(fn () => $this->registry()->service($choices)), is_callable($choices) => new LazyChoice($choices), }; } @@ -102,4 +108,11 @@ final protected function getChoices(): ?ChoiceInterface * @see ElementBuilderInterface::satisfy() */ abstract public function satisfy(Constraint|callable $constraint, ?string $message = null, bool $append = true): static; + + /** + * {@inheritdoc} + * + * @see AbstractElementBuilder::registry() + */ + abstract protected function registry(): RegistryInterface; } diff --git a/src/Csrf/CsrfElementBuilder.php b/src/Csrf/CsrfElementBuilder.php index 1285127..d5c00d2 100644 --- a/src/Csrf/CsrfElementBuilder.php +++ b/src/Csrf/CsrfElementBuilder.php @@ -136,7 +136,7 @@ public function satisfy(Constraint|callable $constraint, ?string $message = null } #[Override] - public function transformer(callable|TransformerInterface $transformer, bool $append = true): static + public function transformer(callable|TransformerInterface|string $transformer, bool $append = true): static { throw new BadMethodCallException(); } diff --git a/src/ElementBuilderInterface.php b/src/ElementBuilderInterface.php index de3cd55..f425ab6 100644 --- a/src/ElementBuilderInterface.php +++ b/src/ElementBuilderInterface.php @@ -88,16 +88,18 @@ public function satisfy(Constraint|callable $constraint, ?string $message = null * return $value->export(); * } * }); + * + * $builder->transformer(TransformerService::class); // Add a transformer, loaded from the registry/container * * - * @param callable|TransformerInterface $transformer The transformer. + * @param callable|TransformerInterface|class-string $transformer The transformer. * @param bool $append Append the transformer. Prepend if false * * @return $this * * @see TransformerInterface */ - public function transformer(callable|TransformerInterface $transformer, bool $append = true): static; + public function transformer(callable|TransformerInterface|string $transformer, bool $append = true): static; /** * Define the initial value of the element diff --git a/src/Filter/ClosureFilter.php b/src/Filter/ClosureFilter.php index 362263c..3a17b9b 100755 --- a/src/Filter/ClosureFilter.php +++ b/src/Filter/ClosureFilter.php @@ -2,6 +2,7 @@ namespace Bdf\Form\Filter; +use Attribute; use Bdf\Form\Child\ChildBuilderInterface; use Bdf\Form\Child\ChildInterface; use Override; @@ -18,6 +19,7 @@ * * @see ChildBuilderInterface::filter() */ +#[Attribute(Attribute::TARGET_PROPERTY)] final readonly class ClosureFilter implements FilterInterface { /** diff --git a/src/Registry/AbstractRegistry.php b/src/Registry/AbstractRegistry.php new file mode 100755 index 0000000..94c01fc --- /dev/null +++ b/src/Registry/AbstractRegistry.php @@ -0,0 +1,172 @@ +[]|callable[] + */ + private array $elementBuilderFactories = [ + StringElement::class => StringElementBuilder::class, + IntegerElement::class => IntegerElementBuilder::class, + FloatElement::class => FloatElementBuilder::class, + BooleanElement::class => BooleanElementBuilder::class, + AnyElement::class => AnyElementBuilder::class, + BackedEnumElement::class => EnumElementBuilder::class, + UnitEnumElement::class => EnumElementBuilder::class, + + EmailElement::class => EmailElementBuilder::class, + UrlElement::class => UrlElementBuilder::class, + + CsrfElement::class => CsrfElementBuilder::class, + PhoneElement::class => PhoneElementBuilder::class, + + DateTimeElement::class => DateTimeElementBuilder::class, + + ArrayElement::class => ArrayElementBuilder::class, + Form::class => FormBuilder::class, + ]; + + /** + * @var class-string[]|callable[] + */ + private array $childBuilderFactories = [ + DateTimeElement::class => DateTimeChildBuilder::class, + PhoneElement::class => PhoneChildBuilder::class, + ArrayElement::class => ArrayChildBuilder::class, + ]; + + #[Override] + public function childBuilder(string $element, string $name): ChildBuilderInterface + { + $elementBuilder = $this->elementBuilder($element); + + $builderFactory = $this->childBuilderFactories[$element] ?? ChildBuilder::class; + + if (is_string($builderFactory)) { + /** @var class-string $builderFactory */ + return new $builderFactory($name, $elementBuilder, $this); + } + + return $builderFactory($name, $elementBuilder, $this); + } + + /** + * {@inheritdoc} + * + * @psalm-param class-string $element + * @psalm-template E as \Bdf\Form\ElementInterface + * @psalm-return ElementBuilderInterface + */ + #[Override] + public function elementBuilder(string $element): ElementBuilderInterface + { + $builderFactory = null; + + if (isset($this->elementBuilderFactories[$element])) { + $builderFactory = $this->elementBuilderFactories[$element]; + } else { + foreach ($this->elementBuilderFactories as $builderElement => $factory) { + if (is_subclass_of($element, $builderElement, true)) { + $builderFactory = $factory; + break; + } + } + } + + if ($builderFactory === null) { + throw new InvalidArgumentException('The element '.$element.' is not registered'); + } + + if (is_string($builderFactory)) { + /** @var class-string> $builderFactory */ + return new $builderFactory($this, $element); + } + + return ($builderFactory)($this, $element); + } + + #[Override] + public function buttonBuilder(string $name): ButtonBuilderInterface + { + return new SubmitButtonBuilder($name); + } + + /** + * Register a new element builder + * + * + * // Register MyCustomBuilder as builder for MyCustomElement + * $registry->register(MyCustomElement::class, MyCustomBuilder::class); + * + * // Register a factory builder. The factory takes as parameters the registry, and the element class name + * $registry->register(MyCustomElement::class, function (Registry $registry, string $element) { + * return new MyCustomBuilder($registry); + * }); + * + * // Register with a custom child builder + * $registry->register(MyCustomElement::class, MyCustomBuilder::class, function (string $name, ElementBuilderInterface $builder, Registry $registry) { + * return new MyCustomChildBuilder($registry, new ChildBuilder($name, $builder, $registry)); + * }); + * + * + * @param string $elementType The element class name + * @param class-string|callable $builderFactory The builder factory, or builder class name + * @param class-string|callable|null $childBuilderFactory The builder factory for child, or builder class name. If null, use default child builder + * + * @see Registry::elementBuilder() + */ + public function register(string $elementType, string|callable $builderFactory, string|callable|null $childBuilderFactory = null): void + { + $this->elementBuilderFactories[$elementType] = $builderFactory; + + if ($childBuilderFactory !== null) { + $this->childBuilderFactories[$elementType] = $childBuilderFactory; + } + } +} diff --git a/src/Registry/Registry.php b/src/Registry/Registry.php index cacef9c..73d737b 100755 --- a/src/Registry/Registry.php +++ b/src/Registry/Registry.php @@ -2,88 +2,26 @@ namespace Bdf\Form\Registry; -use Bdf\Form\Aggregate\ArrayChildBuilder; -use Bdf\Form\Aggregate\ArrayElement; -use Bdf\Form\Aggregate\ArrayElementBuilder; use Bdf\Form\Aggregate\Form; -use Bdf\Form\Aggregate\FormBuilder; -use Bdf\Form\Button\ButtonBuilderInterface; -use Bdf\Form\Button\SubmitButtonBuilder; -use Bdf\Form\Child\ChildBuilder; -use Bdf\Form\Child\ChildBuilderInterface; -use Bdf\Form\Csrf\CsrfElement; -use Bdf\Form\Csrf\CsrfElementBuilder; use Bdf\Form\Custom\CustomForm; use Bdf\Form\Custom\CustomFormBuilder; -use Bdf\Form\ElementBuilderInterface; -use Bdf\Form\Leaf\AnyElement; -use Bdf\Form\Leaf\AnyElementBuilder; -use Bdf\Form\Leaf\BackedEnumElement; -use Bdf\Form\Leaf\BooleanElement; -use Bdf\Form\Leaf\BooleanElementBuilder; -use Bdf\Form\Leaf\Date\DateTimeChildBuilder; -use Bdf\Form\Leaf\Date\DateTimeElement; -use Bdf\Form\Leaf\Date\DateTimeElementBuilder; -use Bdf\Form\Leaf\EnumElementBuilder; -use Bdf\Form\Leaf\FloatElement; -use Bdf\Form\Leaf\FloatElementBuilder; -use Bdf\Form\Leaf\Helper\EmailElement; -use Bdf\Form\Leaf\Helper\EmailElementBuilder; -use Bdf\Form\Leaf\Helper\UrlElement; -use Bdf\Form\Leaf\Helper\UrlElementBuilder; -use Bdf\Form\Leaf\IntegerElement; -use Bdf\Form\Leaf\IntegerElementBuilder; -use Bdf\Form\Leaf\StringElement; -use Bdf\Form\Leaf\StringElementBuilder; -use Bdf\Form\Leaf\UnitEnumElement; -use Bdf\Form\Phone\PhoneChildBuilder; -use Bdf\Form\Phone\PhoneElement; -use Bdf\Form\Phone\PhoneElementBuilder; use Bdf\Form\Struct\StructForm; use Bdf\Form\Struct\StructFormBuilder; use InvalidArgumentException; use Override; -use function is_string; -use function is_subclass_of; +use function sprintf; /** * Base registry interface */ -final class Registry implements RegistryInterface +final class Registry extends AbstractRegistry { /** - * @var class-string[]|callable[] + * @var array + * @psalm-var class-string-map */ - private array $elementBuilderFactories = [ - StringElement::class => StringElementBuilder::class, - IntegerElement::class => IntegerElementBuilder::class, - FloatElement::class => FloatElementBuilder::class, - BooleanElement::class => BooleanElementBuilder::class, - AnyElement::class => AnyElementBuilder::class, - BackedEnumElement::class => EnumElementBuilder::class, - UnitEnumElement::class => EnumElementBuilder::class, - - EmailElement::class => EmailElementBuilder::class, - UrlElement::class => UrlElementBuilder::class, - - CsrfElement::class => CsrfElementBuilder::class, - PhoneElement::class => PhoneElementBuilder::class, - - DateTimeElement::class => DateTimeElementBuilder::class, - - ArrayElement::class => ArrayElementBuilder::class, - Form::class => FormBuilder::class, - ]; - - /** - * @var class-string[]|callable[] - */ - private array $childBuilderFactories = [ - DateTimeElement::class => DateTimeChildBuilder::class, - PhoneElement::class => PhoneChildBuilder::class, - ArrayElement::class => ArrayChildBuilder::class, - ]; + private array $services = []; /** * Registry constructor. @@ -100,91 +38,28 @@ public function __construct() } #[Override] - public function childBuilder(string $element, string $name): ChildBuilderInterface + public function service(string $class): object { - $elementBuilder = $this->elementBuilder($element); - - $builderFactory = $this->childBuilderFactories[$element] ?? ChildBuilder::class; - - if (is_string($builderFactory)) { - /** @var class-string $builderFactory */ - return new $builderFactory($name, $elementBuilder, $this); + if (!isset($this->services[$class])) { + throw new InvalidArgumentException(sprintf('Service "%s" is not registered.', $class)); } - return $builderFactory($name, $elementBuilder, $this); + return $this->services[$class]; } /** - * {@inheritdoc} + * Register a new service, associated to the given type * - * @psalm-param class-string $element - * @psalm-template E as \Bdf\Form\ElementInterface - * @psalm-return ElementBuilderInterface - */ - #[Override] - public function elementBuilder(string $element): ElementBuilderInterface - { - $builderFactory = null; - - if (isset($this->elementBuilderFactories[$element])) { - $builderFactory = $this->elementBuilderFactories[$element]; - } else { - foreach ($this->elementBuilderFactories as $builderElement => $factory) { - if (is_subclass_of($element, $builderElement, true)) { - $builderFactory = $factory; - break; - } - } - } - - if ($builderFactory === null) { - throw new InvalidArgumentException('The element '.$element.' is not registered'); - } - - if (is_string($builderFactory)) { - /** @var class-string> $builderFactory */ - return new $builderFactory($this, $element); - } - - return ($builderFactory)($this, $element); - } - - #[Override] - public function buttonBuilder(string $name): ButtonBuilderInterface - { - return new SubmitButtonBuilder($name); - } - - /** - * Register a new element builder - * - * - * // Register MyCustomBuilder as builder for MyCustomElement - * $registry->register(MyCustomElement::class, MyCustomBuilder::class); - * - * // Register a factory builder. The factory takes as parameters the registry, and the element class name - * $registry->register(MyCustomElement::class, function (Registry $registry, string $element) { - * return new MyCustomBuilder($registry); - * }); + * @param T $service + * @param class-string|null $class The registered class name. If not set, the serice class name will be used * - * // Register with a custom child builder - * $registry->register(MyCustomElement::class, MyCustomBuilder::class, function (string $name, ElementBuilderInterface $builder, Registry $registry) { - * return new MyCustomChildBuilder($registry, new ChildBuilder($name, $builder, $registry)); - * }); - * - * - * @param string $elementType The element class name - * @param class-string|callable $builderFactory The builder factory, or builder class name - * @param class-string|callable|null $childBuilderFactory The builder factory for child, or builder class name. If null, use default child builder - * - * @see Registry::elementBuilder() + * @return void + * @template T as object */ - public function register(string $elementType, string|callable $builderFactory, string|callable|null $childBuilderFactory = null): void + public function registerService(object $service, ?string $class = null): void { - $this->elementBuilderFactories[$elementType] = $builderFactory; + $class ??= $service::class; - if ($childBuilderFactory !== null) { - $this->childBuilderFactories[$elementType] = $childBuilderFactory; - } + $this->services[$class] = $service; } } diff --git a/src/Registry/RegistryInterface.php b/src/Registry/RegistryInterface.php index ab46583..1af2f50 100644 --- a/src/Registry/RegistryInterface.php +++ b/src/Registry/RegistryInterface.php @@ -60,4 +60,14 @@ public function elementBuilder(string $element): ElementBuilderInterface; * @return ButtonBuilderInterface */ public function buttonBuilder(string $name): ButtonBuilderInterface; + + /** + * Get a service instance + * + * @param class-string $class + * + * @return T + * @template T as object + */ + public function service(string $class): object; } diff --git a/src/Util/DelegateElementBuilderTrait.php b/src/Util/DelegateElementBuilderTrait.php index d05b0d3..31f233c 100644 --- a/src/Util/DelegateElementBuilderTrait.php +++ b/src/Util/DelegateElementBuilderTrait.php @@ -27,8 +27,10 @@ final public function satisfy(Constraint|callable $constraint, ?string $message /** * {@inheritdoc} + * + * @param callable|TransformerInterface|class-string $transformer */ - final public function transformer(callable|TransformerInterface $transformer, bool $append = true): static + final public function transformer(callable|TransformerInterface|string $transformer, bool $append = true): static { $this->getElementBuilder()->transformer($transformer, $append); diff --git a/src/Util/TransformerBuilderTrait.php b/src/Util/TransformerBuilderTrait.php index 8e3f9a1..362c3a4 100644 --- a/src/Util/TransformerBuilderTrait.php +++ b/src/Util/TransformerBuilderTrait.php @@ -12,6 +12,7 @@ use function array_unshift; use function count; use function is_callable; +use function is_string; /** * Trait for implements builder of transformer @@ -31,12 +32,15 @@ trait TransformerBuilderTrait /** * {@inheritdoc} * + * @param callable|TransformerInterface|class-string $transformer * @see ElementBuilderInterface::transformer() */ - final public function transformer(callable|TransformerInterface $transformer, bool $append = true): static + final public function transformer(callable|TransformerInterface|string $transformer, bool $append = true): static { if (is_callable($transformer)) { $transformer = new ClosureTransformer($transformer); + } elseif (is_string($transformer)) { + $transformer = $this->registry()->service($transformer); } if ($append === true) { diff --git a/tests/Aggregate/ArrayElementBuilderTest.php b/tests/Aggregate/ArrayElementBuilderTest.php index 2a1bdfa..c1a71ba 100644 --- a/tests/Aggregate/ArrayElementBuilderTest.php +++ b/tests/Aggregate/ArrayElementBuilderTest.php @@ -3,6 +3,8 @@ namespace Bdf\Form\Aggregate; use Bdf\Form\Choice\EnumChoice; +use Bdf\Form\ElementInterface; +use Bdf\Form\Transformer\TransformerInterface; use Bdf\Form\Choice\LazyChoice; use Bdf\Form\Leaf\AnyElementBuilder; use Bdf\Form\Leaf\BooleanElementBuilder; @@ -10,12 +12,16 @@ use Bdf\Form\Leaf\EnumElementBuilder; use Bdf\Form\Leaf\FloatElementBuilder; use Bdf\Form\Leaf\IntegerElementBuilder; +use Bdf\Form\Leaf\MyServiceChoice; use Bdf\Form\Leaf\MyStringEnum; +use Bdf\Form\Leaf\MyTransformerService; use Bdf\Form\Leaf\StringElement; use Bdf\Form\Leaf\StringElementBuilder; use Bdf\Form\Phone\PhoneElementBuilder; +use Bdf\Form\Registry\Registry; use Bdf\Form\Struct\Fixtures\SimpleDto; use Bdf\Form\Struct\StructFormBuilder; +use InvalidArgumentException; use libphonenumber\PhoneNumber; use libphonenumber\PhoneNumberFormat; use libphonenumber\PhoneNumberUtil; @@ -260,6 +266,31 @@ public function test_arrayTransformer() $this->assertSame(['bar' => 'foo'], $element->submit(['foo' => 'bar'])->value()); } + /** + * + */ + public function test_arrayTransformer_from_service() + { + $registry = new Registry(); + $registry->registerService(new MyArrayTransformerService()); + + $builder = new ArrayElementBuilder($registry); + $element = $builder->arrayTransformer(MyArrayTransformerService::class)->buildElement(); + + $this->assertSame(['bar' => 'foo'], $element->submit(['foo' => 'bar'])->value()); + } + + /** + * + */ + public function test_arrayTransformer_from_service_not_registered() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.MyArrayTransformerService::class.'" is not registered.'); + + $this->builder->arrayTransformer(MyArrayTransformerService::class); + } + /** * */ @@ -324,6 +355,31 @@ public function test_transformer_order() $this->assertSame(['BAC'], $element->submit([''])->value()); } + /** + * + */ + public function test_transformer_from_service() + { + $registry = new Registry(); + $registry->registerService(new MyTransformerService()); + + $builder = new ArrayElementBuilder($registry); + $element = $builder->transformer(MyTransformerService::class)->buildElement(); + + $this->assertSame(['foo>', 'bar>'], $element->submit(['foo', 'bar'])->value()); + } + + /** + * + */ + public function test_transformer_from_service_not_registered() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.MyTransformerService::class.'" is not registered.'); + + $this->builder->transformer(MyTransformerService::class); + } + /** * */ @@ -393,4 +449,51 @@ public function test_choices() $this->assertTrue($element->submit(['foo', 'bar'])->valid()); } + + /** + * + */ + public function test_choices_from_service() + { + $choice = new MyServiceChoice(); + + $registry = new Registry(); + $registry->registerService($choice); + + $builder = new ArrayElementBuilder($registry); + $element = $builder->choices(MyServiceChoice::class)->buildElement(); + + $this->assertInstanceOf(LazyChoice::class, $element->choices()); + $this->assertSame(['foo', 'bar'], $element->choices()->values()); + + $this->assertFalse($element->submit(['foo', 'aaa'])->valid()); + $this->assertEquals('One or more of the given values is invalid.', $element->error()->global()); + + $this->assertTrue($element->submit(['foo', 'bar'])->valid()); + } + + /** + * + */ + public function test_choices_from_service_not_registered() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.MyServiceChoice::class.'" is not registered.'); + + $element = $this->builder->choices(MyServiceChoice::class)->buildElement(); + $element->submit(['foo', 'bar']); + } +} + +class MyArrayTransformerService implements TransformerInterface +{ + public function transformToHttp(mixed $value, ElementInterface $input): mixed + { + return array_flip($value); + } + + public function transformFromHttp(mixed $value, ElementInterface $input): mixed + { + return array_flip($value); + } } diff --git a/tests/Attribute/Aggregate/ArrayTransformerTest.php b/tests/Attribute/Aggregate/ArrayTransformerTest.php index a5d8f15..0c3da0c 100644 --- a/tests/Attribute/Aggregate/ArrayTransformerTest.php +++ b/tests/Attribute/Aggregate/ArrayTransformerTest.php @@ -3,10 +3,12 @@ namespace Tests\Form\Attribute\Aggregate; use Bdf\Form\Aggregate\ArrayElement; +use Bdf\Form\Aggregate\FormBuilder; use Bdf\Form\Attribute\Aggregate\ArrayTransformer; use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Processor\AttributesProcessorInterface; use Bdf\Form\ElementInterface; +use Bdf\Form\Registry\Registry; use Bdf\Form\Struct\StructForm; use Bdf\Form\Transformer\TransformerInterface; use PHPUnit\Framework\Attributes\DataProvider; @@ -42,6 +44,178 @@ public function struct(AttributesProcessorInterface $processor) $this->assertEquals(['A_A', 'A-A'], $view['foo']->value()); } + #[DataProvider('provideAttributesProcessor')] + public function test_object(AttributesProcessorInterface $processor) + { + $form = new class(null, $processor) extends AttributeForm { + #[ArrayTransformer(new AArrayTransformer('A'))] + 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()); + } + + #[Test, DataProvider('provideStructAttributesProcessor')] + public function struct_object(AttributesProcessorInterface $processor) + { + $form = new StructForm(TestArrayTransformerObjectStruct::class, processor: $processor); + + $form->submit(['foo' => ['_', '-']]); + $this->assertEquals(['A_', 'A-'], $form['foo']->element()->value()); + + $view = $form->view(); + $this->assertEquals(['A_A', 'A-A'], $view['foo']->value()); + } + + #[DataProvider('provideAttributesProcessor')] + public function test_from_service(AttributesProcessorInterface $processor) + { + $registry = new Registry(); + $registry->registerService(new ServiceArrayTransformer('S')); + + $form = new class(new FormBuilder($registry), $processor) extends AttributeForm { + #[ArrayTransformer(ServiceArrayTransformer::class)] + public ArrayElement $foo; + }; + + $form->submit(['foo' => ['_', '-']]); + $this->assertEquals(['S_', 'S-'], $form->foo->value()); + + $view = $form->view(); + $this->assertEquals(['S_S', 'S-S'], $view['foo']->value()); + } + + #[Test, DataProvider('provideStructAttributesProcessor')] + public function struct_from_service(AttributesProcessorInterface $processor) + { + $registry = new Registry(); + $registry->registerService(new ServiceArrayTransformer('S')); + + $form = new StructForm(TestArrayTransformerServiceStruct::class, new FormBuilder($registry), processor: $processor); + + $form->submit(['foo' => ['_', '-']]); + $this->assertEquals(['S_', 'S-'], $form['foo']->element()->value()); + + $view = $form->view(); + $this->assertEquals(['S_S', 'S-S'], $view['foo']->value()); + } + + #[DataProvider('provideAttributesProcessor')] + public function test_from_service_not_registered(AttributesProcessorInterface $processor) + { + $form = new class(new FormBuilder(new Registry()), $processor) extends AttributeForm { + #[ArrayTransformer(ServiceArrayTransformer::class)] + public ArrayElement $foo; + }; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.ServiceArrayTransformer::class.'" is not registered.'); + + $form->submit(['foo' => ['_', '-']]); + } + + public function test_object_with_constructor_arguments_throws() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Constructor arguments can be used only with transformer class name'); + + new ArrayTransformer(new AArrayTransformer('A'), ['B']); + } + + public function test_code_generator_object() + { + $form = new class extends AttributeForm { + #[ArrayTransformer(new AArrayTransformer('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(object|string $context, FormBuilderInterface $builder): ?PostConfigureInterface + { + $foo = $builder->add('foo', ArrayElement::class); + $foo->arrayTransformer(new AArrayTransformer(c: 'A')); + + return $this; + } + + /** + * {@inheritdoc} + */ + function postConfigure(AttributeForm $form, FormInterface $inner): void + { + $form->foo = $inner['foo']->element(); + } +} + +PHP + , $form +); + } + + public function test_code_generator_from_service() + { + $form = new class extends AttributeForm { + #[ArrayTransformer(ServiceArrayTransformer::class)] + 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\ServiceArrayTransformer; + +class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface +{ + /** + * {@inheritdoc} + */ + function configureBuilder(object|string $context, FormBuilderInterface $builder): ?PostConfigureInterface + { + $foo = $builder->add('foo', ArrayElement::class); + $foo->arrayTransformer(ServiceArrayTransformer::class); + + return $this; + } + + /** + * {@inheritdoc} + */ + function postConfigure(AttributeForm $form, FormInterface $inner): void + { + $form->foo = $inner['foo']->element(); + } +} + +PHP + , $form +); + } + public function test_code_generator() { $form = new class extends AttributeForm { @@ -147,3 +321,37 @@ class TestArrayTransformerStruct #[ArrayTransformer(AArrayTransformer::class, ['A'])] public array $foo; } + +class TestArrayTransformerObjectStruct +{ + #[ArrayTransformer(new AArrayTransformer('A'))] + public array $foo; +} + +class TestArrayTransformerServiceStruct +{ + #[ArrayTransformer(ServiceArrayTransformer::class)] + public array $foo; +} + +/** + * Transformer with a required constructor dependency: not instantiable without parameters, + * so it must be resolved from the registry/container. + */ +class ServiceArrayTransformer implements TransformerInterface +{ + public function __construct( + public string $prefix + ) { + } + + public function transformToHttp($value, ElementInterface $input) + { + return array_map(fn($v) => $v . $this->prefix, $value); + } + + public function transformFromHttp($value, ElementInterface $input) + { + return array_map(fn($v) => $this->prefix . $v, $value); + } +} diff --git a/tests/Attribute/Element/ChoicesTest.php b/tests/Attribute/Element/ChoicesTest.php index 5861e97..6877d30 100644 --- a/tests/Attribute/Element/ChoicesTest.php +++ b/tests/Attribute/Element/ChoicesTest.php @@ -3,11 +3,16 @@ namespace Tests\Form\Attribute\Element; use Bdf\Form\Aggregate\ArrayElement; +use Bdf\Form\Aggregate\FormBuilder; use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Element\Choices; use Bdf\Form\Attribute\Processor\AttributesProcessorInterface; use Bdf\Form\Choice\ArrayChoice; +use Bdf\Form\Choice\ChoiceInterface; +use Bdf\Form\Choice\ChoiceView; +use Bdf\Form\Choice\LazyChoice; use Bdf\Form\Leaf\StringElement; +use Bdf\Form\Registry\Registry; use Bdf\Form\Struct\StructForm; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -67,6 +72,110 @@ public function struct(AttributesProcessorInterface $processor) $this->assertEquals(['bar' => 'You must select at least 2 choices.'], $form->error()->toArray()); } + #[Test, DataProvider('provideAttributesProcessor')] + public function from_service(AttributesProcessorInterface $processor) + { + $registry = new Registry(); + $registry->registerService(new TestChoicesService()); + + $form = new class(new FormBuilder($registry), $processor) extends AttributeForm { + #[Choices(TestChoicesService::class, message: 'my error')] + public StringElement $foo; + + #[Choices(TestChoicesService::class, message: 'my error', options: ['min' => 2])] + public ArrayElement $bar; + }; + + $form->submit(['foo' => 'a', 'bar' => ['b']]); + + $this->assertInstanceOf(LazyChoice::class, $form->foo->choices()); + $this->assertSame(['aaa', 'bbb', 'ccc'], $form->foo->choices()->values()); + $this->assertInstanceOf(LazyChoice::class, $form->bar->choices()); + $this->assertSame(['aaa', 'bbb', 'ccc'], $form->bar->choices()->values()); + $this->assertEquals(['foo' => 'my error', 'bar' => 'my error'], $form->error()->toArray()); + + $form->submit(['foo' => 'aaa', 'bar' => ['aaa', 'bbb']]); + $this->assertTrue($form->valid()); + + $form->submit(['foo' => 'aaa', 'bar' => ['aaa']]); + $this->assertEquals(['bar' => 'You must select at least 2 choices.'], $form->error()->toArray()); + } + + #[Test, DataProvider('provideStructAttributesProcessor')] + public function struct_from_service(AttributesProcessorInterface $processor) + { + $registry = new Registry(); + $registry->registerService(new TestChoicesService()); + + $form = new StructForm(TestChoicesServiceStruct::class, new FormBuilder($registry), processor: $processor); + + $form->submit(['foo' => 'a', 'bar' => ['b']]); + + $this->assertInstanceOf(LazyChoice::class, $form['foo']->element()->choices()); + $this->assertSame(['aaa', 'bbb', 'ccc'], $form['foo']->element()->choices()->values()); + $this->assertInstanceOf(LazyChoice::class, $form['bar']->element()->choices()); + $this->assertSame(['aaa', 'bbb', 'ccc'], $form['bar']->element()->choices()->values()); + $this->assertEquals(['foo' => 'my error', 'bar' => 'my error'], $form->error()->toArray()); + + $form->submit(['foo' => 'aaa', 'bar' => ['aaa', 'bbb']]); + $this->assertTrue($form->valid()); + + $form->submit(['foo' => 'aaa', 'bar' => ['aaa']]); + $this->assertEquals(['bar' => 'You must select at least 2 choices.'], $form->error()->toArray()); + } + + public function test_code_generator_from_service() + { + $form = new class extends AttributeForm { + #[Choices(TestChoicesService::class, message: 'my error')] + public StringElement $foo; + + #[Choices(TestChoicesService::class, message: 'my error', options: ['min' => 2])] + public ArrayElement $bar; + }; + + $this->assertGenerated(<<<'PHP' +namespace Generated; + +use Bdf\Form\Aggregate\ArrayElement; +use Bdf\Form\Aggregate\FormBuilderInterface; +use Bdf\Form\Aggregate\FormInterface; +use Bdf\Form\Attribute\AttributeForm; +use Bdf\Form\Attribute\Processor\AttributesProcessorInterface; +use Bdf\Form\Attribute\Processor\PostConfigureInterface; +use Bdf\Form\Leaf\StringElement; + +class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface +{ + /** + * {@inheritdoc} + */ + function configureBuilder(object|string $context, FormBuilderInterface $builder): ?PostConfigureInterface + { + $foo = $builder->add('foo', StringElement::class); + $foo->choices('Tests\Form\Attribute\Element\TestChoicesService', message: 'my error'); + + $bar = $builder->add('bar', ArrayElement::class); + $bar->choices('Tests\Form\Attribute\Element\TestChoicesService', min: 2, message: 'my error'); + + return $this; + } + + /** + * {@inheritdoc} + */ + function postConfigure(AttributeForm $form, FormInterface $inner): void + { + $form->foo = $inner['foo']->element(); + $form->bar = $inner['bar']->element(); + } +} + +PHP + , $form +); + } + public function test_code_generator() { $form = new class extends AttributeForm { @@ -194,3 +303,28 @@ public static function generateChoices() return ['aaa', 'bbb', 'ccc']; } } + +class TestChoicesServiceStruct +{ + public function __construct( + #[Choices(TestChoicesService::class, message: 'my error')] + public ?string $foo, + #[Choices(TestChoicesService::class, message: 'my error', options: ['min' => 2])] + public array $bar, + ) {} +} + +class TestChoicesService implements ChoiceInterface +{ + #[\Override] + public function values(): array + { + return ['aaa', 'bbb', 'ccc']; + } + + #[\Override] + public function view(?callable $configuration = null): array + { + return new ArrayChoice($this->view())->view($configuration); + } +} diff --git a/tests/Attribute/Element/TransformerTest.php b/tests/Attribute/Element/TransformerTest.php index 82beb76..20b7c5c 100644 --- a/tests/Attribute/Element/TransformerTest.php +++ b/tests/Attribute/Element/TransformerTest.php @@ -3,11 +3,13 @@ namespace Tests\Form\Attribute\Element; use Bdf\Form\Aggregate\ArrayElement; +use Bdf\Form\Aggregate\FormBuilder; use Bdf\Form\Attribute\AttributeForm; use Bdf\Form\Attribute\Element\Transformer; use Bdf\Form\Attribute\Processor\AttributesProcessorInterface; use Bdf\Form\ElementInterface; use Bdf\Form\Leaf\StringElement; +use Bdf\Form\Registry\Registry; use Bdf\Form\Struct\StructForm; use Bdf\Form\Transformer\TransformerInterface; use PHPUnit\Framework\Attributes\DataProvider; @@ -70,6 +72,309 @@ public function testWithArrayStruct(AttributesProcessorInterface $processor) $this->assertEquals(['A_A', 'A-A'], $view['foo']->value()); } + #[DataProvider('provideAttributesProcessor')] + public function test_object(AttributesProcessorInterface $processor) + { + $form = new class(null, $processor) extends AttributeForm { + #[Transformer(new ATransformer('A'))] + public StringElement $foo; + }; + + $form->submit(['foo' => '_']); + $this->assertEquals('A_', $form->foo->value()); + + $view = $form->view(); + $this->assertEquals('A_A', $view['foo']->value()); + } + + #[Test, DataProvider('provideStructAttributesProcessor')] + public function struct_object(AttributesProcessorInterface $processor) + { + $form = new StructForm(TestTransformerObjectStruct::class, processor: $processor); + + $form->submit(['foo' => '_']); + $this->assertEquals('A_', $form->value()->foo); + + $view = $form->view(); + $this->assertEquals('A_A', $view['foo']->value()); + } + + #[DataProvider('provideAttributesProcessor')] + public function test_object_array(AttributesProcessorInterface $processor) + { + $form = new class(null, $processor) extends AttributeForm { + #[Transformer(new AArrayTransformer('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()); + } + + #[DataProvider('provideAttributesProcessor')] + public function test_from_service(AttributesProcessorInterface $processor) + { + $registry = new Registry(); + $registry->registerService(new ServiceTransformer('S')); + + $form = new class(new FormBuilder($registry), $processor) extends AttributeForm { + #[Transformer(ServiceTransformer::class)] + public StringElement $foo; + }; + + $form->submit(['foo' => '_']); + $this->assertEquals('S_', $form->foo->value()); + + $view = $form->view(); + $this->assertEquals('S_S', $view['foo']->value()); + } + + #[Test, DataProvider('provideStructAttributesProcessor')] + public function struct_from_service(AttributesProcessorInterface $processor) + { + $registry = new Registry(); + $registry->registerService(new ServiceTransformer('S')); + + $form = new StructForm(TestTransformerServiceStruct::class, new FormBuilder($registry), processor: $processor); + + $form->submit(['foo' => '_']); + $this->assertEquals('S_', $form->value()->foo); + + $view = $form->view(); + $this->assertEquals('S_S', $view['foo']->value()); + } + + #[DataProvider('provideAttributesProcessor')] + public function test_from_service_array(AttributesProcessorInterface $processor) + { + $registry = new Registry(); + $registry->registerService(new ServiceArrayTransformer('S')); + + $form = new class(new FormBuilder($registry), $processor) extends AttributeForm { + #[Transformer(ServiceArrayTransformer::class, array: true)] + public ArrayElement $foo; + }; + + $form->submit(['foo' => ['_', '-']]); + $this->assertEquals(['S_', 'S-'], $form->foo->value()); + + $view = $form->view(); + $this->assertEquals(['S_S', 'S-S'], $view['foo']->value()); + } + + #[DataProvider('provideAttributesProcessor')] + public function test_from_service_not_registered(AttributesProcessorInterface $processor) + { + $form = new class(new FormBuilder(new Registry()), $processor) extends AttributeForm { + #[Transformer(ServiceTransformer::class)] + public StringElement $foo; + }; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.ServiceTransformer::class.'" is not registered.'); + + $form->submit(['foo' => '_']); + } + + public function test_object_with_constructor_arguments_throws() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Constructor arguments can be used only with transformer class name'); + + new Transformer(new ATransformer('A'), ['B']); + } + + public function test_null_transformer_throws() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The transformer parameter must not be null.'); + + new Transformer(); + } + + public function test_code_generator_object() + { + $form = new class extends AttributeForm { + #[Transformer(new ATransformer('A'))] + public StringElement $foo; + }; + + $this->assertGenerated(<<<'PHP' +namespace Generated; + +use Bdf\Form\Aggregate\FormBuilderInterface; +use Bdf\Form\Aggregate\FormInterface; +use Bdf\Form\Attribute\AttributeForm; +use Bdf\Form\Attribute\Processor\AttributesProcessorInterface; +use Bdf\Form\Attribute\Processor\PostConfigureInterface; +use Bdf\Form\Leaf\StringElement; +use Tests\Form\Attribute\Element\ATransformer; + +class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface +{ + /** + * {@inheritdoc} + */ + function configureBuilder(object|string $context, FormBuilderInterface $builder): ?PostConfigureInterface + { + $foo = $builder->add('foo', StringElement::class); + $foo->transformer(new ATransformer(c: 'A')); + + return $this; + } + + /** + * {@inheritdoc} + */ + function postConfigure(AttributeForm $form, FormInterface $inner): void + { + $form->foo = $inner['foo']->element(); + } +} + +PHP + , $form +); + } + + public function test_code_generator_object_array() + { + $form = new class extends AttributeForm { + #[Transformer(new AArrayTransformer('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(object|string $context, FormBuilderInterface $builder): ?PostConfigureInterface + { + $foo = $builder->add('foo', ArrayElement::class); + $foo->arrayTransformer(new AArrayTransformer(c: 'A')); + + return $this; + } + + /** + * {@inheritdoc} + */ + function postConfigure(AttributeForm $form, FormInterface $inner): void + { + $form->foo = $inner['foo']->element(); + } +} + +PHP + , $form +); + } + + public function test_code_generator_from_service() + { + $form = new class extends AttributeForm { + #[Transformer(ServiceTransformer::class)] + public StringElement $foo; + }; + + $this->assertGenerated(<<<'PHP' +namespace Generated; + +use Bdf\Form\Aggregate\FormBuilderInterface; +use Bdf\Form\Aggregate\FormInterface; +use Bdf\Form\Attribute\AttributeForm; +use Bdf\Form\Attribute\Processor\AttributesProcessorInterface; +use Bdf\Form\Attribute\Processor\PostConfigureInterface; +use Bdf\Form\Leaf\StringElement; +use Tests\Form\Attribute\Element\ServiceTransformer; + +class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface +{ + /** + * {@inheritdoc} + */ + function configureBuilder(object|string $context, FormBuilderInterface $builder): ?PostConfigureInterface + { + $foo = $builder->add('foo', StringElement::class); + $foo->transformer(ServiceTransformer::class); + + return $this; + } + + /** + * {@inheritdoc} + */ + function postConfigure(AttributeForm $form, FormInterface $inner): void + { + $form->foo = $inner['foo']->element(); + } +} + +PHP + , $form +); + } + + public function test_code_generator_from_service_array() + { + $form = new class extends AttributeForm { + #[Transformer(ServiceArrayTransformer::class, 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\ServiceArrayTransformer; + +class GeneratedConfigurator implements AttributesProcessorInterface, PostConfigureInterface +{ + /** + * {@inheritdoc} + */ + function configureBuilder(object|string $context, FormBuilderInterface $builder): ?PostConfigureInterface + { + $foo = $builder->add('foo', ArrayElement::class); + $foo->arrayTransformer(ServiceArrayTransformer::class); + + return $this; + } + + /** + * {@inheritdoc} + */ + function postConfigure(AttributeForm $form, FormInterface $inner): void + { + $form->foo = $inner['foo']->element(); + } +} + +PHP + , $form +); + } + public function test_code_generator() { $form = new class extends AttributeForm { @@ -220,7 +525,7 @@ function configureBuilder(object|string $context, FormBuilderInterface $builder) $builder->generates(TestTransformerArrayStruct::class); $foo = $builder->add('foo', ArrayElement::class); - $foo->arrayTransformer(new AArrayTransformer('A')); + $foo->arrayTransformer(new AArrayTransformer(c: 'A')); $foo->hydrator(new Setter(null))->extractor(new Getter(null)); return null; @@ -275,8 +580,60 @@ class TestTransformerStruct public ?string $foo; } +class TestTransformerObjectStruct +{ + #[Transformer(new ATransformer('A'))] + public ?string $foo; +} + +class TestTransformerServiceStruct +{ + #[Transformer(ServiceTransformer::class)] + public ?string $foo; +} + +/** + * Transformer with a required constructor dependency: not instantiable without parameters, + * so it must be resolved from the registry/container. + */ +class ServiceTransformer implements TransformerInterface +{ + public function __construct( + public string $prefix + ) { + } + + public function transformToHttp($value, ElementInterface $input) + { + return $value . $this->prefix; + } + + public function transformFromHttp($value, ElementInterface $input) + { + return $this->prefix . $value; + } +} + +class ServiceArrayTransformer implements TransformerInterface +{ + public function __construct( + public string $prefix + ) { + } + + public function transformToHttp($value, ElementInterface $input) + { + return array_map(fn($v) => $v . $this->prefix, $value); + } + + public function transformFromHttp($value, ElementInterface $input) + { + return array_map(fn($v) => $this->prefix . $v, $value); + } +} + class TestTransformerArrayStruct { - #[Transformer(AArrayTransformer::class, ['A'], array: true)] + #[Transformer(new AArrayTransformer('A'), array: true)] public array $foo; } diff --git a/tests/Leaf/FloatElementBuilderTest.php b/tests/Leaf/FloatElementBuilderTest.php index 5a9cc89..9184292 100644 --- a/tests/Leaf/FloatElementBuilderTest.php +++ b/tests/Leaf/FloatElementBuilderTest.php @@ -3,6 +3,11 @@ namespace Bdf\Form\Leaf; use Bdf\Form\Choice\ArrayChoice; +use Bdf\Form\Choice\ChoiceInterface; +use Bdf\Form\Choice\ChoiceView; +use Bdf\Form\Choice\LazyChoice; +use Bdf\Form\Registry\Registry; +use InvalidArgumentException; use Locale; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\Extension\Core\DataTransformer\IntegerToLocalizedStringTransformer; @@ -340,4 +345,64 @@ public function test_choices_custom_message() $this->assertTrue($element->failed()); $this->assertEquals('my error', $element->error()->global()); } + + /** + * + */ + public function test_choices_from_service() + { + $choice = new MyFloatServiceChoice(); + + $registry = new Registry(); + $registry->registerService($choice); + + $builder = new FloatElementBuilder($registry); + $element = $builder->choices(MyFloatServiceChoice::class)->buildElement(); + + $this->assertInstanceOf(LazyChoice::class, $element->choices()); + $this->assertSame([12.3, 45.6, 78.9], $element->choices()->values()); + + $element->submit('14.7'); + $this->assertFalse($element->valid()); + $this->assertTrue($element->failed()); + $this->assertEquals('The value you selected is not a valid choice.', $element->error()->global()); + + $element->submit('45.6'); + $this->assertTrue($element->valid()); + } + + /** + * + */ + public function test_choices_from_service_not_registered() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.MyFloatServiceChoice::class.'" is not registered.'); + + $element = $this->builder->choices(MyFloatServiceChoice::class)->buildElement(); + $element->submit('45.6'); + } +} + +class MyFloatServiceChoice implements ChoiceInterface +{ + public function values(): array + { + return [12.3, 45.6, 78.9]; + } + + public function view(?callable $configuration = null): array + { + $view = []; + + foreach ($this->values() as $value) { + $view[] = $choice = new ChoiceView($value, $value); + + if ($configuration !== null) { + $configuration($choice); + } + } + + return $view; + } } diff --git a/tests/Leaf/IntegerElementBuilderTest.php b/tests/Leaf/IntegerElementBuilderTest.php index 82e525f..db12300 100644 --- a/tests/Leaf/IntegerElementBuilderTest.php +++ b/tests/Leaf/IntegerElementBuilderTest.php @@ -3,7 +3,12 @@ namespace Bdf\Form\Leaf; use Bdf\Form\Choice\ArrayChoice; +use Bdf\Form\Choice\ChoiceInterface; +use Bdf\Form\Choice\ChoiceView; use Bdf\Form\Choice\EnumChoice; +use Bdf\Form\Choice\LazyChoice; +use Bdf\Form\Registry\Registry; +use InvalidArgumentException; use Locale; use NumberFormatter; use PHPUnit\Framework\TestCase; @@ -331,6 +336,43 @@ public function test_choices_enum() $this->assertSame('Bar', $view[1]->label); $this->assertSame('121', $view[1]->value); } + + /** + * + */ + public function test_choices_from_service() + { + $choice = new MyIntServiceChoice(); + + $registry = new Registry(); + $registry->registerService($choice); + + $builder = new IntegerElementBuilder($registry); + $element = $builder->choices(MyIntServiceChoice::class)->buildElement(); + + $this->assertInstanceOf(LazyChoice::class, $element->choices()); + $this->assertSame([12, 34, 56], $element->choices()->values()); + + $element->submit(22); + $this->assertFalse($element->valid()); + $this->assertTrue($element->failed()); + $this->assertEquals('The value you selected is not a valid choice.', $element->error()->global()); + + $element->submit(34); + $this->assertTrue($element->valid()); + } + + /** + * + */ + public function test_choices_from_service_not_registered() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.MyIntServiceChoice::class.'" is not registered.'); + + $element = $this->builder->choices(MyIntServiceChoice::class)->buildElement(); + $element->submit(34); + } } enum MyIntEnum: int @@ -338,3 +380,26 @@ enum MyIntEnum: int case Foo = 42; case Bar = 121; } + +class MyIntServiceChoice implements ChoiceInterface +{ + public function values(): array + { + return [12, 34, 56]; + } + + public function view(?callable $configuration = null): array + { + $view = []; + + foreach ($this->values() as $value) { + $view[] = $choice = new ChoiceView($value, $value); + + if ($configuration !== null) { + $configuration($choice); + } + } + + return $view; + } +} diff --git a/tests/Leaf/StringElementBuilderTest.php b/tests/Leaf/StringElementBuilderTest.php index 94b7b6f..844d885 100644 --- a/tests/Leaf/StringElementBuilderTest.php +++ b/tests/Leaf/StringElementBuilderTest.php @@ -3,7 +3,14 @@ namespace Bdf\Form\Leaf; use Bdf\Form\Choice\ArrayChoice; +use Bdf\Form\Choice\ChoiceInterface; +use Bdf\Form\Choice\ChoiceView; use Bdf\Form\Choice\EnumChoice; +use Bdf\Form\Choice\LazyChoice; +use Bdf\Form\ElementInterface; +use Bdf\Form\Registry\Registry; +use Bdf\Form\Transformer\TransformerInterface; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\NotEqualTo; use Symfony\Component\Validator\Constraints\Positive; @@ -91,6 +98,35 @@ public function test_transformer_prepend() $this->assertEquals('_ab', $element->submit('_')->value()); } + /** + * + */ + public function test_transformer_from_service() + { + $registry = new Registry(); + $registry->registerService(new MyTransformerService()); + + $builder = new StringElementBuilder($registry); + $element = $builder + ->transformer(MyTransformerService::class) + ->transformer(function ($value) { return $value . 'a'; }) + ->buildElement() + ; + + $this->assertEquals('_a>', $element->submit('_')->value()); + } + + /** + * + */ + public function test_transformer_from_service_not_registered() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.MyTransformerService::class.'" is not registered.'); + + $this->builder->transformer(MyTransformerService::class); + } + /** * */ @@ -209,6 +245,43 @@ public function test_choices_enum() $this->assertSame('Bar', $view[1]->label); $this->assertSame('bar', $view[1]->value); } + + /** + * + */ + public function test_choices_from_service() + { + $choice = new MyServiceChoice(); + + $registry = new Registry(); + $registry->registerService($choice); + + $builder = new StringElementBuilder($registry); + $element = $builder->choices(MyServiceChoice::class)->buildElement(); + + $this->assertInstanceOf(LazyChoice::class, $element->choices()); + $this->assertSame(['foo', 'bar'], $element->choices()->values()); + + $element->submit('aaa'); + $this->assertFalse($element->valid()); + $this->assertTrue($element->failed()); + $this->assertEquals('The value you selected is not a valid choice.', $element->error()->global()); + + $element->submit('foo'); + $this->assertTrue($element->valid()); + } + + /** + * + */ + public function test_choices_from_service_not_registered() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Service "'.MyServiceChoice::class.'" is not registered.'); + + $element = $this->builder->choices(MyServiceChoice::class)->buildElement(); + $element->submit('foo'); + } } enum MyStringEnum: string @@ -216,3 +289,39 @@ enum MyStringEnum: string case Foo = 'foo'; case Bar = 'bar'; } + +class MyTransformerService implements TransformerInterface +{ + public function transformToHttp(mixed $value, ElementInterface $input): mixed + { + return $value; + } + + public function transformFromHttp(mixed $value, ElementInterface $input): mixed + { + return $value . '>'; + } +} + +class MyServiceChoice implements ChoiceInterface +{ + public function values(): array + { + return ['foo', 'bar']; + } + + public function view(?callable $configuration = null): array + { + $view = []; + + foreach ($this->values() as $value) { + $view[] = $choice = new ChoiceView($value, $value); + + if ($configuration !== null) { + $configuration($choice); + } + } + + return $view; + } +} diff --git a/tests/Registry/RegistryTest.php b/tests/Registry/RegistryTest.php index 57f6881..f41f4b0 100644 --- a/tests/Registry/RegistryTest.php +++ b/tests/Registry/RegistryTest.php @@ -156,6 +156,18 @@ public function test_buttonBuilder() $this->assertInstanceOf(SubmitButtonBuilder::class, $this->registry->buttonBuilder('btn')); $this->assertEquals('btn', $this->registry->buttonBuilder('btn')->buildButton()->name()); } + + public function test_service() + { + $service = new \stdClass(); + $this->registry->registerService($service); + + $this->assertSame($service, $this->registry->service(\stdClass::class)); + + $otherService = new \stdClass(); + $this->registry->registerService($otherService, 'other'); + $this->assertSame($otherService, $this->registry->service('other')); + } } class MyCustomTestElement extends LeafElement