src/ValueObject/ValueObject.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

namespace ValueObject;

use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Validation;
use ValueObject\Exception\ParameterNotFoundException;
use ValueObject\Exception\ValidationException;
use Symfony\Component\Validator\Validator\ValidatorInterface;

abstract class ValueObject
{
    /**
     * This array contains VO parameters values which is already validated.
     *
     * @var array $params Validated params, which are available through magic method getParameterNameInCamelCase.
     */
    private $params = [];

    /**
     * This array contains validation errors.
     *
     * @var array $errors Validation errors.
     */
    private $errors = [];

    /**
     * Symfony validator instance.
     *
     * @var ValidatorInterface
     */
    private $validator;

    /**
     * Returns validation rules.
     *
     * This rules describes REQUIRED parameters.
     * Optional parameter must be resolved outside VO instance,
     * so only in this way we can ensure that VO have all parameters and they all valid.
     *
     * @return array Array with validation rules.
     */
    abstract protected function getRules();

    /**
     * Constructor.
     *
     * Most important method.
     * Here we validate received parameters against rules defined in method getRules,
     * and call after validation lifecycle callback.
     *
     * @param array $params Parameters being validated.
     *
     * @throws ValidationException In case when validation failed.
     */
    public function __construct(array $params)
    {
        // Initiate instance of Symfony validator.
        $this->validator = Validation::createValidator();

        $this->validate($params);
        $this->afterValidation($params);
        // In case when we obtain invalid parameter we throw error here,
        // this approach ensures that our VO have only valid parameters.
        if (0 !== count($this->errors)) {
            throw new ValidationException($this->errors);
        }
    }

    /**
     * Most important method. Here performs all validation stuff.
     *
     * @param array $params Parameters being validated.
     */
    private function validate(array $params)
    {
        /** @var array $rules */
        foreach ($this->getRules() as $paramName => $rules) {
            $constraints = $this->getConstraints($rules);
            // In the beginning of validation we must be sure that required parameter is passed to VO,
            // otherwise it will be first validation error.
            if (isset($params[$paramName])) {
                $value = $params[$paramName];
                // Performs Symfony validation.
                $this->validateParameter($paramName, $value, $constraints);
            } else {
                // We can not validate this parameter against validation rules,
                // because this parameter was not passed to VO.
                $this->setError($paramName, 'This parameter is required.');
            }
        }
    }

    /**
     * Gets validation constraints out from VO rules.
     *
     * One validation rule (parameter $rules) can have lot of constraints (Symfony constraints).
     * We must check them all,
     * so that's why here we build array with appropriate constraints for particular validation rule.
     * This array contains only constraints for only one validation rule,
     * for only one parameter which must be validated.
     *
     * @param array $rules Array of validation rules.
     *
     * @return array
     */
    private function getConstraints(array $rules)
    {
        $constraints = [];
        foreach ($rules as $constraintName => $options) {
            $constraintOptions = $options;
            // Constraint can be specified in simple way,
            // like string which is constraint name (for example: `'NotBlank'` etc).
            // Or as array with parameters, like:
            // array kes - is constraint name
            // and array value - constrain options (for example: `'Length' => ['min' => 5]`).
            if (!is_array($options)) {
                $constraintName = $options;
                $constraintOptions = [];
            }
            $constraintClassName = 'Symfony\Component\Validator\Constraints\\' . $constraintName;
            $constraints[] = new $constraintClassName($constraintOptions);
        }

        return $constraints;
    }

    /**
     * Validate parameter value against bunch of constraints.
     *
     * @param string $paramName Parameter name.
     * @param string $value Parameter value.
     * @param array $constraints Array of validation constraints.
     */
    private function validateParameter($paramName, $value, array $constraints)
    {
        $violations = $this->validator->validate($value, $constraints);
        if (0 === count($violations)) {
            // Parameter valid,
            // and only in this case we can store this parameter inside VO.
            // Thereby we ensure that our VO contains only valid parameters with only valid values.
            $this->params[$paramName] = $value;
        } else {
            // Parameter invalid,
            // so we can not store it inside VO,
            // we only can store error message about validation fail.
            $this->errors[$paramName] = $violations;
        }
    }

    /**
     * Sets error message for certain parameter.
     *
     * @param string $paramName Parameter name (key in array $parameters which is passed to __constructor method).
     * @param string $message Custom error message.
     */
    public function setError($paramName, $message)
    {
        $violation = new ConstraintViolation($message, '', [], '', $paramName, null);
        if (isset($this->errors[$paramName])) {
            // Adds error message to ConstraintViolationList.
            $this->errors[$paramName]->add($violation);
        } else {
            // Creates ConstraintViolationList and puts into it first error message.
            $this->errors[$paramName] = new ConstraintViolationList([$violation]);
        }
    }

    /**
     * Lifecycle callback - after validation.
     *
     * This method can be used for custom validation purposes.
     * This method will be called each time after validation rules (validate method).
     *
     * @param array $params Array with parameters passed to VO.
     */
    public function afterValidation(array $params)
    {}

    /**
     * Returns parameter value by parameter name.
     *
     * @param string $name Parameter name (key in array $parameters which is passed to __constructor method).
     * @param array $arguments Array with arguments.
     *
     * @throws ParameterNotFoundException In case when VO don't have needed parameter.
     *
     * @return mixed Parameter value.
     */
    public function __call($name, array $arguments)
    {
        $paramName = lcfirst(substr($name, 3));
        if (!isset($this->params[$paramName])) {
            throw new ParameterNotFoundException("Parameter: $name not found by name: $paramName.");
        }

        return $this->params[$paramName];
    }

    /**
     * Gets VO parameter as array.
     *
     * @return array Array which contains all VO parameters.
     */
    public function toArray()
    {
        return $this->params;
    }
}