Skip to content

Instantly share code, notes, and snippets.

@boekkooi
Last active December 11, 2020 02:28
Show Gist options
  • Save boekkooi/07c499adc3d18a44c238 to your computer and use it in GitHub Desktop.
Save boekkooi/07c499adc3d18a44c238 to your computer and use it in GitHub Desktop.
Symfony Form DataMapper using object constructors

Form Constructor DataMapper

Is is a very simple DataMapper that will create a instance of a class based on the form input using the class constructor. This works great for values objects or commands.

Example

namespace Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints;
use ValueObject\Address;
use Acme\Symfony\Form\DataMapper\ConstructorMapper;

class AddressFormType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        parent::buildForm($builder, $options);

        $builder
            ->add('street', 'text', [
                'label' => 'form.street',
                'translation_domain' => 'Address',
                'constraints' => [
                    new Constraints\NotBlank(),
                    new Constraints\Length(['min' => 2, 'max' => 255])
                ]
            ])
            ->add('zipCode', 'text', [
                'label' => 'form.zip_code',
                'translation_domain' => 'Address',
                'constraints' => [
                    new Constraints\NotBlank(),
                    new Constraints\Length(['min' => 2, 'max' => 50])
                ]
            ])
            ->add('city', 'text', [
                'label' => 'form.city',
                'translation_domain' => 'Address',
                'constraints' => [
                    new Constraints\NotBlank(),
                    new Constraints\Length(['min' => 2, 'max' => 255])
                ]
            ])
            ->add('region', 'text', [
                'required' => false,
                'label' => 'form.region',
                'translation_domain' => 'Address',
                'constraints' => [
                    new Constraints\Length(['max' => 255])
                ]
            ])
            ->add('country', 'country', [
                'label' => 'form.country',
                'translation_domain' => 'Address',
                'choice_translation_domain' => false,
                'empty_value' => 'Choose an option',
                'constraints' => [
                    new Constraints\NotBlank(),
                    new Constraints\Country(),
                    new Constraints\Length(['min' => 2, 'max' => 4])
                ]
            ])
            ->setDataMapper(new ConstructorMapper(Address::class));
    }

    /**
     * @inheritdoc
     */
    public function getName()
    {
        return 'address';
    }
}

namespace ValueObject;

use Symfony\Component\Intl\Intl;
use Assert;
use ValueObject\ValueObject;

/**
 * Address value object
 */
final class Address implements ValueObject
{
    /**
     * @var string
     */
    private $street;

    /**
     * @var string
     */
    private $zipCode;

    /**
     * @var string
     */
    private $city;

    /**
     * @var string
     */
    private $region;

    /**
     * @var string
     */
    private $country;

    /**
     * @param string $street
     * @param string $zipCode
     * @param string|null $region
     * @param string $city
     * @param string $country
     */
    public function __construct($street, $zipCode, $region = null, $city, $country)
    {
        Assert\lazy()
            ->that($street, 'street')->string()->minLength(2)->maxLength(255)
            ->that($zipCode, 'zipCode')->string()->minLength(2)->maxLength(50)
            ->that($city, 'city')->string()->minLength(2)->maxLength(255)
            ->that($region, 'region')->nullOr()->string()->maxLength(255)
            ->that($country, 'country')->string()->maxLength(2, 4)->inArray(array_keys(Intl::getRegionBundle()->getCountryNames()))
            ->verifyNow();

        $this->city = $city;
        $this->country = $country;
        $this->region = $region;
        $this->street = $street;
        $this->zipCode = $zipCode;
    }

    /**
     * @return string
     */
    public function getStreet()
    {
        return $this->street;
    }

    /**
     * @return string
     */
    public function getZipCode()
    {
        return $this->zipCode;
    }

    /**
     * @return string
     */
    public function getCity()
    {
        return $this->city;
    }

    /**
     * @return string
     */
    public function getRegion()
    {
        return $this->region;
    }

    /**
     * @return string
     */
    public function getCountry()
    {
        return $this->country;
    }

    /**
     * @param string|null $locale
     * @return string
     */
    public function getCountryName($locale = null)
    {
        return Intl::getRegionBundle()->getCountryName($this->country, $locale);
    }

