fre5h / DoctrineEnumBundle

📦 Provides support of ENUM type for Doctrine in Symfony applications.
https://github.com/fre5h/DoctrineEnumBundle
MIT License
459 stars 75 forks source link

How to use guessing with DTO instead of Entity? #226

Open craigh opened 1 year ago

craigh commented 1 year ago

I'm trying to use the FormType guessing feature of this bundle, but with a DTO and not an Entity. Is this possible? I see the guessing uses the Type - which I assume is from the @ORM\Column(type="MyCoolEnum") declaration.

https://github.com/fre5h/DoctrineEnumBundle/blob/4448ef6abd4bcdeee6811d11a932315754d8f0d9/Form/EnumTypeGuesser.php#L68

I guess Symfony uses the validation constraints instead. Is there a way this could be done?

fre5h commented 1 year ago

@craigh do you get some error? if yes, then please write it here and also show me your form type.

craigh commented 1 year ago

Hello!

No, I do not receive any error, the guessing just fails so the input is displayed as a simple text box.

The form generation is dynamic and complicated, but in the end it is simply this:

$builder->add($field->name, $field->type, $options);

where:

$field->name = 'state'
$field->type = null
$options = []

As I said, the form data is a DTO instead of an Entity. So I have tried various versions of attributes in the DTO definition:

    #[DoctrineAssert\EnumType(USStateType::class)]
    public string $state;
    #[DoctrineAssert\EnumType(USStateType::class)]
    public USStateType $state;
    public USStateType $state;

none seem to have any effect.

craigh commented 1 year ago

I am using the same Enum in a 'regular' form and it works fine there. So the Enum is setup properly when using an Entity-backed form.

Thank you for quickly responding and trying to help me! 🙏

fre5h commented 1 year ago

It's not possible to use form guesser for DTOs now. Because

class EnumTypeGuesser extends DoctrineOrmTypeGuesser

Enum Guesser extends Doctrine ORM Guesser, so it expects the mapped entity. To do what you want is needed a new Guesser, which will does somehow detection from any Plain PHP Class, not only from entities.

craigh commented 1 year ago

Thank you, this was my assumption as well. I was hoping you might be able to extend Symfony's Validation guesser to add Enum types as well. I guess this goes in as a feature request.

thanks!

craigh commented 1 year ago

Here is something I hacked together. You could clean it up and add it to the bundle.

// src/Form/Extension/Validator/EnumValidatorTypeGuesser.php
<?php

namespace App\Form\Extension\Validator;

use Fresh\DoctrineEnumBundle\DBAL\Types\AbstractEnumType;
use Fresh\DoctrineEnumBundle\Exception\EnumType\EnumTypeIsRegisteredButClassDoesNotExistException;
use Fresh\DoctrineEnumBundle\Validator\Constraints\EnumType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormTypeGuesserInterface;
use Symfony\Component\Form\Guess\Guess;
use Symfony\Component\Form\Guess\TypeGuess;
use Symfony\Component\Form\Guess\ValueGuess;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Mapping\ClassMetadataInterface;
use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface;

class EnumValidatorTypeGuesser implements FormTypeGuesserInterface
{
    /** @var string[] */
    private array $registeredEnumTypes = [];

    public function __construct(
        private readonly MetadataFactoryInterface $metadataFactory,
        array $registeredTypes,
    ) {
        foreach ($registeredTypes as $type => $details) {
            $this->registeredEnumTypes[$type] = $details['class'];
        }
    }

    public function guessType(string $class, string $property): ?TypeGuess
    {
        return $this->guess($class, $property, function (Constraint $constraint) {
            return $this->guessTypeForConstraint($constraint);
        });
    }

    public function guessRequired(string $class, string $property): ?ValueGuess
    {
        return null;
    }

    public function guessMaxLength(string $class, string $property): ?ValueGuess
    {
        return null;
    }

    public function guessPattern(string $class, string $property): ?ValueGuess
    {
        return null;
    }

    /**
     * Guesses a field class name for a given constraint.
     */
    public function guessTypeForConstraint(Constraint $constraint): ?TypeGuess
    {
        if ($constraint instanceof EnumType) {
            if (!\in_array($constraint->entity, $this->registeredEnumTypes, true)) {
                return null;
            }
            $registeredEnumTypeFQCN = $constraint->entity;

            if (!\class_exists($constraint->entity)) {
                $exceptionMessage = \sprintf(
                    'ENUM type "%s" is registered as "%s", but that class does not exist',
                    $constraint->entity,
                    $registeredEnumTypeFQCN
                );

                throw new EnumTypeIsRegisteredButClassDoesNotExistException($exceptionMessage);
            }

            if (!\is_subclass_of($registeredEnumTypeFQCN, AbstractEnumType::class)) {
                return null;
            }

            /** @var AbstractEnumType<int|string, int|string> $registeredEnumTypeFQCN */
            $parameters = [
                'choices' => $registeredEnumTypeFQCN::getChoices(), // Get the choices from the fully qualified class name
            ];

            return new TypeGuess(ChoiceType::class, $parameters, Guess::VERY_HIGH_CONFIDENCE);
        }

        return null;
    }

    /**
     * Iterates over the constraints of a property, executes a constraints on
     * them and returns the best guess.
     */
    protected function guess(string $class, string $property, \Closure $closure, $defaultValue = null): Guess|null
    {
        $guesses = [];
        $classMetadata = $this->metadataFactory->getMetadataFor($class);

        if ($classMetadata instanceof ClassMetadataInterface && $classMetadata->hasPropertyMetadata($property)) {
            foreach ($classMetadata->getPropertyMetadata($property) as $memberMetadata) {
                foreach ($memberMetadata->getConstraints() as $constraint) {
                    if ($guess = $closure($constraint)) {
                        $guesses[] = $guess;
                    }
                }
            }
        }

        if ($defaultValue !== null) {
            $guesses[] = new ValueGuess($defaultValue, Guess::LOW_CONFIDENCE);
        }

        return Guess::getBestGuess($guesses);
    }
}

Config:

# services.yaml
    App\Form\Extension\Validator\EnumValidatorTypeGuesser:
        arguments:
            - '@validator.mapping.class_metadata_factory'
            - "%doctrine.dbal.connection_factory.types%"