Completed
Push — 2.1 ( b0b06b...3e2500 )
by Alexander
13:41
created

UniqueValidator::validateAttribute()   D

Complexity

Conditions 10
Paths 52

Size

Total Lines 36
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 10

Importance

Changes 0
Metric Value
dl 0
loc 36
ccs 22
cts 22
cp 1
rs 4.8196
c 0
b 0
f 0
cc 10
eloc 22
nc 52
nop 2
crap 10

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Model;
12
use yii\db\ActiveQuery;
13
use yii\db\ActiveQueryInterface;
14
use yii\db\ActiveRecord;
15
use yii\db\ActiveRecordInterface;
16
use yii\helpers\Inflector;
17
18
/**
19
 * UniqueValidator validates that the attribute value is unique in the specified database table.
20
 *
21
 * UniqueValidator checks if the value being validated is unique in the table column specified by
22
 * the ActiveRecord class [[targetClass]] and the attribute [[targetAttribute]].
23
 *
24
 * The following are examples of validation rules using this validator:
25
 *
26
 * ```php
27
 * // a1 needs to be unique
28
 * ['a1', 'unique']
29
 * // a1 needs to be unique, but column a2 will be used to check the uniqueness of the a1 value
30
 * ['a1', 'unique', 'targetAttribute' => 'a2']
31
 * // a1 and a2 need to be unique together, and they both will receive error message
32
 * [['a1', 'a2'], 'unique', 'targetAttribute' => ['a1', 'a2']]
33
 * // a1 and a2 need to be unique together, only a1 will receive error message
34
 * ['a1', 'unique', 'targetAttribute' => ['a1', 'a2']]
35
 * // a1 needs to be unique by checking the uniqueness of both a2 and a3 (using a1 value)
36
 * ['a1', 'unique', 'targetAttribute' => ['a2', 'a1' => 'a3']]
37
 * ```
38
 *
39
 * @author Qiang Xue <[email protected]>
40
 * @since 2.0
41
 */