    /**
     * @inheritdoc
     */
    public function equals($object)
    {
        return
            $object instanceof self &&
            $this->street === $object->street &&
            $this->zipCode === $object->zipCode &&
            $this->city === $object->city &&
            $this->region === $object->region &&
            $this->country === $object->country;
    }
}
<?php
namespace Acme\Symfony\Form\DataMapper;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\DataMapper\PropertyPathMapper;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
class ConstructorMapper extends PropertyPathMapper
{
/**
* @var string
*/
private $class;
/**
* @var array
*/
private $defaults;
/**
* @var NameConverterInterface|null
*/
protected $nameConverter;
/**
* Constructor.
*
* @param string $class The object class to reverse transform the data into
* @param array $defaults Command constructor default parameter values
* @param PropertyAccessorInterface $propertyAccessor
* @param NameConverterInterface|null $nameConverter
*/
public function __construct($class, array $defaults = [], PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null)
{
parent::__construct($propertyAccessor);
$this->class = $class;
$this->defaults = $defaults;
$this->nameConverter = $nameConverter;
}
/**
* @inheritdoc
*/
public function mapFormsToData($forms, &$data)
{
if (null === $forms) {
$data = null;
return;
}
// Fetch the form data
$formData = DataMapperUtils::resolveFormsToArray($forms);
// Resolve the arguments
try {
$reflectionClass = new \ReflectionClass($this->class);
$params = $this->resolveConstructorParameters($reflectionClass, $formData);
} catch (TransformationFailedException $e) {
@trigger_error('ConstructorMapper: Failed to resolve arguments - ' . $e->getMessage(), E_USER_NOTICE);
$data = null;
return;
}
// Create new instance
try {
$data = $reflectionClass->newInstanceArgs($params);
} catch (\Exception $e) {
@trigger_error('ConstructorMapper: Failed to create instance - ' . $e->getMessage(), E_USER_WARNING);
$data = null;
return;
}
}
/**
* @param $reflectionClass
* @param array|\ArrayAccess $data
*
* @return array
*/
protected function resolveConstructorParameters(\ReflectionClass $reflectionClass, $data)
{
$constructor = $this->getCommandConstructor($reflectionClass);
$params = [];
foreach ($constructor->getParameters() as $constructorParameter) {
$params[] = $this->resolveParameterValue($constructorParameter, $data);
}
return $params;
}
/**
* @param \ReflectionParameter $parameter
* @param array|\ArrayAccess $data
*
* @return mixed
*/
protected function resolveParameterValue(\ReflectionParameter $parameter, $data)
{
$paramName = $parameter->name;
$key = $this->nameConverter ? $this->nameConverter->normalize($paramName) : $paramName;
// The constructor parameter is in the form data
if (isset($data[$key]) || array_key_exists($key, $data)) {
$this->checkParameterCanBeSetWith($parameter, $data[$key]);
return $data[$key];
}
// The constructor parameter default value was provided
if (isset($this->defaults[$key]) || array_key_exists($key, $this->defaults)) {
$this->checkParameterCanBeSetWith($parameter, $this->defaults[$key]);
return $this->defaults[$key];
}
// The constructor parameter has a default
if ($parameter->isOptional()) {
return $parameter->getDefaultValue();
}
throw new TransformationFailedException(
sprintf(
'Cannot create an instance of %s from serialized data because its constructor requires parameter "%s" to be present.',
$this->class,
$parameter->name
)
);
}
protected function checkParameterCanBeSetWith(\ReflectionParameter $parameter, $data)
{
if ($data === null && $parameter->allowsNull()) {
return;
}
if ($parameter->isArray() && !is_array($data)) {
throw new TransformationFailedException(sprintf(
'Expected "%s" constructor argument "%s" to be an array got "%s"',
$parameter->getDeclaringClass()->getName(),
$parameter->getName(),
is_object($data) ? get_class($data) : gettype($data)
));
}
if ($parameter->getClass() !== null && (!is_object($data) || !$parameter->getClass()->isInstance($data))) {
throw new TransformationFailedException(sprintf(
'Expected "%s" constructor argument "%s" to be an instance of "%s" got "%s"',
$parameter->getDeclaringClass()->getName(),
$parameter->getName(),
$parameter->getClass()->getName(),
is_object($data) ? get_class($data) : gettype($data)
));
}
}
/**
* @param \ReflectionClass $reflectionClass
*
* @return \ReflectionMethod
*/
protected function getCommandConstructor(\ReflectionClass $reflectionClass)
{
$constructor = $reflectionClass->getConstructor();
if ($constructor instanceof \ReflectionMethod) {
return $constructor;
}
throw new TransformationFailedException(sprintf(
'__construct is required for "%s"',
$this->class
));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment