yiisoft/yii2

View on GitHub
framework/data/DataFilter.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

/**
 * @link https://www.yiiframework.com/
 * @copyright Copyright (c) 2008 Yii Software LLC
 * @license https://www.yiiframework.com/license/
 */

namespace yii\data;

use Yii;
use yii\base\InvalidConfigException;
use yii\base\Model;
use yii\helpers\ArrayHelper;
use yii\validators\BooleanValidator;
use yii\validators\EachValidator;
use yii\validators\NumberValidator;
use yii\validators\StringValidator;
use yii\validators\DateValidator;
use yii\validators\Validator;

/**
 * DataFilter is a special [[Model]] for processing query filtering specification.
 * It allows validating and building a filter condition passed via request.
 *
 * Filter example:
 *
 * ```json
 * {
 *     "or": [
 *         {
 *             "and": [
 *                 {
 *                     "name": "some name",
 *                 },
 *                 {
 *                     "price": "25",
 *                 }
 *             ]
 *         },
 *         {
 *             "id": {"in": [2, 5, 9]},
 *             "price": {
 *                 "gt": 10,
 *                 "lt": 50
 *             }
 *         }
 *     ]
 * }
 * ```
 *
 * In the request the filter should be specified using a key name equal to [[filterAttributeName]]. Thus actual HTTP request body
 * will look like following:
 *
 * ```json
 * {
 *     "filter": {"or": {...}},
 *     "page": 2,
 *     ...
 * }
 * ```
 *
 * Raw filter value should be assigned to [[filter]] property of the model.
 * You may populate it from request data via [[load()]] method:
 *
 * ```php
 * use yii\data\DataFilter;
 *
 * $dataFilter = new DataFilter();
 * $dataFilter->load(Yii::$app->request->getBodyParams());
 * ```
 *
 * In order to function this class requires a search model specified via [[searchModel]]. This search model should declare
 * all available search attributes and their validation rules. For example:
 *
 * ```php
 * class SearchModel extends \yii\base\Model
 * {
 *     public $id;
 *     public $name;
 *
 *     public function rules()
 *     {
 *         return [
 *             [['id', 'name'], 'trim'],
 *             ['id', 'integer'],
 *             ['name', 'string'],
 *         ];
 *     }
 * }
 * ```
 *
 * In order to reduce amount of classes, you may use [[\yii\base\DynamicModel]] instance as a [[searchModel]].
 * In this case you should specify [[searchModel]] using a PHP callable:
 *
 * ```php
 * function () {
 *     return (new \yii\base\DynamicModel(['id' => null, 'name' => null]))
 *         ->addRule(['id', 'name'], 'trim')
 *         ->addRule('id', 'integer')
 *         ->addRule('name', 'string');
 * }
 * ```
 *
 * You can use [[validate()]] method to check if filter value is valid. If validation fails you can use
 * [[getErrors()]] to get actual error messages.
 *
 * In order to acquire filter condition suitable for fetching data use [[build()]] method.
 *
 * > Note: This is a base class. Its implementation of [[build()]] simply returns normalized [[filter]] value.
 * In order to convert filter to particular format you should use descendant of this class that implements
 * [[buildInternal()]] method accordingly.
 *
 * @see ActiveDataFilter
 *
 * @property array $errorMessages Error messages in format `[errorKey => message]`. Note that the type of this
 * property differs in getter and setter. See [[getErrorMessages()]] and [[setErrorMessages()]] for details.
 * @property mixed $filter Raw filter value.
 * @property array $searchAttributeTypes Search attribute type map. Note that the type of this property
 * differs in getter and setter. See [[getSearchAttributeTypes()]] and [[setSearchAttributeTypes()]] for details.
 * @property Model $searchModel Model instance. Note that the type of this property differs in getter and
 * setter. See [[getSearchModel()]] and [[setSearchModel()]] for details.
 *
 * @author Paul Klimov <klimov.paul@gmail.com>
 * @since 2.0.13
 */
class DataFilter extends Model
{
    const TYPE_INTEGER = 'integer';
    const TYPE_FLOAT = 'float';
    const TYPE_BOOLEAN = 'boolean';
    const TYPE_STRING = 'string';
    const TYPE_ARRAY = 'array';
    const TYPE_DATETIME = 'datetime';
    const TYPE_DATE = 'date';
    const TYPE_TIME = 'time';