42
class UniqueValidator extends Validator
43
{
44
    /**
45
     * @var string the name of the ActiveRecord class that should be used to validate the uniqueness
46
     * of the current attribute value.
47
     * This must be a fully qualified class name.
48
     *
49
     * If not set, it will use the ActiveRecord class of the attribute being validated.
50
     *
51
     * @see targetAttribute
52
     */
53
    public $targetClass;
54
    /**
55
     * @var string|array the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that should be used to
56
     * validate the uniqueness of the current attribute value. If not set, it will use the name
57
     * of the attribute currently being validated. You may use an array to validate the uniqueness
58
     * of multiple columns at the same time. The array values are the attributes that will be
59
     * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated.
60
     */
61
    public $targetAttribute;
62
    /**
63
     * @var string|array|\Closure additional filter to be applied to the DB query used to check the uniqueness of the attribute value.
64
     * This can be a string or an array representing the additional query condition (refer to [[\yii\db\Query::where()]]
65
     * on the format of query condition), or an anonymous function with the signature `function ($query)`, where `$query`
66
     * is the [[\yii\db\Query|Query]] object that you can modify in the function.
67
     */
68
    public $filter;
69
    /**
70
     * @var string the user-defined error message.
71
     *
72
     * When validating single attribute, it may contain
73
     * the following placeholders which will be replaced accordingly by the validator:
74
     *
75
     * - `{attribute}`: the label of the attribute being validated
76
     * - `{value}`: the value of the attribute being validated
77
     *
78
     * When validating mutliple attributes, it may contain the following placeholders:
79
     *
80
     * - `{attributes}`: the labels of the attributes being validated.
81
     * - `{values}`: the values of the attributes being validated.
82
     */
83
    public $message;
84
    /**
85
     * @var string and|or define how target attributes are related
86
     * @since 2.0.11
87
     */
88
    public $targetAttributeJunction = 'and';
89
    /**
90
     * @var bool whether this validator is forced to always use master DB
91
     * @since 2.0.14
92
     */
93
    public $forceMasterDb =  true;
94
95
96
    /**
97
     * {@inheritdoc}
98
     */
99 67
    public function init()
100
    {
101 67
        parent::init();
102 67
        if ($this->message !== null) {
103 3
            return;
104
        }
105 64
        if (is_array($this->targetAttribute) && count($this->targetAttribute) > 1) {
106 9
            $this->message = Yii::t('yii', 'The combination {values} of {attributes} has already been taken.');
107
        } else {
108 58
            $this->message = Yii::t('yii', '{attribute} "{value}" has already been taken.');
109
        }
110 64
    }
111
112
    /**
113
     * {@inheritdoc}
114
     */
115 52
    public function validateAttribute($model, $attribute)
116
    {
117
        /* @var $targetClass ActiveRecordInterface */
118 52
        $targetClass = $this->getTargetClass($model);
119 52
        $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
120 52
        $rawConditions = $this->prepareConditions($targetAttribute, $model, $attribute);
121 52
        $conditions = [$this->targetAttributeJunction === 'or' ? 'or' : 'and'];
122
123 52
        foreach ($rawConditions as $key => $value) {
124 52
            if (is_array($value)) {
125 6
                $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
126 6
                return;
127
            }
128 49
            $conditions[] = [$key => $value];
129
        }
130
131 49
        $db = $targetClass::getDb();
132
133 49
        $modelExists = false;
134
135 49
        if ($this->forceMasterDb && method_exists($db, 'useMaster')) {
136 49
            $db->useMaster(function () use ($targetClass, $conditions, $model, &$modelExists) {
137 49
                $modelExists = $this->modelExists($targetClass, $conditions, $model);
0 ignored issues
show
Documentation introduced by
$targetClass is of type object<yii\db\ActiveRecordInterface>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
138 49
            });
139
        } else {
140 3
            $modelExists = $this->modelExists($targetClass, $conditions, $model);
0 ignored issues
show
Documentation introduced by
$targetClass is of type object<yii\db\ActiveRecordInterface>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
141
        }
142
143 46
        if ($modelExists) {
144 29
            if (is_array($targetAttribute) && count($targetAttribute) > 1) {
145 6
                $this->addComboNotUniqueError($model, $attribute);
146
            } else {
147 29
                $this->addError($model, $attribute, $this->message);
148
            }
149
        }
150 46
    }
151
152
    /**
153
     * @param Model $model the data model to be validated
154
     * @return string Target class name
155
     */
156 61
    private function getTargetClass($model)
157
    {
158 61
        return $this->targetClass === null ? get_class($model) : $this->targetClass;
159
    }
160
161
    /**
162
     * Checks whether the $model exists in the database.
163
     *
164
     * @param string $targetClass the name of the ActiveRecord class that should be used to validate the uniqueness
165
     * of the current attribute value.
166
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
167
     * @param Model $model the data model to be validated
168
     *
169
     * @return bool whether the model already exists
170
     */
171 49
    private function modelExists($targetClass, $conditions, $model)
172
    {
173
        /** @var ActiveRecordInterface $targetClass $query */
174 49
        $query = $this->prepareQuery($targetClass, $conditions);
175
176 49
        if (!$model instanceof ActiveRecordInterface
177 46
            || $model->getIsNewRecord()
178 49
            || get_class($model) !== $targetClass
179
        ) {
180
            // if current $model isn't in the database yet then it's OK just to call exists()
181
            // also there's no need to run check based on primary keys, when $targetClass is not the same as $model's class
182 34
            $exists = $query->exists();
183
        } else {
184
            // if current $model is in the database already we can't use exists()
185 26
            if ($query instanceof \yii\db\ActiveQuery) {
186
                // only select primary key to optimize query
187 26
                $columnsCondition = array_flip($targetClass::primaryKey());
188 26
                $query->select(array_flip($this->applyTableAlias($query, $columnsCondition)));
189
                
190
                // any with relation can't be loaded because related fields are not selected
191 26
                $query->with = null;
0 ignored issues
show
Documentation Bug introduced by
It seems like null of type null is incompatible with the declared type array of property $with.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
192
            }
193 26
            $models = $query->limit(2)->asArray()->all();
194 26
            $n = count($models);
195 26
            if ($n === 1) {
196
                // if there is one record, check if it is the currently validated model
197 23
                $dbModel = reset($models);
198 23
                $pks = $targetClass::primaryKey();
199 23
                $pk = [];
200 23
                foreach ($pks as $pkAttribute) {
201 23
                    $pk[$pkAttribute] = $dbModel[$pkAttribute];
202
                }
203 23
                $exists = ($pk != $model->getOldPrimaryKey(true));
204
            } else {
205
                // if there is more than one record, the value is not unique
206 6
                $exists = $n > 1;
207
            }
208
        }
209
210 46
        return $exists;
211
    }
212
213
    /**
214
     * Prepares a query by applying filtering conditions defined in $conditions method property
215
     * and [[filter]] class property.
216
     *
217
     * @param ActiveRecordInterface $targetClass the name of the ActiveRecord class that should be used to validate
218
     * the uniqueness of the current attribute value.
219
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format
220
     *
221
     * @return ActiveQueryInterface|ActiveQuery
222
     */
223 52
    private function prepareQuery($targetClass, $conditions)
224
    {
225 52
        $query = $targetClass::find();
226 52
        $query->andWhere($conditions);
227 52
        if ($this->filter instanceof \Closure) {
228 6
            call_user_func($this->filter, $query);
229 49
        } elseif ($this->filter !== null) {
230 3
            $query->andWhere($this->filter);
231
        }
232
233 52
        return $query;
234
    }
235
236
    /**
237
     * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with
238
     * [[\yii\db\Query::where()|Query::where()]] key-value format.
239
     *
240
     * @param string|array $targetAttribute the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that
241
     * should be used to validate the uniqueness of the current attribute value. You may use an array to validate
242
     * the uniqueness of multiple columns at the same time. The array values are the attributes that will be
243
     * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated.
244
     * If the key and the value are the same, you can just specify the value.
245
     * @param Model $model the data model to be validated
246
     * @param string $attribute the name of the attribute to be validated in the $model
247
     *
248
     * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
249
     */
250 55
    private function prepareConditions($targetAttribute, $model, $attribute)
251
    {
252 55
        if (is_array($targetAttribute)) {
253 24
            $conditions = [];
254 24
            foreach ($targetAttribute as $k => $v) {
255 24
                $conditions[$v] = is_int($k) ? $model->$v : $model->$k;
256
            }
257
        } else {
258 40
            $conditions = [$targetAttribute => $model->$attribute];
259
        }
260
261 55
        $targetModelClass = $this->getTargetClass($model);
262 55
        if (!is_subclass_of($targetModelClass, 'yii\db\ActiveRecord')) {
263 6
            return $conditions;
264
        }
265
266
        /** @var ActiveRecord $targetModelClass */
267 55
        return $this->applyTableAlias($targetModelClass::find(), $conditions);
268
    }
269
270
    /**
271
     * Builds and adds error message to the specified model attribute.
272
     * Should be used when [[targetAttribute]] is an array (is a combination of attributes).
273
     * @param \yii\base\Model $model the data model.
274
     * @param string $attribute the name of the attribute.
275
     */
276 6
    private function addComboNotUniqueError($model, $attribute)
277
    {
278 6
        $attributeCombo = [];
279 6
        $valueCombo = [];
280 6
        foreach ($this->targetAttribute as $key => $value) {
0 ignored issues
show
Bug introduced by
The expression $this->targetAttribute of type string|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
281 6
            if (is_int($key)) {
282 6
                $attributeCombo[] = $model->getAttributeLabel($value);
283 6
                $valueCombo[] = '"' . $model->$value . '"';
284
            } else {
285
                $attributeCombo[] = $model->getAttributeLabel($key);
286 6
                $valueCombo[] = '"' . $model->$key . '"';
287
            }
288
        }
289 6
        $this->addError($model, $attribute, $this->message, [
290 6
            'attributes' => Inflector::sentence($attributeCombo),
291 6
            'values' => implode('-', $valueCombo),
292
        ]);
293 6
    }
294
295
    /**
296
     * Returns conditions with alias.
297
     * @param ActiveQuery $query
298
     * @param array $conditions array of condition, keys to be modified
299
     * @param null|string $alias set empty string for no apply alias. Set null for apply primary table alias
300
     * @return array
301
     */
302 55
    private function applyTableAlias($query, $conditions, $alias = null)
303
    {
304 55
        if ($alias === null) {
305 55
            $alias = array_keys($query->getTablesUsedInFrom())[0];
306
        }
307 55
        $prefixedConditions = [];
308 55
        foreach ($conditions as $columnName => $columnValue) {
309 55
            if (strpos($columnName, '(') === false) {
310 55
                $prefixedColumn = "{$alias}.[[" . preg_replace(
311 55
                    '/^' . preg_quote($alias) . '\.(.*)$/',
312 55
                    '$1',
313 55
                    $columnName) . ']]';
314
            } else {
315
                // there is an expression, can't prefix it reliably
316 3
                $prefixedColumn = $columnName;
317
            }
318
319 55
            $prefixedConditions[$prefixedColumn] = $columnValue;
320
        }
321
322 55
        return $prefixedConditions;
323
    }
324
}
325