Completed
Push — 2.1 ( 7c8525...0afc41 )
by Alexander
21:05 queued 16:02
created

UniqueValidator::validateAttribute()   C

Complexity

Conditions 7
Paths 28

Size

Total Lines 24
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 7

Importance

Changes 0
Metric Value
dl 0
loc 24
ccs 15
cts 15
cp 1
rs 6.7272
c 0
b 0
f 0
cc 7
eloc 15
nc 28
nop 2
crap 7
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\ActiveRecord;
14
use yii\db\ActiveQueryInterface;
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
     */
84
    public $message;
85
    /**
86
     * @var string
87
     * @since 2.0.9
88
     * @deprecated since version 2.0.10, to be removed in 2.1. Use [[message]] property
89
     * to setup custom message for multiple target attributes.
90
     */
91
    public $comboNotUnique;
92
    /**
93
     * @var string and|or define how target attributes are related
94
     * @since 2.0.11
95
     */
96
    public $targetAttributeJunction = 'and';
97
98
99
    /**
100
     * @inheritdoc
101
     */
102 57
    public function init()
103
    {
104 57
        parent::init();
105 57
        if ($this->message !== null) {
106 3
            return;
107
        }
108 57
        if (is_array($this->targetAttribute) && count($this->targetAttribute) > 1) {
109
            // fallback for deprecated `comboNotUnique` property - use it as message if is set
110 6
            if ($this->comboNotUnique === null) {
0 ignored issues
show
Deprecated Code introduced by
The property yii\validators\UniqueValidator::$comboNotUnique has been deprecated with message: since version 2.0.10, to be removed in 2.1. Use [[message]] property
to setup custom message for multiple target attributes.

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...
111 3
                $this->message = Yii::t('yii', 'The combination {values} of {attributes} has already been taken.');
112
            } else {
113 3
                $this->message = $this->comboNotUnique;
0 ignored issues
show
Deprecated Code introduced by
The property yii\validators\UniqueValidator::$comboNotUnique has been deprecated with message: since version 2.0.10, to be removed in 2.1. Use [[message]] property
to setup custom message for multiple target attributes.

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...
114
            }
115
        } else {
116 54
            $this->message = Yii::t('yii', '{attribute} "{value}" has already been taken.');
117
        }
118 57
    }
119
120
    /**
121
     * @inheritdoc
122
     */
123 42
    public function validateAttribute($model, $attribute)
124
    {
125
        /* @var $targetClass ActiveRecordInterface */
126 42
        $targetClass = $this->getTargetClass($model);
127 42
        $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
128 42
        $rawConditions = $this->prepareConditions($targetAttribute, $model, $attribute);
129 42
        $conditions[] = $this->targetAttributeJunction === 'or' ? 'or' : 'and';
0 ignored issues
show
Coding Style Comprehensibility introduced by
$conditions was never initialized. Although not strictly required by PHP, it is generally a good practice to add $conditions = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
130
131 42
        foreach ($rawConditions as $key => $value) {
132 42
            if (is_array($value)) {
133 6
                $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
134 6
                return;
135
            }
136 39
            $conditions[] = [$key => $value];
137
        }
138
139 39
        if ($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...
140 32
            if (count($targetAttribute) > 1) {
141 6
                $this->addComboNotUniqueError($model, $attribute);
142
            } else {
143 32
                $this->addError($model, $attribute, $this->message);
144
            }
145
        }
146 36
    }
147
148
    /**
149
     * @param Model $model the data model to be validated
150
     * @return string Target class name
151
     */
152 51
    private function getTargetClass($model)
153
    {
154 51
        return $this->targetClass === null ? get_class($model) : $this->targetClass;
155
    }
156
157
    /**
158
     * Checks whether the $model exists in the database.
159
     *
160
     * @param string $targetClass the name of the ActiveRecord class that should be used to validate the uniqueness
161
     * of the current attribute value.
162
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
163
     * @param Model $model the data model to be validated
164
     *
165
     * @return bool whether the model already exists
166
     */
167 39
    private function modelExists($targetClass, $conditions, $model)
168
    {
169
        /** @var ActiveRecordInterface $targetClass $query */
170 39
        $query = $this->prepareQuery($targetClass, $conditions);
171
172 39
        if (!$model instanceof ActiveRecordInterface
173 36
            || $model->getIsNewRecord()
174 22
            || get_class($model) !== $targetClass
175
        ) {
176
            // if current $model isn't in the database yet then it's OK just to call exists()
177
            // also there's no need to run check based on primary keys, when $targetClass is not the same as $model's class
178 36
            $exists = $query->exists();
179
        } else {
180
            // if current $model is in the database already we can't use exists()
181 16
            if ($query instanceof \yii\db\ActiveQuery) {
182
                // only select primary key to optimize query
183 16
                $columnsCondition = array_flip($targetClass::primaryKey());
184 16
                $query->select(array_flip($this->applyTableAlias($query, $columnsCondition)));
185
            }
186 16
            $models = $query->limit(2)->asArray()->all();
187 16
            $n = count($models);
188 16
            if ($n === 1) {
189
                // if there is one record, check if it is the currently validated model
190 16
                $dbModel = reset($models);
191 16
                $pks = $targetClass::primaryKey();
192 16
                $pk = [];
193 16
                foreach ($pks as $pkAttribute) {
194 16
                    $pk[$pkAttribute] = $dbModel[$pkAttribute];
195
                }
196 16
                $exists = ($pk != $model->getOldPrimaryKey(true));
197
            } else {
198
                // if there is more than one record, the value is not unique
199 3
                $exists = $n > 1;
200
            }
201
        }
202
203 36
        return $exists;
204
    }