    /**
     * @var string name of the attribute that handles filter value.
     * The name is used to load data via [[load()]] method.
     */
    public $filterAttributeName = 'filter';
    /**
     * @var string label for the filter attribute specified via [[filterAttributeName]].
     * It will be used during error messages composition.
     */
    public $filterAttributeLabel;
    /**
     * @var array keywords or expressions that could be used in a filter.
     * Array keys are the expressions used in raw filter value obtained from user request.
     * Array values are internal build keys used in this class methods.
     *
     * Any unspecified keyword will not be recognized as a filter control and will be treated as
     * an attribute name. Thus you should avoid conflicts between control keywords and attribute names.
     * For example: in case you have control keyword 'like' and an attribute named 'like', specifying condition
     * for such attribute will be impossible.
     *
     * You may specify several keywords for the same filter build key, creating multiple aliases. For example:
     *
     * ```php
     * [
     *     'eq' => '=',
     *     '=' => '=',
     *     '==' => '=',
     *     '===' => '=',
     *     // ...
     * ]
     * ```
     *
     * > Note: while specifying filter controls take actual data exchange format, which your API uses, in mind.
     * > Make sure each specified control keyword is valid for the format. For example, in XML tag name can start
     * > only with a letter character, thus controls like `>`, '=' or `$gt` will break the XML schema.
     */
    public $filterControls = [
        'and' => 'AND',
        'or' => 'OR',
        'not' => 'NOT',
        'lt' => '<',
        'gt' => '>',
        'lte' => '<=',
        'gte' => '>=',
        'eq' => '=',
        'neq' => '!=',
        'in' => 'IN',
        'nin' => 'NOT IN',
        'like' => 'LIKE',
    ];
    /**
     * @var array maps filter condition keywords to validation methods.
     * These methods are used by [[validateCondition()]] to validate raw filter conditions.
     */
    public $conditionValidators = [
        'AND' => 'validateConjunctionCondition',
        'OR' => 'validateConjunctionCondition',
        'NOT' => 'validateBlockCondition',
        '<' => 'validateOperatorCondition',
        '>' => 'validateOperatorCondition',
        '<=' => 'validateOperatorCondition',
        '>=' => 'validateOperatorCondition',
        '=' => 'validateOperatorCondition',
        '!=' => 'validateOperatorCondition',
        'IN' => 'validateOperatorCondition',
        'NOT IN' => 'validateOperatorCondition',
        'LIKE' => 'validateOperatorCondition',
    ];
    /**
     * @var array specifies the list of supported search attribute types per each operator.
     * This field should be in format: 'operatorKeyword' => ['type1', 'type2' ...].
     * Supported types list can be specified as `*`, which indicates that operator supports all types available.
     * Any unspecified keyword will not be considered as a valid operator.
     */
    public $operatorTypes = [
        '<' => [self::TYPE_INTEGER, self::TYPE_FLOAT, self::TYPE_DATETIME, self::TYPE_DATE, self::TYPE_TIME],
        '>' => [self::TYPE_INTEGER, self::TYPE_FLOAT, self::TYPE_DATETIME, self::TYPE_DATE, self::TYPE_TIME],
        '<=' => [self::TYPE_INTEGER, self::TYPE_FLOAT, self::TYPE_DATETIME, self::TYPE_DATE, self::TYPE_TIME],
        '>=' => [self::TYPE_INTEGER, self::TYPE_FLOAT, self::TYPE_DATETIME, self::TYPE_DATE, self::TYPE_TIME],
        '=' => '*',
        '!=' => '*',
        'IN' => '*',
        'NOT IN' => '*',
        'LIKE' => [self::TYPE_STRING],
    ];
    /**
     * @var array list of operators keywords, which should accept multiple values.
     */
    public $multiValueOperators = [
        'IN',
        'NOT IN',
    ];
    /**
     * @var array actual attribute names to be used in searched condition, in format: [filterAttribute => actualAttribute].
     * For example, in case of using table joins in the search query, attribute map may look like the following:
     *
     * ```php
     * [
     *     'authorName' => '{{author}}.[[name]]'
     * ]
     * ```
     *
     * Attribute map will be applied to filter condition in [[normalize()]] method.
     */
    public $attributeMap = [];
    /**
     * @var string representation of `null` instead of literal `null` in case the latter cannot be used.
     * @since 2.0.40
     */
    public $nullValue = 'NULL';

