Completed
Push — query-filter-having ( b78d63...3e0323 )
by Alexander
58:34 queued 19:55
created

UniqueValidator::prepareQuery()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3.1406

Importance

Changes 0
Metric Value
dl 0
loc 13
ccs 6
cts 8
cp 0.75
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 8
nc 3
nop 2
crap 3.1406
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\ActiveRecordInterface;
15
use yii\db\Query;
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
     * If the key and the value are the same, you can just specify the value.
57
     */
58
    public $targetAttribute;
59
    /**
60
     * @var string|array|\Closure additional filter to be applied to the DB query used to check the uniqueness of the attribute value.
61
     * This can be a string or an array representing the additional query condition (refer to [[\yii\db\Query::where()]]
62
     * on the format of query condition), or an anonymous function with the signature `function ($query)`, where `$query`
63
     * is the [[\yii\db\Query|Query]] object that you can modify in the function.
64
     */
65
    public $filter;
66
    /**
67
     * @var string the user-defined error message. When validating single attribute, it may contain
68
     * the following placeholders which will be replaced accordingly by the validator:
69
     *
70
     * - `{attribute}`: the label of the attribute being validated
71
     * - `{value}`: the value of the attribute being validated
72
     *
73
     * When validating mutliple attributes, it may contain the following placeholders:
74
     *
75
     * - `{attributes}`: the labels of the attributes being validated.
76
     * - `{values}`: the values of the attributes being validated.
77
     *
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 42
89
    /**
90 42
     * @inheritdoc
91 42
     */
92 3
    public function init()
93
    {
94 42
        parent::init();
95
        if ($this->message !== null) {
96 6
            return;
97 3
        }
98 3
        if (is_array($this->targetAttribute) && count($this->targetAttribute) > 1) {
99 3
            // fallback for deprecated `comboNotUnique` property - use it as message if is set
100
            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...
101 6
                $this->message = Yii::t('yii', 'The combination {values} of {attributes} has already been taken.');
102 39
            } else {
103
                $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...
104 42
            }
105
        } else {
106
            $this->message = Yii::t('yii', '{attribute} "{value}" has already been taken.');
107
        }
108
    }
109 39
110
    /**
111
     * @inheritdoc
112 39
     */
113 39
    public function validateAttribute($model, $attribute)
114
    {
115 39
        /* @var $targetClass ActiveRecordInterface */
116 12
        $targetClass = $this->targetClass === null ? get_class($model) : $this->targetClass;
117 12
        $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
118 12
        $conditions = $this->prepareConditions($targetAttribute, $model, $attribute);
119 12
120 12
        foreach ($conditions as $value) {
121 33
            if (is_array($value)) {
122
                $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
123
                return;
124 39
            }
125 39
        }
126 6
127
        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...
128 6
            if (count($targetAttribute) > 1) {
129
                $this->addComboNotUniqueError($model, $attribute);
130 36
            } else {
131
                $this->addError($model, $attribute, $this->message);
132 36
            }
133 36
        }
134
    }
135 36
136
    /**
137 36
     * Checks whether the $model exists in the database.
138
     *
139
     * @param string $targetClass the name of the ActiveRecord class that should be used to validate the uniqueness
140
     * of the current attribute value.
141 36
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
142
     * @param Model $model the data model to be validated
143
     *
144 33
     * @return bool whether the model already exists
145 30
     */
146
    private function modelExists($targetClass, $conditions, $model)
