Passed
Push — master ( 9dbdd9...d5a428 )
by Alexander
04:15
created

framework/validators/UniqueValidator.php (3 issues)

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. 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 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 114
    public function init()
103
    {
104 114
        parent::init();
105 114
        if ($this->message !== null) {
106 5
            return;
107
        }
108 114
        if (is_array($this->targetAttribute) && count($this->targetAttribute) > 1) {
109
            // fallback for deprecated `comboNotUnique` property - use it as message if is set
110 25
            if ($this->comboNotUnique === null) {
111 20
                $this->message = Yii::t('yii', 'The combination {values} of {attributes} has already been taken.');
112
            } else {
113 25
                $this->message = $this->comboNotUnique;
114
            }
115
        } else {
116 94
            $this->message = Yii::t('yii', '{attribute} "{value}" has already been taken.');
117
        }
118 114
    }
119
120
    /**
121
     * {@inheritdoc}
122
     */
123 89
    public function validateAttribute($model, $attribute)
124
    {
125
        /* @var $targetClass ActiveRecordInterface */
126 89
        $targetClass = $this->getTargetClass($model);
127 89
        $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
128 89
        $rawConditions = $this->prepareConditions($targetAttribute, $model, $attribute);
129 89
        $conditions = [$this->targetAttributeJunction === 'or' ? 'or' : 'and'];
130
131 89
        foreach ($rawConditions as $key => $value) {
132 89
            if (is_array($value)) {
133 10
                $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
134 10
                return;
135
            }
136 84
            $conditions[] = [$key => $value];
137
        }
138
139 84
        $db = $targetClass::getDb();
140
141 84
        $modelExists = false;
142
143 84
        if ($this->forceMasterDb && method_exists($db, 'useMaster')) {
144 84
            $db->useMaster(function () use ($targetClass, $conditions, $model, &$modelExists) {
145 84
                $modelExists = $this->modelExists($targetClass, $conditions, $model);
146 84
            });
147
        } else {
148 5
            $modelExists = $this->modelExists($targetClass, $conditions, $model);
149
        }
150
151 79
        if ($modelExists) {
152 47
            if (is_array($targetAttribute) && count($targetAttribute) > 1) {
153 10
                $this->addComboNotUniqueError($model, $attribute);
154
            } else {
155 47
                $this->addError($model, $attribute, $this->message);
156
            }
157
        }
158 79
    }
159
160
    /**
161
     * @param Model $model the data model to be validated
162
     * @return string Target class name
163
     */
164 104
    private function getTargetClass($model)
165
    {
166 104
        return $this->targetClass === null ? get_class($model) : $this->targetClass;
167
    }
168
169
    /**
170
     * Checks whether the $model exists in the database.
171
     *
172
     * @param string $targetClass the name of the ActiveRecord class that should be used to validate the uniqueness
173
     * of the current attribute value.
174
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
175
     * @param Model $model the data model to be validated
176
     *
177
     * @return bool whether the model already exists
178
     */
179 84
    private function modelExists($targetClass, $conditions, $model)
180
    {
181
        /** @var ActiveRecordInterface|\yii\base\BaseObject $targetClass $query */
182 84
        $query = $this->prepareQuery($targetClass, $conditions);
183
184 84
        if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== $targetClass::className()) {
0 ignored issues
show
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

184
        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...
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

184
        if (!$model instanceof ActiveRecordInterface || $model->/** @scrutinizer ignore-call */ getIsNewRecord() || $model->className() !== $targetClass::className()) {
Loading history...
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

184
        if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== $targetClass::/** @scrutinizer ignore-call */ className()) {
Loading history...
185
            // if current $model isn't in the database yet then it's OK just to call exists()
186
            // also there's no need to run check based on primary keys, when $targetClass is not the same as $model's class
187 54
            $exists = $query->exists();
188
        } else {
189
            // if current $model is in the database already we can't use exists()
190 47
            if ($query instanceof \yii\db\ActiveQuery) {
191
                // only select primary key to optimize query
192 47
                $columnsCondition = array_flip($targetClass::primaryKey());
193 47
                $query->select(array_flip($this->applyTableAlias($query, $columnsCondition)));
194
                
195
                // any with relation can't be loaded because related fields are not selected
196 47
                $query->with = null;
197
    
198 47
                if (is_array($query->joinWith)) {
199
                    // any joinWiths need to have eagerLoading turned off to prevent related fields being loaded
200 10
                    foreach ($query->joinWith as &$joinWith) {
201
                        // \yii\db\ActiveQuery::joinWith adds eagerLoading at key 1
202 10
                        $joinWith[1] = false;
203
                    }
204 10
                    unset($joinWith);
205
                }
206
            }
207 47
            $models = $query->limit(2)->asArray()->all();
208 47
            $n = count($models);
209 47
            if ($n === 1) {
210
                // if there is one record, check if it is the currently validated model
211 44
                $dbModel = reset($models);
212 44
                $pks = $targetClass::primaryKey();
213 44
                $pk = [];
214 44
                foreach ($pks as $pkAttribute) {
215 44
                    $pk[$pkAttribute] = $dbModel[$pkAttribute];
216
                }
217 44
                $exists = ($pk != $model->getOldPrimaryKey(true));
218
            } else {
219
                // if there is more than one record, the value is not unique
220 8
                $exists = $n > 1;
221
            }
222
        }
223
224 79
        return $exists;
225
    }
