Passed
Push — master ( 9a3093...472600 )
by Alexander
09:50
created

UniqueValidator::getForceMasterDb()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
rs 10
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\db\Connection;
17
use yii\helpers\Inflector;
18
19
/**
20
 * UniqueValidator validates that the attribute value is unique in the specified database table.
21
 *
22
 * UniqueValidator checks if the value being validated is unique in the table column specified by
23
 * the ActiveRecord class [[targetClass]] and the attribute [[targetAttribute]].
24
 *
25
 * The following are examples of validation rules using this validator:
26
 *
27
 * ```php
28
 * // a1 needs to be unique
29
 * ['a1', 'unique']
30
 * // a1 needs to be unique, but column a2 will be used to check the uniqueness of the a1 value
31
 * ['a1', 'unique', 'targetAttribute' => 'a2']
32
 * // a1 and a2 need to be unique together, and they both will receive error message
33
 * [['a1', 'a2'], 'unique', 'targetAttribute' => ['a1', 'a2']]
34
 * // a1 and a2 need to be unique together, only a1 will receive error message
35
 * ['a1', 'unique', 'targetAttribute' => ['a1', 'a2']]
36
 * // a1 needs to be unique by checking the uniqueness of both a2 and a3 (using a1 value)
37
 * ['a1', 'unique', 'targetAttribute' => ['a2', 'a1' => 'a3']]
38
 * ```
39
 *
40
 * @mixin ForceMasterDbTrait
41
 *
42
 * @author Qiang Xue <[email protected]>
43
 * @since 2.0
44
 */
45
class UniqueValidator extends Validator
46
{
47
    /**
48
     * @var string the name of the ActiveRecord class that should be used to validate the uniqueness
49
     * of the current attribute value. If not set, it will use the ActiveRecord class of the attribute being validated.
50
     * @see targetAttribute
51
     */
52
    public $targetClass;
53
    /**
54
     * @var string|array the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that should be used to
55
     * validate the uniqueness of the current attribute value. If not set, it will use the name
56
     * of the attribute currently being validated. You may use an array to validate the uniqueness
57
     * of multiple columns at the same time. The array values are the attributes that will be
58
     * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated.
59
     */
60
    public $targetAttribute;
61
    /**
62
     * @var string|array|\Closure additional filter to be applied to the DB query used to check the uniqueness of the attribute value.
63
     * This can be a string or an array representing the additional query condition (refer to [[\yii\db\Query::where()]]
64
     * on the format of query condition), or an anonymous function with the signature `function ($query)`, where `$query`
65
     * is the [[\yii\db\Query|Query]] object that you can modify in the function.
66
     */
67
    public $filter;
68
    /**
69
     * @var string the user-defined error message.
70
     *
71
     * When validating single attribute, it may contain
72
     * the following placeholders which will be replaced accordingly by the validator:
73
     *
74
     * - `{attribute}`: the label of the attribute being validated
75
     * - `{value}`: the value of the attribute being validated
76
     *
77
     * When validating mutliple attributes, it may contain the following placeholders:
78
     *
79
     * - `{attributes}`: the labels of the attributes being validated.
80
     * - `{values}`: the values of the attributes being validated.
81
     */
82
    public $message;
83
    /**
84
     * @var string
85
     * @since 2.0.9
86
     * @deprecated since version 2.0.10, to be removed in 2.1. Use [[message]] property
87
     * to setup custom message for multiple target attributes.
88
     */
89
    public $comboNotUnique;
90
    /**
91
     * @var string and|or define how target attributes are related
92
     * @since 2.0.11
93
     */
94
    public $targetAttributeJunction = 'and';
95
    /**
96
     * @var bool whether this validator is forced to always use the primary DB connection
97
     * @since 2.0.26
98
     */
99
    public $forcePrimaryDb =  true;
100
    /**
101
     * Returns the value of [[forcePrimaryDb]].
102
     * @return bool
103
     * @deprecated since 2.0.36. Use [[forcePrimaryDb]] instead.
104
     */
105
    public function getForceMasterDb()
106
    {
107
        return $this->forcePrimaryDb;
108
    }
109
    /**
110
     * Sets the value of [[forcePrimaryDb]].
111
     * @param bool $value
112
     * @deprecated since 2.0.36. Use [[forcePrimaryDb]] instead.
113
     */
114
    public function setForceMasterDb($value)
115
    {
116
        $this->forcePrimaryDb = $value;
117
    }
118
119
120
    /**
121
     * {@inheritdoc}
122
     */
123 70
    public function init()
124
    {
125 70
        parent::init();
126 70
        if ($this->message !== null) {
127 3
            return;
128
        }
129 70
        if (is_array($this->targetAttribute) && count($this->targetAttribute) > 1) {
130
            // fallback for deprecated `comboNotUnique` property - use it as message if is set
131 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

131
            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...
132 12
                $this->message = Yii::t('yii', 'The combination {values} of {attributes} has already been taken.');
133
            } else {
134 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

134
                $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...
135
            }
136
        } else {
137 58
            $this->message = Yii::t('yii', '{attribute} "{value}" has already been taken.');
138
        }
139 70
    }
140
141
    /**
142
     * {@inheritdoc}
143
     */
144 55
    public function validateAttribute($model, $attribute)
