phpdoctrine-ormzend-framework2zend-formzend-validate

ZF2 + Doctrine 2 - Entity created when requirements not met and values empty


Extending on 2 previous questions about form structure and validating collections I've run into the next issue.

My form validates properly. Including included collections by way of Fieldsets. But the innermost Fieldset should not result in an Entity and a FK association to the parent if its values are not set.

An Address may or may not have linked Coordinates. It's possible to create all of these in the same Form.

However, the Coordinates should not be created and should not be linked from the Address if no coordinates have been given in the Form. They're not required in the Form and the Entity Coordinates itself requires both properties of Latitude and Longitude to be set.

Below, first the Entities. Following are the Fieldsets used for the AddressForm. I've removed stuff from both unrelated to the chain of Address -> Coordinates.

Address.php

class Address extends AbstractEntity
{
    // Properties

    /**
     * @var Coordinates
     * @ORM\OneToOne(targetEntity="Country\Entity\Coordinates", cascade={"persist"}, fetch="EAGER", orphanRemoval=true)
     * @ORM\JoinColumn(name="coordinates_id", referencedColumnName="id", nullable=true)
     */
    protected $coordinates;

    // Getters/Setters
}

Coordinates.php

class Coordinates extends AbstractEntity
{
    /**
     * @var string
     * @ORM\Column(name="latitude", type="string", nullable=false)
     */
    protected $latitude;

    /**
     * @var string
     * @ORM\Column(name="longitude", type="string", nullable=false)
     */
    protected $longitude;

    // Getters/Setters
}

As is seen in the Entities above. An Address has a OneToOne uni-directional relationship to Coordinates. The Coordinates entity requires both latitude and longitude properties, as seen with the nullable=false.

It's there that it goes wrong. If an Address is created, but no Coordinates's properties are set in the form, it still creates a Coordinates Entity, but leaves the latitude and longitude properties empty, even though they're required.

So, in short:

Below the Fieldsets and InputFilters to clarify things further.

AddressFieldset.php

class AddressFieldset extends AbstractFieldset
{
    public function init()
    {
        parent::init();

        // Other properties

        $this->add([
            'type' => CoordinatesFieldset::class,
            'required' => false,
            'name' => 'coordinates',
            'options' => [
                'use_as_base_fieldset' => false,
            ],
        ]);
    }
}

CoordinatesFieldset.php

class CoordinatesFieldset extends AbstractFieldset
{
    public function init()
    {
        parent::init();

        $this->add([
            'name' => 'latitude',
            'required' => true,
            'type' => Text::class,
            'options' => [
                'label' => _('Latitude'),
            ],
        ]);

        $this->add([
            'name' => 'longitude',
            'required' => true,
            'type' => Text::class,
            'options' => [
                'label' => _('Longitude'),
            ],
        ]);
    }
}

AddressFieldsetInputFilter.php

class AddressFieldsetInputFilter extends AbstractFieldsetInputFilter
{
    /** @var CoordinatesFieldsetInputFilter $coordinatesFieldsetInputFilter */
    protected $coordinatesFieldsetInputFilter;

    public function __construct(
        CoordinatesFieldsetInputFilter $filter,
        EntityManager $objectManager,
        Translator $translator
    ) {
        $this->coordinatesFieldsetInputFilter = $filter;

        parent::__construct([
            'object_manager' => $objectManager,
            'object_repository' => $objectManager->getRepository(Address::class),
            'translator' => $translator,
        ]);
    }

    /**
     * Sets AddressFieldset Element validation
     */
    public function init()
    {
        parent::init();

        $this->add($this->coordinatesFieldsetInputFilter, 'coordinates');

        // Other filters/validators
    }
}

CoordinatesFieldsetInputFilter.php