    /**
     * @var array|\Closure list of error messages responding to invalid filter structure, in format: `[errorKey => message]`.
     */
    private $_errorMessages;
    /**
     * @var mixed raw filter specification.
     */
    private $_filter;
    /**
     * @var Model|array|string|callable model to be used for filter attributes validation.
     */
    private $_searchModel;
    /**
     * @var array list of search attribute types in format: attributeName => type
     */
    private $_searchAttributeTypes;


    /**
     * @return mixed raw filter value.
     */
    public function getFilter()
    {
        return $this->_filter;
    }

    /**
     * @param mixed $filter raw filter value.
     */
    public function setFilter($filter)
    {
        $this->_filter = $filter;
    }

    /**
     * @return Model model instance.
     * @throws InvalidConfigException on invalid configuration.
     */
    public function getSearchModel()
    {
        if (!is_object($this->_searchModel) || $this->_searchModel instanceof \Closure) {
            $model = Yii::createObject($this->_searchModel);
            if (!$model instanceof Model) {
                throw new InvalidConfigException('`' . get_class($this) . '::$searchModel` should be an instance of `' . Model::className() . '` or its DI compatible configuration.');
            }
            $this->_searchModel = $model;
        }
        return $this->_searchModel;
    }

    /**
     * @param Model|array|string|callable $model model instance or its DI compatible configuration.
     * @throws InvalidConfigException on invalid configuration.
     */
    public function setSearchModel($model)
    {
        if (is_object($model) && !$model instanceof Model && !$model instanceof \Closure) {
            throw new InvalidConfigException('`' . get_class($this) . '::$searchModel` should be an instance of `' . Model::className() . '` or its DI compatible configuration.');
        }
        $this->_searchModel = $model;
    }

    /**
     * @return array search attribute type map.
     */
    public function getSearchAttributeTypes()
    {
        if ($this->_searchAttributeTypes === null) {
            $this->_searchAttributeTypes = $this->detectSearchAttributeTypes();
        }
        return $this->_searchAttributeTypes;
    }

    /**
     * @param array|null $searchAttributeTypes search attribute type map.
     */
    public function setSearchAttributeTypes($searchAttributeTypes)
    {
        $this->_searchAttributeTypes = $searchAttributeTypes;
    }

    /**
     * Composes default value for [[searchAttributeTypes]] from the [[searchModel]] validation rules.
     * @return array attribute type map.
     */
    protected function detectSearchAttributeTypes()
    {
        $model = $this->getSearchModel();

        $attributeTypes = [];
        foreach ($model->activeAttributes() as $attribute) {
            $attributeTypes[$attribute] = self::TYPE_STRING;
        }

        foreach ($model->getValidators() as $validator) {
            $type = $this->detectSearchAttributeType($validator);

            if ($type !== null) {
                foreach ((array) $validator->attributes as $attribute) {
                    $attributeTypes[$attribute] = $type;
                }
            }
        }

        return $attributeTypes;
    }

    /**
     * Detect attribute type from given validator.
     *
     * @param Validator $validator validator from which to detect attribute type.
     * @return string|null detected attribute type.
     * @since 2.0.14
     */
    protected function detectSearchAttributeType(Validator $validator)
    {
        if ($validator instanceof BooleanValidator) {
            return self::TYPE_BOOLEAN;
        }

        if ($validator instanceof NumberValidator) {
            return $validator->integerOnly ? self::TYPE_INTEGER : self::TYPE_FLOAT;
        }

        if ($validator instanceof StringValidator) {
            return self::TYPE_STRING;
        }

        if ($validator instanceof EachValidator) {
            return self::TYPE_ARRAY;
        }

        if ($validator instanceof DateValidator) {
            if ($validator->type == DateValidator::TYPE_DATETIME) {
                return self::TYPE_DATETIME;
            }

            if ($validator->type == DateValidator::TYPE_TIME) {
                return self::TYPE_TIME;
            }
            return self::TYPE_DATE;
        }
    }

