Issues (910)

framework/validators/UniqueValidator.php (2 issues)

1
<?php
2
/**
3
 * @link https://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license https://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
    /**
100
     * {@inheritdoc}
101
     */
102 73
    public function init()
103
    {
104 73
        parent::init();
105 73
        if ($this->message !== null) {
106 3
            return;
107
        }
108 73
        if (is_array($this->targetAttribute) && count($this->targetAttribute) > 1) {
109
            // fallback for deprecated `comboNotUnique` property - use it as message if is set
110 18
            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

110
            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...
111 15
                $this->message = Yii::t('yii', 'The combination {values} of {attributes} has already been taken.');
112
            } else {
113 18
                $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

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