226
227
    /**
228
     * Prepares a query by applying filtering conditions defined in $conditions method property
229
     * and [[filter]] class property.
230
     *
231
     * @param ActiveRecordInterface $targetClass the name of the ActiveRecord class that should be used to validate
232
     * the uniqueness of the current attribute value.
233
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format
234
     *
235
     * @return ActiveQueryInterface|ActiveQuery
236
     */
237 89
    private function prepareQuery($targetClass, $conditions)
238
    {
239 89
        $query = $targetClass::find();
240 89
        $query->andWhere($conditions);
241 89
        if ($this->filter instanceof \Closure) {
242 10
            call_user_func($this->filter, $query);
243 84
        } elseif ($this->filter !== null) {
244 5
            $query->andWhere($this->filter);
245
        }
246
247 89
        return $query;
248
    }
249
250
    /**
251
     * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with
252
     * [[\yii\db\Query::where()|Query::where()]] key-value format.
253
     *
254
     * @param string|array $targetAttribute the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that
255
     * should be used to validate the uniqueness of the current attribute value. You may use an array to validate
256
     * the uniqueness of multiple columns at the same time. The array values are the attributes that will be
257
     * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated.
258
     * If the key and the value are the same, you can just specify the value.
259
     * @param Model $model the data model to be validated
260
     * @param string $attribute the name of the attribute to be validated in the $model
261
     *
262
     * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
263
     */
264 94
    private function prepareConditions($targetAttribute, $model, $attribute)
265
    {
266 94
        if (is_array($targetAttribute)) {
267 45
            $conditions = [];
268 45
            foreach ($targetAttribute as $k => $v) {
269 45
                $conditions[$v] = is_int($k) ? $model->$v : $model->$k;
270
            }
271
        } else {
272 64
            $conditions = [$targetAttribute => $model->$attribute];
273
        }
274
275 94
        $targetModelClass = $this->getTargetClass($model);
276 94
        if (!is_subclass_of($targetModelClass, 'yii\db\ActiveRecord')) {
277 10
            return $conditions;
278
        }
279
280
        /** @var ActiveRecord $targetModelClass */
281 94
        return $this->applyTableAlias($targetModelClass::find(), $conditions);
282
    }
283
284
    /**
285
     * Builds and adds [[comboNotUnique]] error message to the specified model attribute.
286
     * @param \yii\base\Model $model the data model.
287
     * @param string $attribute the name of the attribute.
288
     */
289 10
    private function addComboNotUniqueError($model, $attribute)
290
    {
291 10
        $attributeCombo = [];
292 10
        $valueCombo = [];
293 10
        foreach ($this->targetAttribute as $key => $value) {
294 10
            if (is_int($key)) {
295 10
                $attributeCombo[] = $model->getAttributeLabel($value);
296 10
                $valueCombo[] = '"' . $model->$value . '"';
297
            } else {
298
                $attributeCombo[] = $model->getAttributeLabel($key);
299 10
                $valueCombo[] = '"' . $model->$key . '"';
300
            }
301
        }
302 10
        $this->addError($model, $attribute, $this->message, [
303 10
            'attributes' => Inflector::sentence($attributeCombo),
304 10
            'values' => implode('-', $valueCombo),
305
        ]);
306 10
    }
307
308
    /**
309
     * Returns conditions with alias.
310
     * @param ActiveQuery $query
311
     * @param array $conditions array of condition, keys to be modified
312
     * @param null|string $alias set empty string for no apply alias. Set null for apply primary table alias
313
     * @return array
314
     */
315 94
    private function applyTableAlias($query, $conditions, $alias = null)
316
    {
317 94
        if ($alias === null) {
318 94
            $alias = array_keys($query->getTablesUsedInFrom())[0];
319
        }
320 94
        $prefixedConditions = [];
321 94
        foreach ($conditions as $columnName => $columnValue) {
322 94
            if (strpos($columnName, '(') === false) {
323 94
                $columnName = preg_replace('/^' . preg_quote($alias) . '\.(.*)$/', '$1', $columnName);
324 94
                if (strpos($columnName, '[[') === 0) {
325 1
                    $prefixedColumn = "{$alias}.{$columnName}";
326
                } else {
327 94
                    $prefixedColumn = "{$alias}.[[{$columnName}]]";
328
                }
329
            } else {
330
                // there is an expression, can't prefix it reliably
331 5
                $prefixedColumn = $columnName;
332
            }
333
334 94
            $prefixedConditions[$prefixedColumn] = $columnValue;
335
        }
336
337 94
        return $prefixedConditions;
338
    }
339
}
340