145
    {
146
        /* @var $targetClass ActiveRecordInterface */
147 55
        $targetClass = $this->getTargetClass($model);
148 55
        $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
149 55
        $rawConditions = $this->prepareConditions($targetAttribute, $model, $attribute);
150 55
        $conditions = [$this->targetAttributeJunction === 'or' ? 'or' : 'and'];
151
152 55
        foreach ($rawConditions as $key => $value) {
153 55
            if (is_array($value)) {
154 6
                $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
155 6
                return;
156
            }
157 52
            $conditions[] = [$key => $value];
158
        }
159
160
        /** @var Connection|mixed $db */
161 52
        $db = $targetClass::getDb();
162
163 52
        $modelExists = false;
164
165 52
        if ($this->forcePrimaryDb && method_exists($db, 'usePrimary')) {
166 52
            $db->usePrimary(function () use ($targetClass, $conditions, $model, &$modelExists) {
167 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

167
                $modelExists = $this->modelExists(/** @scrutinizer ignore-type */ $targetClass, $conditions, $model);
Loading history...
168 52
            });
169
        } else {
170 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

170
            $modelExists = $this->modelExists(/** @scrutinizer ignore-type */ $targetClass, $conditions, $model);
Loading history...
171
        }
172
173 49
        if ($modelExists) {
174 29
            if (is_array($targetAttribute) && count($targetAttribute) > 1) {
175 6
                $this->addComboNotUniqueError($model, $attribute);
176
            } else {
177 29
                $this->addError($model, $attribute, $this->message);
178
            }
179
        }
180 49
    }
181
182
    /**
183
     * @param Model $model the data model to be validated
184
     * @return string Target class name
185
     */
186 64
    private function getTargetClass($model)
187
    {
188 64
        return $this->targetClass === null ? get_class($model) : $this->targetClass;
189
    }
190
191
    /**
192
     * Checks whether the $model exists in the database.
193
     *
194
     * @param string $targetClass the name of the ActiveRecord class that should be used to validate the uniqueness
195
     * of the current attribute value.
196
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
197
     * @param Model $model the data model to be validated
198
     *
199
     * @return bool whether the model already exists
200
     */
201 52
    private function modelExists($targetClass, $conditions, $model)
202
    {
203
        /** @var ActiveRecordInterface|\yii\base\BaseObject $targetClass $query */
204 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

204
        $query = $this->prepareQuery(/** @scrutinizer ignore-type */ $targetClass, $conditions);
Loading history...
205
206 52
        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

206
        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 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

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

206
        if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== $targetClass::/** @scrutinizer ignore-call */ className()) {
Loading history...
207
            // if current $model isn't in the database yet then it's OK just to call exists()
208
            // also there's no need to run check based on primary keys, when $targetClass is not the same as $model's class
209 34
            $exists = $query->exists();
210
        } else {
211
            // if current $model is in the database already we can't use exists()
212 29
            if ($query instanceof \yii\db\ActiveQuery) {
213
                // only select primary key to optimize query
214 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

214
                $columnsCondition = array_flip($targetClass::/** @scrutinizer ignore-call */ primaryKey());
Loading history...
215 29
                $query->select(array_flip($this->applyTableAlias($query, $columnsCondition)));
216
217
                // any with relation can't be loaded because related fields are not selected
218 29
                $query->with = null;
219
220 29
                if (is_array($query->joinWith)) {
0 ignored issues
show
introduced by
The condition is_array($query->joinWith) is always true.
Loading history...
221
                    // any joinWiths need to have eagerLoading turned off to prevent related fields being loaded
222 6
                    foreach ($query->joinWith as &$joinWith) {
223
                        // \yii\db\ActiveQuery::joinWith adds eagerLoading at key 1
224 6
                        $joinWith[1] = false;
225
                    }
226 6
                    unset($joinWith);
227
                }
228
            }
229 29
            $models = $query->limit(2)->asArray()->all();
230 29
            $n = count($models);
231 29
            if ($n === 1) {
232
                // if there is one record, check if it is the currently validated model
233 26
                $dbModel = reset($models);
234 26
                $pks = $targetClass::primaryKey();
235 26
                $pk = [];
236 26
                foreach ($pks as $pkAttribute) {
237 26
                    $pk[$pkAttribute] = $dbModel[$pkAttribute];
238
                }
239 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

239
                $exists = ($pk != $model->/** @scrutinizer ignore-call */ getOldPrimaryKey(true));
Loading history...
240
            } else {
241
                // if there is more than one record, the value is not unique
242 6
                $exists = $n > 1;
243
            }
244
        }
245
246 49
        return $exists;
247
    }
248
249
    /**
250
     * Prepares a query by applying filtering conditions defined in $conditions method property
251
     * and [[filter]] class property.
252
     *
253
     * @param ActiveRecordInterface $targetClass the name of the ActiveRecord class that should be used to validate
254
     * the uniqueness of the current attribute value.
255
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format
256
     *
257
     * @return ActiveQueryInterface|ActiveQuery
258
     */
259 55
    private function prepareQuery($targetClass, $conditions)
260
    {
261 55
        $query = $targetClass::find();
262 55
        $query->andWhere($conditions);
263 55
        if ($this->filter instanceof \Closure) {
264 6
            call_user_func($this->filter, $query);
265 52
        } elseif ($this->filter !== null) {
266 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

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