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); |
|||
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||||
183 | |||||
184 | 84 | if (!$model instanceof ActiveRecordInterface || $model->getIsNewRecord() || $model->className() !== $targetClass::className()) { |
|||
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); |
|||
0 ignored issues
–
show
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
Loading history...
|
|||||
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 |