147
    {
148 13
        /** @var ActiveRecordInterface $targetClass $query */
149 13
        $query = $this->prepareQuery($targetClass, $conditions);
150 13
151 13
        if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== $targetClass::className()) {
152 13
            // if current $model isn't in the database yet then it's OK just to call exists()
153 13
            // also there's no need to run check based on primary keys, when $targetClass is not the same as $model's class
154 13
            $exists = $query->exists();
155 13
        } else {
156
            // if current $model is in the database already we can't use exists()
157 9
            /** @var $models ActiveRecordInterface[] */
158 9
            $models = $query->select($targetClass::primaryKey())->limit(2)->all();
159
            $n = count($models);
160 7
            if ($n === 1) {
161
                $keys = array_keys($conditions);
162 13
                $pks = $targetClass::primaryKey();
163 3
                sort($keys);
164
                sort($pks);
165
                if ($keys === $pks) {
166
                    // primary key is modified and not unique
167 33
                    $exists = $model->getOldPrimaryKey() != $model->getPrimaryKey();
168 29
                } else {
169 6
                    // non-primary key, need to exclude the current record based on PK
170 6
                    $exists = reset($models)->getPrimaryKey() != $model->getOldPrimaryKey();
171 29
                }
172
            } else {
173 29
                $exists = $n > 1;
174 33
            }
175
        }
176
177
        return $exists;
178
    }
179
180
    /**
181 6
     * Prepares a query by applying filtering conditions defined in $conditions method property
182
     * and [[filter]] class property.
183 6
     *
184 6
     * @param ActiveRecordInterface $targetClass the name of the ActiveRecord class that should be used to validate
185 6
     * the uniqueness of the current attribute value.
186 6
     * @param array $conditions conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format
187 6
     *
188 6
     * @return ActiveQueryInterface|ActiveQuery
189 6
     */
190
    private function prepareQuery($targetClass, $conditions)
191
    {
192
        $query = $targetClass::find();
193 6
        $query->andWhere($conditions);
194 6
195 6
        if ($this->filter instanceof \Closure) {
196 6
            call_user_func($this->filter, $query);
197 6
        } elseif ($this->filter !== null) {
198 6
            $query->andWhere($this->filter);
199
        }
200
201
        return $query;
202
    }
203
204
    /**
205
     * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with
206
     * [[\yii\db\Query::where()|Query::where()]] key-value format.
207
     *
208
     * @param string|array $targetAttribute the name of the [[\yii\db\ActiveRecord|ActiveRecord]] attribute that
209
     * should be used to validate the uniqueness of the current attribute value. You may use an array to validate
210
     * the uniqueness of multiple columns at the same time. The array values are the attributes that will be
211
     * used to validate the uniqueness, while the array keys are the attributes whose values are to be validated.
212
     * If the key and the value are the same, you can just specify the value.
213
     * @param Model $model the data model to be validated
214
     * @param string $attribute the name of the attribute to be validated in the $model
215
216
     * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
217
     */
218
    private function prepareConditions($targetAttribute, $model, $attribute)
219
    {
220
        if (is_array($targetAttribute)) {
221
            $conditions = [];
222
            foreach ($targetAttribute as $k => $v) {
223
                $conditions[$v] = is_int($k) ? $model->$v : $model->$k;
224
            }
225
        } else {
226
            $conditions = [$targetAttribute => $model->$attribute];
227
        }
228
229
        return $conditions;
230
    }
231
232
    /**
233
     * Builds and adds [[comboNotUnique]] error message to the specified model attribute.
234
     * @param \yii\base\Model $model the data model.
235
     * @param string $attribute the name of the attribute.
236
     */
237
    private function addComboNotUniqueError($model, $attribute)
238
    {
239
        $attributeCombo = [];
240
        $valueCombo = [];
241
        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...
242
            if(is_int($key)) {
243
                $attributeCombo[] = $model->getAttributeLabel($value);
244
                $valueCombo[] = '"' . $model->$value . '"';
245
            } else {
246
                $attributeCombo[] = $model->getAttributeLabel($key);
247
                $valueCombo[] = '"' . $model->$key . '"';
248
            }
249
        }
250
        $this->addError($model, $attribute, $this->message, [
251
            'attributes' => Inflector::sentence($attributeCombo),
252
            'values' => implode('-', $valueCombo)
253
        ]);
254
    }
255
}
256