Passed
Push — master ( 385fe1...5adb55 )
by Alexander
10:01
created

ExistValidator   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 286
Duplicated Lines 0 %

Test Coverage

Coverage 93.75%

Importance

Changes 0
Metric Value
eloc 94
dl 0
loc 286
ccs 90
cts 96
cp 0.9375
rs 8.8798
c 0
b 0
f 0
wmc 44

11 Methods

Rating   Name   Duplication   Size   Complexity  
A validateValue() 0 16 6
A getTargetClass() 0 3 2
A validateAttribute() 0 6 2
A checkTargetRelationExistence() 0 23 6
A queryValueExists() 0 6 2
A createQuery() 0 11 3
B checkTargetAttributeExistence() 0 24 8
A applyTableAlias() 0 21 4
A valueExists() 0 14 3
A init() 0 5 2
A prepareConditions() 0 21 6

How to fix   Complexity   

Complex Class

Complex classes like ExistValidator often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ExistValidator, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://www.yiiframework.com/license/
6
 */
7
8
namespace yii\validators;
9
10
use Yii;
11
use yii\base\InvalidConfigException;
12
use yii\base\Model;
13
use yii\db\ActiveQuery;
14
use yii\db\ActiveRecord;
15
use yii\db\QueryInterface;
16
17
/**
18
 * ExistValidator validates that the attribute value exists in a table.
19
 *
20
 * ExistValidator checks if the value being validated can be found in the table column specified by
21
 * the ActiveRecord class [[targetClass]] and the attribute [[targetAttribute]].
22
 * Since version 2.0.14 you can use more convenient attribute [[targetRelation]]
23
 *
24
 * This validator is often used to verify that a foreign key contains a value
25
 * that can be found in the foreign table.
26
 *
27
 * The following are examples of validation rules using this validator:
28
 *
29
 * ```php
30
 * // a1 needs to exist
31
 * ['a1', 'exist']
32
 * // a1 needs to exist, but its value will use a2 to check for the existence
33
 * ['a1', 'exist', 'targetAttribute' => 'a2']
34
 * // a1 and a2 need to exist together, and they both will receive error message
35
 * [['a1', 'a2'], 'exist', 'targetAttribute' => ['a1', 'a2']]
36
 * // a1 and a2 need to exist together, only a1 will receive error message
37
 * ['a1', 'exist', 'targetAttribute' => ['a1', 'a2']]
38
 * // a1 needs to exist by checking the existence of both a2 and a3 (using a1 value)
39
 * ['a1', 'exist', 'targetAttribute' => ['a2', 'a1' => 'a3']]
40
 * // type_id needs to exist in the column "id" in the table defined in ProductType class
41
 * ['type_id', 'exist', 'targetClass' => ProductType::class, 'targetAttribute' => ['type_id' => 'id']],
42
 * // the same as the previous, but using already defined relation "type"
43
 * ['type_id', 'exist', 'targetRelation' => 'type'],
44
 * ```
45
 *
46
 * @author Qiang Xue <[email protected]>
47
 * @since 2.0
48
 */