    /**
     * @return array error messages in format `[errorKey => message]`.
     */
    public function getErrorMessages()
    {
        if (!is_array($this->_errorMessages)) {
            if ($this->_errorMessages === null) {
                $this->_errorMessages = $this->defaultErrorMessages();
            } else {
                $this->_errorMessages = array_merge(
                    $this->defaultErrorMessages(),
                    call_user_func($this->_errorMessages)
                );
            }
        }
        return $this->_errorMessages;
    }

    /**
     * Sets the list of error messages responding to invalid filter structure, in format: `[errorKey => message]`.
     * Message may contain placeholders that will be populated depending on the message context.
     * For each message a `{filter}` placeholder is available referring to the label for [[filterAttributeName]] attribute.
     * @param array|\Closure $errorMessages error messages in `[errorKey => message]` format, or a PHP callback returning them.
     */
    public function setErrorMessages($errorMessages)
    {
        if (is_array($errorMessages)) {
            $errorMessages = array_merge($this->defaultErrorMessages(), $errorMessages);
        }
        $this->_errorMessages = $errorMessages;
    }

    /**
     * Returns default values for [[errorMessages]].
     * @return array default error messages in `[errorKey => message]` format.
     */
    protected function defaultErrorMessages()
    {
        return [
            'invalidFilter' => Yii::t('yii', 'The format of {filter} is invalid.'),
            'operatorRequireMultipleOperands' => Yii::t('yii', 'Operator "{operator}" requires multiple operands.'),
            'unknownAttribute' => Yii::t('yii', 'Unknown filter attribute "{attribute}"'),
            'invalidAttributeValueFormat' => Yii::t('yii', 'Condition for "{attribute}" should be either a value or valid operator specification.'),
            'operatorRequireAttribute' => Yii::t('yii', 'Operator "{operator}" must be used with a search attribute.'),
            'unsupportedOperatorType' => Yii::t('yii', '"{attribute}" does not support operator "{operator}".'),
        ];
    }

    /**
     * Parses content of the message from [[errorMessages]], specified by message key.
     * @param string $messageKey message key.
     * @param array $params params to be parsed into the message.
     * @return string composed message string.
     */
    protected function parseErrorMessage($messageKey, $params = [])
    {
        $messages = $this->getErrorMessages();
        if (isset($messages[$messageKey])) {
            $message = $messages[$messageKey];
        } else {
            $message = Yii::t('yii', 'The format of {filter} is invalid.');
        }

        $params = array_merge(
            [
                'filter' => $this->getAttributeLabel($this->filterAttributeName),
            ],
            $params
        );

        return Yii::$app->getI18n()->format($message, $params, Yii::$app->language);
    }

    // Model specific:

    /**
     * {@inheritdoc}
     */
    public function attributes()
    {
        return [
            $this->filterAttributeName,
        ];
    }

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

