Passed
Pull Request — master (#19417)
by Fedonyuk
08:08
created

UniqueValidator::modelExists()   B

Complexity

Conditions 9
Paths 7

Size

Total Lines 46
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 9

Importance

Changes 0
Metric Value
cc 9
eloc 24
nc 7
nop 3
dl 0
loc 46
ccs 23
cts 23
cp 1
crap 9
rs 8.0555
c 0
b 0
f 0
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|null the name of the ActiveRecord class that should be used to validate the uniqueness
46
     * of the current attribute value. If not set, it will use the ActiveRecord class of the attribute being validated.
47
     * @see targetAttribute
48
     */
49
    public $targetClass;
50
    /**
51
     * @var string|array|null the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that should be used to
52
     * validate the uniqueness of the current attribute value. If not set, it will use the name
53
     * of the attribute currently being validated. You may use an array to validate the uniqueness
54
     * of multiple columns at the same time. The array values are the attributes that will be
55
     * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated.
56
     */
57
    public $targetAttribute;
58
    /**
59
     * @var string|array|\Closure additional filter to be applied to the DB query used to check the uniqueness of the attribute value.
60
     * This can be a string or an array representing the additional query condition (refer to [[\yii\db\Query::where()]]
61
     * on the format of query condition), or an anonymous function with the signature `function ($query)`, where `$query`
62
     * is the [[\yii\db\Query|Query]] object that you can modify in the function.
63
     */
64
    public $filter;
65
    /**
66
     * @var string the user-defined error message.
67
     *
68
     * When validating single attribute, it may contain
69
     * the following placeholders which will be replaced accordingly by the validator:
70
     *
71
     * - `{attribute}`: the label of the attribute being validated
72
     * - `{value}`: the value of the attribute being validated
73
     *
74
     * When validating mutliple attributes, it may contain the following placeholders:
75
     *
76
     * - `{attributes}`: the labels of the attributes being validated.
77
     * - `{values}`: the values of the attributes being validated.
78
     */
79
    public $message;
80
    /**
81
     * @var string
82
     * @since 2.0.9
83
     * @deprecated since version 2.0.10, to be removed in 2.1. Use [[message]] property
84
     * to setup custom message for multiple target attributes.
85
     */
86
    public $comboNotUnique;
87
    /**
88
     * @var string and|or define how target attributes are related
89
     * @since 2.0.11
90
     */
91
    public $targetAttributeJunction = 'and';
92
    /**
93
     * @var bool whether this validator is forced to always use master DB
94
     * @since 2.0.14
95
     */
96
    public $forceMasterDb =  true;
97
98
    /**
99
     * @var bool
100
     */
101
    private $_isCombo = false;
102
103
    /**
104
     * {@inheritdoc}
105
     */
106 70
    public function init()
107
    {
108 70
        parent::init();
109
110 70
        $this->_isCombo = is_array($this->targetAttribute) && count($this->targetAttribute) > 1;
111
112 70
        if ($this->message !== null) {
113 3
            return;
114
        }
115
116 70
        if ($this->_isCombo) {
117
            // fallback for deprecated `comboNotUnique` property - use it as message if is set
118 15
            if ($this->comboNotUnique === null) {
0 ignored issues
show
Deprecated Code introduced by
The property yii\validators\UniqueValidator::$comboNotUnique has been deprecated: since version 2.0.10, to be removed in 2.1. Use [[message]] property to setup custom message for multiple target attributes. ( Ignorable by Annotation )

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

118
            if (/** @scrutinizer ignore-deprecated */ $this->comboNotUnique === null) {

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
119 12
                $this->message = Yii::t('yii', 'The combination {values} of {attributes} has already been taken.');
120
            } else {
121 15
                $this->message = $this->comboNotUnique;
0 ignored issues
show
Deprecated Code introduced by
The property yii\validators\UniqueValidator::$comboNotUnique has been deprecated: since version 2.0.10, to be removed in 2.1. Use [[message]] property to setup custom message for multiple target attributes. ( Ignorable by Annotation )

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

121
                $this->message = /** @scrutinizer ignore-deprecated */ $this->comboNotUnique;

This property has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the property will be removed from the class and what other property to use instead.

Loading history...
122
            }
123
        } else {
124 58
            $this->message = Yii::t('yii', '{attribute} "{value}" has already been taken.');
125
        }
126 70
    }
127
128
    /**
129
     * {@inheritdoc}
130
     */
131
    public function validateAttributes($model, $attributes = null)
132
    {
133
        if (
134
            !$this->skipOnError
135
            || !$this->_isCombo
136
            || !is_array($attributes)
137
            || count($attributes) < 2
138
            || array_diff($attributes, $this->targetAttribute) !== []
0 ignored issues
show
Bug introduced by
It seems like $this->targetAttribute can also be of type null and string; however, parameter $excludes of array_diff() 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

138
            || array_diff($attributes, /** @scrutinizer ignore-type */ $this->targetAttribute) !== []
Loading history...
139
        ) {
140
            parent::validateAttributes($model, $attributes);
141
            return;
142
        }
143
144
        $attributes = $this->getValidationAttributes($attributes);
145
        // if any attribute skips validation, other attributes also skip it
146
        foreach ($attributes as $attribute) {
147
            $skip = ($this->skipOnError && $model->hasErrors($attribute))
148
                || ($this->skipOnEmpty && $this->isEmpty($model->$attribute));
149
            if ($skip) {
150
                return;
151
            }
152
        }
153
154
        foreach ($attributes as $attribute) {
155
            if ($this->when === null || call_user_func($this->when, $model, $attribute)) {
156
                $this->validateAttribute($model, $attribute);
157
            }
158
        }
159
    }
160
161
    /**
162
     * {@inheritdoc}
163
     */
164 55
    public function validateAttribute($model, $attribute)
165
    {
166
        /* @var $targetClass ActiveRecordInterface */
167 55
        $targetClass = $this->getTargetClass($model);
168 55
        $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
169 55
        $rawConditions = $this->prepareConditions($targetAttribute, $model, $attribute);
170 55
        $conditions = [$this->targetAttributeJunction === 'or' ? 'or' : 'and'];
171
172 55
        foreach ($rawConditions as $key => $value) {
173 55
            if (is_array($value)) {
174 6
                $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
175 6
                return;
176
            }
177 52
            $conditions[] = [$key => $value];
178
        }
179
180 52
        $db = $targetClass::getDb();
181
182 52
        $modelExists = false;
183
184 52
        if ($this->forceMasterDb && method_exists($db, 'useMaster')) {
185 52
            $db->useMaster(function () use ($targetClass, $conditions, $model, &$modelExists) {
186 52
                $modelExists = $this->modelExists($targetClass, $conditions, $model);
0 ignored issues
show
Bug introduced by
$targetClass of type yii\db\ActiveRecordInterface is incompatible with the type string expected by parameter $targetClass of yii\validators\UniqueValidator::modelExists(). ( Ignorable by Annotation )

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

186
                $modelExists = $this->modelExists(/** @scrutinizer ignore-type */ $targetClass, $conditions, $model);
Loading history...
187 52
            });
188
        } else {
189 3
            $modelExists = $this->modelExists($targetClass, $conditions, $model);
0 ignored issues
show
Bug introduced by
$targetClass of type yii\db\ActiveRecordInterface is incompatible with the type string expected by parameter $targetClass of yii\validators\UniqueValidator::modelExists(). ( Ignorable by Annotation )

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

189
            $modelExists = $this->modelExists(/** @scrutinizer ignore-type */ $targetClass, $conditions, $model);
Loading history...
190
        }
191
192 49
        if ($modelExists) {
193 29
            if (is_array($targetAttribute) && count($targetAttribute) > 1) {
194 6
                $this->addComboNotUniqueError($model, $attribute);
195
            } else {
196 29
                $this->addError($model, $attribute, $this->message);
197
            }
198
        }
199 49
    }
200
201
    /**
202
     * @param Model $model the data model to be validated
203
     * @return string Target class name
204
     */
205 64
    private function getTargetClass($model)
206
    {
207 64
        return $this->targetClass === null ? get_class($model) : $this->targetClass;
208
    }
209
210
    /**
211
     * Checks whether the $model exists in the database.
212
     *
213
     * @param string $targetClass the name of the ActiveRecord class that should be used to validate the uniqueness
214
     * of the current attribute value.
215
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
216
     * @param Model $model the data model to be validated
217
     *
218
     * @return bool whether the model already exists
219
     */
220 52
    private function modelExists($targetClass, $conditions, $model)
221
    {
222
        /** @var ActiveRecordInterface|\yii\base\BaseObject $targetClass $query */
223 52
        $query = $this->prepareQuery($targetClass, $conditions);
0 ignored issues
show
Bug introduced by
It seems like $targetClass can also be of type yii\base\BaseObject; however, parameter $targetClass of yii\validators\UniqueValidator::prepareQuery() does only seem to accept yii\db\ActiveRecordInterface, 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

223
        $query = $this->prepareQuery(/** @scrutinizer ignore-type */ $targetClass, $conditions);
Loading history...
224
225 52
        if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== $targetClass::className()) {
0 ignored issues
show
Bug introduced by
The method getIsNewRecord() does not exist on yii\base\Model. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

225
        if (!$model instanceof ActiveRecordInterface || $model->/** @scrutinizer ignore-call */ getIsNewRecord() || $model->className() !== $targetClass::className()) {
Loading history...
Deprecated Code introduced by
The function yii\base\BaseObject::className() has been deprecated: since 2.0.14. On PHP >=5.5, use `::class` instead. ( Ignorable by Annotation )

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

225
        if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== /** @scrutinizer ignore-deprecated */ $targetClass::className()) {

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
Bug introduced by
The method className() does not exist on yii\db\ActiveRecordInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to yii\db\ActiveRecordInterface. ( Ignorable by Annotation )

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

225
        if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== $targetClass::/** @scrutinizer ignore-call */ className()) {
Loading history...
226
            // if current $model isn't in the database yet then it's OK just to call exists()
227
            // also there's no need to run check based on primary keys, when $targetClass is not the same as $model's class
228 34
            $exists = $query->exists();
229
        } else {
230
            // if current $model is in the database already we can't use exists()
231 29
            if ($query instanceof \yii\db\ActiveQuery) {
232
                // only select primary key to optimize query
233 29
                $columnsCondition = array_flip($targetClass::primaryKey());
0 ignored issues
show
introduced by
The method primaryKey() does not exist on yii\base\BaseObject. Maybe you want to declare this class abstract? ( Ignorable by Annotation )

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

233
                $columnsCondition = array_flip($targetClass::/** @scrutinizer ignore-call */ primaryKey());
Loading history...
234 29
                $query->select(array_flip($this->applyTableAlias($query, $columnsCondition)));
235
236
                // any with relation can't be loaded because related fields are not selected
237 29
                $query->with = null;
238
239 29
                if (is_array($query->joinWith)) {
240
                    // any joinWiths need to have eagerLoading turned off to prevent related fields being loaded
241 6
                    foreach ($query->joinWith as &$joinWith) {
242
                        // \yii\db\ActiveQuery::joinWith adds eagerLoading at key 1
243 6
                        $joinWith[1] = false;
244
                    }
245 6
                    unset($joinWith);
246
                }
247
            }
248 29
            $models = $query->limit(2)->asArray()->all();
249 29
            $n = count($models);
250 29
            if ($n === 1) {
251
                // if there is one record, check if it is the currently validated model
252 26
                $dbModel = reset($models);
253 26
                $pks = $targetClass::primaryKey();
254 26
                $pk = [];
255 26
                foreach ($pks as $pkAttribute) {
256 26
                    $pk[$pkAttribute] = $dbModel[$pkAttribute];
257
                }
258 26
                $exists = ($pk != $model->getOldPrimaryKey(true));
0 ignored issues
show
Bug introduced by
The method getOldPrimaryKey() does not exist on yii\base\Model. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

258
                $exists = ($pk != $model->/** @scrutinizer ignore-call */ getOldPrimaryKey(true));
Loading history...
259
            } else {
260
                // if there is more than one record, the value is not unique
261 6
                $exists = $n > 1;
262
            }
263
        }
264
265 49
        return $exists;
266
    }
267
268
    /**
269
     * Prepares a query by applying filtering conditions defined in $conditions method property
270
     * and [[filter]] class property.
271
     *
272
     * @param ActiveRecordInterface $targetClass the name of the ActiveRecord class that should be used to validate
273
     * the uniqueness of the current attribute value.
274
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format
275
     *
276
     * @return ActiveQueryInterface|ActiveQuery
277
     */
278 55
    private function prepareQuery($targetClass, $conditions)
279
    {
280 55
        $query = $targetClass::find();
281 55
        $query->andWhere($conditions);
282 55
        if ($this->filter instanceof \Closure) {
283 6
            call_user_func($this->filter, $query);
284 52
        } elseif ($this->filter !== null) {
285 3
            $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

285
            $query->andWhere(/** @scrutinizer ignore-type */ $this->filter);
Loading history...
286
        }
287
288 55
        return $query;
289
    }
290
291
    /**
292
     * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with
293
     * [[\yii\db\Query::where()|Query::where()]] key-value format.
294
     *
295
     * @param string|array $targetAttribute the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that
296
     * should be used to validate the uniqueness of the current attribute value. You may use an array to validate
297
     * the uniqueness of multiple columns at the same time. The array values are the attributes that will be
298
     * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated.
299
     * If the key and the value are the same, you can just specify the value.
300
     * @param Model $model the data model to be validated
301
     * @param string $attribute the name of the attribute to be validated in the $model
302
     *
303
     * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
304
     */
305 58
    private function prepareConditions($targetAttribute, $model, $attribute)
306
    {
307 58
        if (is_array($targetAttribute)) {
308 27
            $conditions = [];
309 27
            foreach ($targetAttribute as $k => $v) {
310 27
                $conditions[$v] = is_int($k) ? $model->$v : $model->$k;
311
            }
312
        } else {
313 40
            $conditions = [$targetAttribute => $model->$attribute];
314
        }
315
316 58
        $targetModelClass = $this->getTargetClass($model);
317 58
        if (!is_subclass_of($targetModelClass, 'yii\db\ActiveRecord')) {
318 6
            return $conditions;
319
        }
320
321
        /** @var ActiveRecord $targetModelClass */
322 58
        return $this->applyTableAlias($targetModelClass::find(), $conditions);
323
    }
324
325
    /**
326
     * Builds and adds [[comboNotUnique]] error message to the specified model attribute.
327
     * @param \yii\base\Model $model the data model.
328
     * @param string $attribute the name of the attribute.
329
     */
330 6
    private function addComboNotUniqueError($model, $attribute)
331
    {
332 6
        $attributeCombo = [];
333 6
        $valueCombo = [];
334 6
        foreach ($this->targetAttribute as $key => $value) {
335 6
            if (is_int($key)) {
336 6
                $attributeCombo[] = $model->getAttributeLabel($value);
337 6
                $valueCombo[] = '"' . $model->$value . '"';
338
            } else {
339
                $attributeCombo[] = $model->getAttributeLabel($key);
340
                $valueCombo[] = '"' . $model->$key . '"';
341
            }
342
        }
343 6
        $this->addError($model, $attribute, $this->message, [
344 6
            'attributes' => Inflector::sentence($attributeCombo),
345 6
            'values' => implode('-', $valueCombo),
346
        ]);
347 6
    }
348
349
    /**
350
     * Returns conditions with alias.
351
     * @param ActiveQuery $query
352
     * @param array $conditions array of condition, keys to be modified
353
     * @param string|null $alias set empty string for no apply alias. Set null for apply primary table alias
354
     * @return array
355
     */
356 58
    private function applyTableAlias($query, $conditions, $alias = null)
357
    {
358 58
        if ($alias === null) {
359 58
            $alias = array_keys($query->getTablesUsedInFrom())[0];
360
        }
361 58
        $prefixedConditions = [];
362 58
        foreach ($conditions as $columnName => $columnValue) {
363 58
            if (strpos($columnName, '(') === false) {
364 58
                $columnName = preg_replace('/^' . preg_quote($alias) . '\.(.*)$/', '$1', $columnName);
365 58
                if (strncmp($columnName, '[[', 2) === 0) {
366 1
                    $prefixedColumn = "{$alias}.{$columnName}";
367
                } else {
368 58
                    $prefixedColumn = "{$alias}.[[{$columnName}]]";
369
                }
370
            } else {
371
                // there is an expression, can't prefix it reliably
372 3
                $prefixedColumn = $columnName;
373
            }
374
375 58
            $prefixedConditions[$prefixedColumn] = $columnValue;
376
        }
377
378 58
        return $prefixedConditions;
379
    }
380
}
381