49
class ExistValidator extends Validator
50
{
51
    /**
52
     * @var string the name of the ActiveRecord class that should be used to validate the existence
53
     * of the current attribute value. If not set, it will use the ActiveRecord class of the attribute being validated.
54
     * @see targetAttribute
55
     */
56
    public $targetClass;
57
    /**
58
     * @var string|array the name of the ActiveRecord attribute that should be used to
59
     * validate the existence of the current attribute value. If not set, it will use the name
60
     * of the attribute currently being validated. You may use an array to validate the existence
61
     * of multiple columns at the same time. The array key is the name of the attribute with the value to validate,
62
     * the array value is the name of the database field to search.
63
     */
64
    public $targetAttribute;
65
    /**
66
     * @var string the name of the relation that should be used to validate the existence of the current attribute value
67
     * This param overwrites $targetClass and $targetAttribute
68
     * @since 2.0.14
69
     */
70
    public $targetRelation;
71
    /**
72
     * @var string|array|\Closure additional filter to be applied to the DB query used to check the existence of the attribute value.
73
     * This can be a string or an array representing the additional query condition (refer to [[\yii\db\Query::where()]]
74
     * on the format of query condition), or an anonymous function with the signature `function ($query)`, where `$query`
75
     * is the [[\yii\db\Query|Query]] object that you can modify in the function.
76
     */
77
    public $filter;
78
    /**
79
     * @var bool whether to allow array type attribute.
80
     */
81
    public $allowArray = false;
82
    /**
83
     * @var string and|or define how target attributes are related
84
     * @since 2.0.11
85
     */
86
    public $targetAttributeJunction = 'and';
87
    /**
88
     * @var bool whether this validator is forced to always use master DB
89
     * @since 2.0.14
90
     */
91
    public $forceMasterDb = true;
92
93
94
    /**
95
     * {@inheritdoc}
96
     */
97 28
    public function init()
98
    {
99 28
        parent::init();
100 28
        if ($this->message === null) {
101 28
            $this->message = Yii::t('yii', '{attribute} is invalid.');
102
        }
103 28
    }
104
105
    /**
106
     * {@inheritdoc}
107
     */
108 22
    public function validateAttribute($model, $attribute)
109
    {
110 22
        if (!empty($this->targetRelation)) {
111 9
            $this->checkTargetRelationExistence($model, $attribute);
112
        } else {
113 13
            $this->checkTargetAttributeExistence($model, $attribute);
114
        }
115 22
    }
116
117
    /**
118
     * Validates existence of the current attribute based on relation name
119
     * @param \yii\db\ActiveRecord $model the data model to be validated
120
     * @param string $attribute the name of the attribute to be validated.
121
     */
122 9
    private function checkTargetRelationExistence($model, $attribute)
123
    {
124 9
        $exists = false;
125
        /** @var ActiveQuery $relationQuery */
126 9
        $relationQuery = $model->{'get' . ucfirst($this->targetRelation)}();
127
128 9
        if ($this->filter instanceof \Closure) {
129 3
            call_user_func($this->filter, $relationQuery);
130 6
        } elseif ($this->filter !== null) {
131
            $relationQuery->andWhere($this->filter);
132
        }
133
134 9
        if ($this->forceMasterDb && method_exists($model::getDb(), 'useMaster')) {
135
            $model::getDb()->useMaster(function() use ($relationQuery, &$exists) {
136 9
                $exists = $relationQuery->exists();
137 9
            });
138
        } else {
139 3
            $exists = $relationQuery->exists();
140
        }
141
142
143 9
        if (!$exists) {
144 6
            $this->addError($model, $attribute, $this->message);
145
        }
146 9
    }
147
148
    /**
149
     * Validates existence of the current attribute based on targetAttribute
150
     * @param \yii\base\Model $model the data model to be validated
151
     * @param string $attribute the name of the attribute to be validated.
152
     */
153 13
    private function checkTargetAttributeExistence($model, $attribute)
154
    {
155 13
        $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
156 13
        $params = $this->prepareConditions($targetAttribute, $model, $attribute);
157 13
        $conditions = [$this->targetAttributeJunction == 'or' ? 'or' : 'and'];
158
159 13
        if (!$this->allowArray) {
160 13
            foreach ($params as $key => $value) {
161 13
                if (is_array($value)) {
162 3
                    $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
163
164 3
                    return;
165
                }
166 13
                $conditions[] = [$key => $value];
167
            }
168
        } else {
169 3
            $conditions[] = $params;
170
        }
171
172 13
        $targetClass = $this->targetClass === null ? get_class($model) : $this->targetClass;
173 13
        $query = $this->createQuery($targetClass, $conditions);
174
175 13
        if (!$this->valueExists($targetClass, $query, $model->$attribute)) {
176 6
            $this->addError($model, $attribute, $this->message);
177
        }
178 13
    }
179
180
    /**
181
     * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with
182
     * [[\yii\db\Query::where()|Query::where()]] key-value format.
183
     *
184
     * @param $targetAttribute array|string $attribute the name of the ActiveRecord attribute that should be used to
185
     * validate the existence of the current attribute value. If not set, it will use the name
186
     * of the attribute currently being validated. You may use an array to validate the existence
187
     * of multiple columns at the same time. The array key is the name of the attribute with the value to validate,
188
     * the array value is the name of the database field to search.
189
     * If the key and the value are the same, you can just specify the value.
190
     * @param \yii\base\Model $model the data model to be validated
191
     * @param string $attribute the name of the attribute to be validated in the $model
192
     * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
193
     * @throws InvalidConfigException
194
     */
195 13
    private function prepareConditions($targetAttribute, $model, $attribute)
196
    {
197 13
        if (is_array($targetAttribute)) {
198 9
            if ($this->allowArray) {
199
                throw new InvalidConfigException('The "targetAttribute" property must be configured as a string.');
200
            }
201 9
            $conditions = [];
202 9
            foreach ($targetAttribute as $k => $v) {
203 9
                $conditions[$v] = is_int($k) ? $model->$v : $model->$k;
204
            }
205
        } else {
206 4
            $conditions = [$targetAttribute => $model->$attribute];
207
        }
208
209 13
        $targetModelClass = $this->getTargetClass($model);
210 13
        if (!is_subclass_of($targetModelClass, 'yii\db\ActiveRecord')) {
211
            return $conditions;
212
        }
213
214
        /** @var ActiveRecord $targetModelClass */
215 13
        return $this->applyTableAlias($targetModelClass::find(), $conditions);
216
    }
217
218
    /**
219
     * @param Model $model the data model to be validated
220
     * @return string Target class name
221
     */
222 13
    private function getTargetClass($model)
223
    {
224 13
        return $this->targetClass === null ? get_class($model) : $this->targetClass;
225
    }
226
227
    /**
228
     * {@inheritdoc}
229
     */
230 6
    protected function validateValue($value)
231
    {
232 6
        if ($this->targetClass === null) {
233 3
            throw new InvalidConfigException('The "targetClass" property must be set.');
234
        }
235 6
        if (!is_string($this->targetAttribute)) {
236 3
            throw new InvalidConfigException('The "targetAttribute" property must be configured as a string.');
237
        }
238
239 3
        if (is_array($value) && !$this->allowArray) {
240 3
            return [$this->message, []];
241
        }
242
243 3
        $query = $this->createQuery($this->targetClass, [$this->targetAttribute => $value]);
244
245 3
        return $this->valueExists($this->targetClass, $query, $value) ? null : [$this->message, []];
246
    }
247
248
    /**
249
     * Check whether value exists in target table
250
     *
251
     * @param string $targetClass
252
     * @param QueryInterface $query
253
     * @param mixed $value the value want to be checked
254
     * @return bool
255
     */
256 16
    private function valueExists($targetClass, $query, $value)
257
    {
258 16
        $db = $targetClass::getDb();
259 16
        $exists = false;
260
261 16
        if ($this->forceMasterDb && method_exists($db, 'useMaster')) {
262 16
            $db->useMaster(function ($db) use ($query, $value, &$exists) {
263 16
                $exists = $this->queryValueExists($query, $value);
264 16
            });
265
        } else {
266
            $exists = $this->queryValueExists($query, $value);
267
        }
268
269 16
        return $exists;
270
    }
271
272
273
    /**
274
     * Run query to check if value exists
275
     *
276
     * @param QueryInterface $query
277
     * @param mixed $value the value to be checked
278
     * @return bool
279
     */
280 16
    private function queryValueExists($query, $value)
281
    {
282 16
        if (is_array($value)) {
283 3
            return $query->count("DISTINCT [[$this->targetAttribute]]") == count($value) ;
284
        }
285 16
        return $query->exists();
286
    }
287
288
    /**
289
     * Creates a query instance with the given condition.
290
     * @param string $targetClass the target AR class
291
     * @param mixed $condition query condition
292
     * @return \yii\db\ActiveQueryInterface the query instance
293
     */
294 16
    protected function createQuery($targetClass, $condition)
295
    {
296
        /* @var $targetClass \yii\db\ActiveRecordInterface */
297 16
        $query = $targetClass::find()->andWhere($condition);
298 16
        if ($this->filter instanceof \Closure) {
299
            call_user_func($this->filter, $query);
300 16
        } elseif ($this->filter !== null) {
301
            $query->andWhere($this->filter);
0 ignored issues
show
Bug introduced by
It seems like $this->filter can also be of type string; however, parameter $condition of yii\db\QueryInterface::andWhere() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

301
            $query->andWhere(/** @scrutinizer ignore-type */ $this->filter);
Loading history...
302
        }
303
304 16
        return $query;
305
    }
306
307
    /**
308
     * Returns conditions with alias.
309
     * @param ActiveQuery $query
310
     * @param array $conditions array of condition, keys to be modified
311
     * @param null|string $alias set empty string for no apply alias. Set null for apply primary table alias
312
     * @return array
313
     */
314 13
    private function applyTableAlias($query, $conditions, $alias = null)
315
    {
316 13
        if ($alias === null) {
317 13
            $alias = array_keys($query->getTablesUsedInFrom())[0];
318
        }
319 13
        $prefixedConditions = [];
320 13
        foreach ($conditions as $columnName => $columnValue) {
321 13
            if (strpos($columnName, '(') === false) {
322 10
                $prefixedColumn = "{$alias}.[[" . preg_replace(
323 10
                    '/^' . preg_quote($alias) . '\.(.*)$/',
324 10
                    '$1',
325 10
                    $columnName) . ']]';
326
            } else {
327
                // there is an expression, can't prefix it reliably
328 3
                $prefixedColumn = $columnName;
329
            }
330
331 13
            $prefixedConditions[$prefixedColumn] = $columnValue;
332
        }
333
334 13
        return $prefixedConditions;
335
    }
336
}
337