Passed
Push — master ( bba380...009961 )
by Alexander
08:54 queued 11s
created

ExistValidator   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 294
Duplicated Lines 0 %

Test Coverage

Coverage 94.06%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 99
dl 0
loc 294
ccs 95
cts 101
cp 0.9406
rs 8.64
c 2
b 0
f 0
wmc 47

11 Methods

Rating   Name   Duplication   Size   Complexity  
A validateAttribute() 0 6 2
A init() 0 5 2
A checkTargetRelationExistence() 0 23 6
A validateValue() 0 16 6
A getTargetClass() 0 3 2
A queryValueExists() 0 6 2
A createQuery() 0 11 3
B checkTargetAttributeExistence() 0 32 11
A applyTableAlias() 0 21 4
A valueExists() 0 14 3
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|null 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|null 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 31
    public function init()
98
    {
99 31
        parent::init();
100 31
        if ($this->message === null) {
101 31
            $this->message = Yii::t('yii', '{attribute} is invalid.');
102
        }
103 31
    }
104
105
    /**
106
     * {@inheritdoc}
107
     */
108 25
    public function validateAttribute($model, $attribute)
109
    {
110 25
        if (!empty($this->targetRelation)) {
111 9
            $this->checkTargetRelationExistence($model, $attribute);
112
        } else {
113 16
            $this->checkTargetAttributeExistence($model, $attribute);
114
        }
115 25
    }
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
        $connection = $model::getDb();
135 9
        if ($this->forceMasterDb && method_exists($connection, 'useMaster')) {
136
            $exists = $connection->useMaster(function() use ($relationQuery) {
137 9
                return $relationQuery->exists();
138 9
            });
139
        } else {
140 3
            $exists = $relationQuery->exists();
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 16
    private function checkTargetAttributeExistence($model, $attribute)
154
    {
155 16
        $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
156 16
        if ($this->skipOnError) {
157 16
            foreach ((array)$targetAttribute as $k => $v) {
158 16
                if ($model->hasErrors(is_int($k) ? $v : $k)) {
159 3
                    return;
160
                }
161
            }
162
        }
163
164 16
        $params = $this->prepareConditions($targetAttribute, $model, $attribute);
165 16
        $conditions = [$this->targetAttributeJunction == 'or' ? 'or' : 'and'];
166
167 16
        if (!$this->allowArray) {
168 16
            foreach ($params as $key => $value) {
169 16
                if (is_array($value)) {
170 3
                    $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
171
172 3
                    return;
173
                }
174 16
                $conditions[] = [$key => $value];
175
            }
176
        } else {
177 3
            $conditions[] = $params;
178
        }
179
180 16
        $targetClass = $this->getTargetClass($model);
181 16
        $query = $this->createQuery($targetClass, $conditions);
182
183 16
        if (!$this->valueExists($targetClass, $query, $model->$attribute)) {
184 9
            $this->addError($model, $attribute, $this->message);
185
        }
186 16
    }
187
188
    /**
189
     * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with
190
     * [[\yii\db\Query::where()|Query::where()]] key-value format.
191
     *
192
     * @param $targetAttribute array|string|null $attribute the name of the ActiveRecord attribute that should be used to
193
     * validate the existence of the current attribute value. If not set, it will use the name
194
     * of the attribute currently being validated. You may use an array to validate the existence
195
     * of multiple columns at the same time. The array key is the name of the attribute with the value to validate,
196
     * the array value is the name of the database field to search.
197
     * If the key and the value are the same, you can just specify the value.
198
     * @param \yii\base\Model $model the data model to be validated
199
     * @param string $attribute the name of the attribute to be validated in the $model
200
     * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
201
     * @throws InvalidConfigException
202
     */
203 16
    private function prepareConditions($targetAttribute, $model, $attribute)
204
    {
205 16
        if (is_array($targetAttribute)) {
206 12
            if ($this->allowArray) {
207
                throw new InvalidConfigException('The "targetAttribute" property must be configured as a string.');
208
            }
209 12
            $conditions = [];
210 12
            foreach ($targetAttribute as $k => $v) {
211 12
                $conditions[$v] = is_int($k) ? $model->$v : $model->$k;
212
            }
213
        } else {
214 4
            $conditions = [$targetAttribute => $model->$attribute];
215
        }
216
217 16
        $targetModelClass = $this->getTargetClass($model);
218 16
        if (!is_subclass_of($targetModelClass, 'yii\db\ActiveRecord')) {
219
            return $conditions;
220
        }
221
222
        /** @var ActiveRecord $targetModelClass */
223 16
        return $this->applyTableAlias($targetModelClass::find(), $conditions);
224
    }
225
226
    /**
227
     * @param Model $model the data model to be validated
228
     * @return string Target class name
229
     */
230 16
    private function getTargetClass($model)
231
    {
232 16
        return $this->targetClass === null ? get_class($model) : $this->targetClass;
233
    }
234
235
    /**
236
     * {@inheritdoc}
237
     */
238 6
    protected function validateValue($value)
239
    {
240 6
        if ($this->targetClass === null) {
241 3
            throw new InvalidConfigException('The "targetClass" property must be set.');
242
        }
243 6
        if (!is_string($this->targetAttribute)) {
244 3
            throw new InvalidConfigException('The "targetAttribute" property must be configured as a string.');
245
        }
246
247 3
        if (is_array($value) && !$this->allowArray) {
248 3
            return [$this->message, []];
249
        }
250
251 3
        $query = $this->createQuery($this->targetClass, [$this->targetAttribute => $value]);
252
253 3
        return $this->valueExists($this->targetClass, $query, $value) ? null : [$this->message, []];
254
    }
255
256
    /**
257
     * Check whether value exists in target table.
258
     *
259
     * @param string $targetClass the model
260
     * @param QueryInterface $query
261
     * @param mixed $value the value want to be checked
262
     * @return bool
263
     */
264 19
    private function valueExists($targetClass, $query, $value)
265
    {
266 19
        $db = $targetClass::getDb();
267 19
        $exists = false;
268
269 19
        if ($this->forceMasterDb && method_exists($db, 'useMaster')) {
270 19
            $exists = $db->useMaster(function () use ($query, $value) {
271 19
                return $this->queryValueExists($query, $value);
272 19
            });
273
        } else {
274
            $exists = $this->queryValueExists($query, $value);
275
        }
276
277 19
        return $exists;
278
    }
279
280
281
    /**
282
     * Run query to check if value exists.
283
     *
284
     * @param QueryInterface $query
285
     * @param mixed $value the value to be checked
286
     * @return bool
287
     */
288 19
    private function queryValueExists($query, $value)
289
    {
290 19
        if (is_array($value)) {
291 3
            return $query->count("DISTINCT [[$this->targetAttribute]]") == count(array_unique($value));
292
        }
293 19
        return $query->exists();
294
    }
295
296
    /**
297
     * Creates a query instance with the given condition.
298
     * @param string $targetClass the target AR class
299
     * @param mixed $condition query condition
300
     * @return \yii\db\ActiveQueryInterface the query instance
301
     */
302 19
    protected function createQuery($targetClass, $condition)
303
    {
304
        /* @var $targetClass \yii\db\ActiveRecordInterface */
305 19
        $query = $targetClass::find()->andWhere($condition);
306 19
        if ($this->filter instanceof \Closure) {
307
            call_user_func($this->filter, $query);
308 19
        } elseif ($this->filter !== null) {
309
            $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

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