205
206
    /**
207
     * Prepares a query by applying filtering conditions defined in $conditions method property
208
     * and [[filter]] class property.
209
     *
210
     * @param ActiveRecordInterface $targetClass the name of the ActiveRecord class that should be used to validate
211
     * the uniqueness of the current attribute value.
212
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format
213
     *
214
     * @return ActiveQueryInterface|ActiveQuery
215
     */
216 42
    private function prepareQuery($targetClass, $conditions)
217
    {
218 42
        $query = $targetClass::find();
219 42
        $query->andWhere($conditions);
220 42
        if ($this->filter instanceof \Closure) {
221 6
            call_user_func($this->filter, $query);
222 39
        } elseif ($this->filter !== null) {
223 3
            $query->andWhere($this->filter);
224
        }
225
226 42
        return $query;
227
    }
228
229
    /**
230
     * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with
231
     * [[\yii\db\Query::where()|Query::where()]] key-value format.
232
     *
233
     * @param string|array $targetAttribute the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that
234
     * should be used to validate the uniqueness of the current attribute value. You may use an array to validate
235
     * the uniqueness of multiple columns at the same time. The array values are the attributes that will be
236
     * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated.
237
     * If the key and the value are the same, you can just specify the value.
238
     * @param Model $model the data model to be validated
239
     * @param string $attribute the name of the attribute to be validated in the $model
240
     *
241
     * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
242
     */
243 45
    private function prepareConditions($targetAttribute, $model, $attribute)
244
    {
245 45
        if (is_array($targetAttribute)) {
246 15
            $conditions = [];
247 15
            foreach ($targetAttribute as $k => $v) {
248 15
                $conditions[$v] = is_int($k) ? $model->$v : $model->$k;
249
            }
250
        } else {
251 39
            $conditions = [$targetAttribute => $model->$attribute];
252
        }
253
254 45
        if (!$model instanceof ActiveRecord) {
255 9
            return $conditions;
256
        }
257
258 42
        return $this->prefixConditions($model, $conditions);
259
    }
260
261
    /**
262
     * Builds and adds [[comboNotUnique]] error message to the specified model attribute.
263
     * @param \yii\base\Model $model the data model.
264
     * @param string $attribute the name of the attribute.
265
     */
266 6
    private function addComboNotUniqueError($model, $attribute)
267
    {
268 6
        $attributeCombo = [];
269 6
        $valueCombo = [];
270 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...
271 6
            if (is_int($key)) {
272 6
                $attributeCombo[] = $model->getAttributeLabel($value);
273 6
                $valueCombo[] = '"' . $model->$value . '"';
274
            } else {
275
                $attributeCombo[] = $model->getAttributeLabel($key);
276
                $valueCombo[] = '"' . $model->$key . '"';
277
            }
278
        }
279 6
        $this->addError($model, $attribute, $this->message, [
280 6
            'attributes' => Inflector::sentence($attributeCombo),
281 6
            'values' => implode('-', $valueCombo)
282
        ]);
283 6
    }
284
285
    /**
286
     * Returns conditions with alias
287
     * @param ActiveQuery $query
288
     * @param array $conditions array of condition, keys to be modified
289
     * @param null|string $alias set empty string for no apply alias. Set null for apply primary table alias
290
     * @return array
291
     */
292 42
    private function applyTableAlias($query, $conditions, $alias = null)
293
    {
294 42
        if ($alias === null) {
295 42
            $alias = array_keys($query->getTablesUsedInFrom())[0];
296
        }
297 42
        $prefixedConditions = [];
298 42
        foreach ($conditions as $columnName => $columnValue) {
299 42
            $prefixedColumn = "{$alias}.[[" . preg_replace(
300 42
                    '/^' . preg_quote($alias) . '\.(.*)$/',
301 42
                    "$1",
302 42
                    $columnName) . "]]";
303 42
            $prefixedConditions[$prefixedColumn] = $columnValue;
304
        }
305 42
        return $prefixedConditions;
306
    }
307
308
    /**
309
     * Prefix conditions with aliases
310
     *
311
     * @param ActiveRecord $model
312
     * @param array $conditions
313
     * @return array
314
     */
315 42
    private function prefixConditions($model, $conditions)
316
    {
317 42
        $targetModelClass = $this->getTargetClass($model);
318
319
        /** @var ActiveRecord $targetModelClass */
320 42
        return $this->applyTableAlias($targetModelClass::find(), $conditions);
321
    }
322
}
323