    /**
     * {@inheritdoc}
     */
    public function rules()
    {
        return [
            [$this->filterAttributeName, 'validateFilter', 'skipOnEmpty' => false],
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function attributeLabels()
    {
        return [
            $this->filterAttributeName => $this->filterAttributeLabel,
        ];
    }

    // Validation:

    /**
     * Validates filter attribute value to match filer condition specification.
     */
    public function validateFilter()
    {
        $value = $this->getFilter();
        if ($value !== null) {
            $this->validateCondition($value);
        }
    }

    /**
     * Validates filter condition.
     * @param mixed $condition raw filter condition.
     */
    protected function validateCondition($condition)
    {
        if (!is_array($condition)) {
            $this->addError($this->filterAttributeName, $this->parseErrorMessage('invalidFilter'));
            return;
        }

        if (empty($condition)) {
            return;
        }

        foreach ($condition as $key => $value) {
            $method = 'validateAttributeCondition';
            if (isset($this->filterControls[$key])) {
                $controlKey = $this->filterControls[$key];
                if (isset($this->conditionValidators[$controlKey])) {
                    $method = $this->conditionValidators[$controlKey];
                }
            }
            $this->$method($key, $value);
        }
    }

    /**
     * Validates conjunction condition that consists of multiple independent ones.
     * This covers such operators as `and` and `or`.
     * @param string $operator raw operator control keyword.
     * @param mixed $condition raw condition.
     */
    protected function validateConjunctionCondition($operator, $condition)
    {
        if (!is_array($condition) || !ArrayHelper::isIndexed($condition)) {
            $this->addError($this->filterAttributeName, $this->parseErrorMessage('operatorRequireMultipleOperands', ['operator' => $operator]));
            return;
        }

        foreach ($condition as $part) {
            $this->validateCondition($part);
        }
    }

    /**
     * Validates block condition that consists of a single condition.
     * This covers such operators as `not`.
     * @param string $operator raw operator control keyword.
     * @param mixed $condition raw condition.
     */
    protected function validateBlockCondition($operator, $condition)
    {
        $this->validateCondition($condition);
    }

    /**
     * Validates search condition for a particular attribute.
     * @param string $attribute search attribute name.
     * @param mixed $condition search condition.
     */
    protected function validateAttributeCondition($attribute, $condition)
    {
        $attributeTypes = $this->getSearchAttributeTypes();
        if (!isset($attributeTypes[$attribute])) {
            $this->addError($this->filterAttributeName, $this->parseErrorMessage('unknownAttribute', ['attribute' => $attribute]));
            return;
        }

        if (is_array($condition)) {
            $operatorCount = 0;
            foreach ($condition as $rawOperator => $value) {
                if (isset($this->filterControls[$rawOperator])) {
                    $operator = $this->filterControls[$rawOperator];
                    if (isset($this->operatorTypes[$operator])) {
                        $operatorCount++;
                        $this->validateOperatorCondition($rawOperator, $value, $attribute);
                    }
                }
            }

            if ($operatorCount > 0) {
                if ($operatorCount < count($condition)) {
                    $this->addError($this->filterAttributeName, $this->parseErrorMessage('invalidAttributeValueFormat', ['attribute' => $attribute]));
                }
            } else {
                // attribute may allow array value:
                $this->validateAttributeValue($attribute, $condition);
            }
        } else {
            $this->validateAttributeValue($attribute, $condition);
        }
    }

    /**
     * Validates operator condition.
     * @param string $operator raw operator control keyword.
     * @param mixed $condition attribute condition.
     * @param string|null $attribute attribute name.
     */
    protected function validateOperatorCondition($operator, $condition, $attribute = null)
    {
        if ($attribute === null) {
            // absence of an attribute indicates that operator has been placed in a wrong position
            $this->addError($this->filterAttributeName, $this->parseErrorMessage('operatorRequireAttribute', ['operator' => $operator]));
            return;
        }

        $internalOperator = $this->filterControls[$operator];

        // check operator type :
        $operatorTypes = $this->operatorTypes[$internalOperator];
        if ($operatorTypes !== '*') {
            $attributeTypes = $this->getSearchAttributeTypes();
            $attributeType = $attributeTypes[$attribute];
            if (!in_array($attributeType, $operatorTypes, true)) {
                $this->addError($this->filterAttributeName, $this->parseErrorMessage('unsupportedOperatorType', ['attribute' => $attribute, 'operator' => $operator]));
                return;
            }
        }

        if (in_array($internalOperator, $this->multiValueOperators, true)) {
            // multi-value operator:
            if (!is_array($condition)) {
                $this->addError($this->filterAttributeName, $this->parseErrorMessage('operatorRequireMultipleOperands', ['operator' => $operator]));
            } else {
                foreach ($condition as $v) {
                    $this->validateAttributeValue($attribute, $v);
                }
            }
        } else {
            // single-value operator :
            $this->validateAttributeValue($attribute, $condition);
        }
    }

    /**
     * Validates attribute value in the scope of [[model]].
     * @param string $attribute attribute name.
     * @param mixed $value attribute value.
     */
    protected function validateAttributeValue($attribute, $value)
    {
        $model = $this->getSearchModel();
        if (!$model->isAttributeSafe($attribute)) {
            $this->addError($this->filterAttributeName, $this->parseErrorMessage('unknownAttribute', ['attribute' => $attribute]));
            return;
        }

        $model->{$attribute} = $value === $this->nullValue ? null : $value;
        if (!$model->validate([$attribute])) {
            $this->addError($this->filterAttributeName, $model->getFirstError($attribute));
            return;
        }
    }

    /**
     * Validates attribute value in the scope of [[searchModel]], applying attribute value filters if any.
     * @param string $attribute attribute name.
     * @param mixed $value attribute value.
     * @return mixed filtered attribute value.
     */
    protected function filterAttributeValue($attribute, $value)
    {
        $model = $this->getSearchModel();
        if (!$model->isAttributeSafe($attribute)) {
            $this->addError($this->filterAttributeName, $this->parseErrorMessage('unknownAttribute', ['attribute' => $attribute]));
            return $value;
        }
        $model->{$attribute} = $value;
        if (!$model->validate([$attribute])) {
            $this->addError($this->filterAttributeName, $model->getFirstError($attribute));
            return $value;
        }

        return $model->{$attribute};
    }

    // Build:

    /**
     * Builds actual filter specification form [[filter]] value.
     * @param bool $runValidation whether to perform validation (calling [[validate()]])
     * before building the filter. Defaults to `true`. If the validation fails, no filter will
     * be built and this method will return `false`.
     * @return mixed|false built actual filter value, or `false` if validation fails.
     */
    public function build($runValidation = true)
    {
        if ($runValidation && !$this->validate()) {
            return false;
        }
        return $this->buildInternal();
    }

    /**
     * Performs actual filter build.
     * By default this method returns result of [[normalize()]].
     * The child class may override this method providing more specific implementation.
     * @return mixed built actual filter value.
     */
    protected function buildInternal()
    {
        return $this->normalize(false);
    }

    /**
     * Normalizes filter value, replacing raw keys according to [[filterControls]] and [[attributeMap]].
     * @param bool $runValidation whether to perform validation (calling [[validate()]])
     * before normalizing the filter. Defaults to `true`. If the validation fails, no filter will
     * be processed and this method will return `false`.
     * @return array|bool normalized filter value, or `false` if validation fails.
     */
    public function normalize($runValidation = true)
    {
        if ($runValidation && !$this->validate()) {
            return false;
        }

        $filter = $this->getFilter();
        if (!is_array($filter) || empty($filter)) {
            return [];
        }

        return $this->normalizeComplexFilter($filter);
    }

    /**
     * Normalizes complex filter recursively.
     * @param array $filter raw filter.
     * @return array normalized filter.
     */
    private function normalizeComplexFilter(array $filter)
    {
        $result = [];
        foreach ($filter as $key => $value) {
            if (isset($this->filterControls[$key])) {
                $key = $this->filterControls[$key];
            } elseif (isset($this->attributeMap[$key])) {
                $key = $this->attributeMap[$key];
            }
            if (is_array($value)) {
                $result[$key] = $this->normalizeComplexFilter($value);
            } elseif ($value === $this->nullValue) {
                $result[$key] = null;
            } else {
                $result[$key] = $value;
            }
        }
        return $result;
    }

    // Property access:

    /**
     * {@inheritdoc}
     */
    public function canGetProperty($name, $checkVars = true, $checkBehaviors = true)
    {
        if ($name === $this->filterAttributeName) {
            return true;
        }
        return parent::canGetProperty($name, $checkVars, $checkBehaviors);
    }

    /**
     * {@inheritdoc}
     */
    public function canSetProperty($name, $checkVars = true, $checkBehaviors = true)
    {
        if ($name === $this->filterAttributeName) {
            return true;
        }
        return parent::canSetProperty($name, $checkVars, $checkBehaviors);
    }

    /**
     * {@inheritdoc}
     */
    public function __get($name)
    {
        if ($name === $this->filterAttributeName) {
            return $this->getFilter();
        }

        return parent::__get($name);
    }

    /**
     * {@inheritdoc}
     */
    public function __set($name, $value)
    {
        if ($name === $this->filterAttributeName) {
            $this->setFilter($value);
        } else {
            parent::__set($name, $value);
        }
    }

    /**
     * {@inheritdoc}
     */
    public function __isset($name)
    {
        if ($name === $this->filterAttributeName) {
            return $this->getFilter() !== null;
        }

        return parent::__isset($name);
    }

    /**
     * {@inheritdoc}
     */
    public function __unset($name)
    {
        if ($name === $this->filterAttributeName) {
            $this->setFilter(null);
        } else {
            parent::__unset($name);
        }
    }
}