class CoordinatesFieldsetInputFilter extends AbstractFieldsetInputFilter
{
    public function init()
    {
        parent::init();

        $this->add([
            'name' => 'latitude',
            'required' => true,
            'allow_empty' => true,
            'filters' => [
                ['name' => StringTrim::class],
                ['name' => StripTags::class],
            ],
            'validators' => [
                [
                    'name' => StringLength::class,
                    'options' => [
                        'min' => 2,
                        'max' => 255,
                    ],
                ],
                [
                    'name' => Callback::class,
                    'options' => [
                        'callback' => function($value, $context) {
                            //If longitude has a value, mark required
                            if(empty($context['longitude']) && strlen($value) > 0) {
                                $validatorChain = $this->getInputs()['longitude']->getValidatorChain();

                                $validatorChain->attach(new NotEmpty(['type' => NotEmpty::NULL]));
                                $this->getInputs()['longitude']->setValidatorChain($validatorChain);

                                return false;
                            }

                            return true;
                        },
                        'messages' => [
                            'callbackValue' => _('Longitude is required when setting Latitude. Give both or neither.'),
                        ],
                    ],
                ],
            ],
        ]);

        // Another, pretty much identical function for longitude (reverse some params and you're there...)
    }
}

EDIT: Adding a DB Dump image. Shows empty latitude, longitude.

Empty latitude/longitude

EDIT2: When I remove 'allow_empty' => true, from the AddressFieldsetInputFilter inputs and fill a single input (latitude or longitude), then it validates correctly, unless you leave both inputs empty, then it breaks off immediately to return that the input is required. (Value is required and can't be empty).


Solution

  • By chance did I stumple upon this answer, which was for allowing a Fieldset to be empty but validate it if at least a single input was filled in.

    By extending my own AbstractFormInputFilter and AbstractFieldsetInputFilter classes from an AbstractInputFilter class, which incorporates the answer, I'm now able to supply FielsetInputFilters, such as the AddressFieldsetInputFilter, with an additional ->setRequired(false). Which is then validated in the AbstractInputFilter, if it actually is empty.

    The linked answer gives this code:

    <?php
    namespace Application\InputFilter;
    
    use Zend\InputFilter as ZFI;
    
    class InputFilter extends ZFI\InputFilter
    {
        private $required = true;
    
        /**
         * @return boolean
         */
        public function isRequired()
        {
            return $this->required;
        }
    
        /**
         * @param boolean $required
         *
         * @return $this
         */
        public function setRequired($required)
        {
            $this->required = (bool) $required;
            return $this;
        }
    
        /**
         * @return bool
         */
        public function isValid()
        {
            if (!$this->isRequired() && empty(array_filter($this->getRawValues()))) {
                return true;
            }
    
            return parent::isValid();
        }
    }
    

    As I mentioned I used this code to extend my own AbstractInputFilter, allowing small changes in *FieldsetInputFilterFactory classes.

    AddressFieldsetInputFilterFactory.php

    class AddressFieldsetInputFilterFactory extends AbstractFieldsetInputFilterFactory
    {
        /**
         * @param ServiceLocatorInterface|ControllerManager $serviceLocator
         * @return InputFilter
         */
        public function createService(ServiceLocatorInterface $serviceLocator)
        {
            parent::setupRequirements($serviceLocator, Address::class);
    
            /** @var CoordinatesFieldsetInputFilter $coordinatesFieldsetInputFilter */
            $coordinatesFieldsetInputFilter = $this->getServiceManager()->get('InputFilterManager')
                ->get(CoordinatesFieldsetInputFilter::class);
            $coordinatesFieldsetInputFilter->setRequired(false); // <-- Added option
    
            return new AddressFieldsetInputFilter(
                $coordinatesFieldsetInputFilter,
                $this->getEntityManager(),
                $this->getTranslator()
            );
        }
    }
    

    Might not be a good idea for everybody's projects, but it solves my problem of not always wanting to validate a Fieldset and it definitely solves the original issue of not creating an Entity with just an ID, as shown in the screenshot in the question.