ExistValidator.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  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. namespace yii\validators;
  8. use Yii;
  9. use yii\base\InvalidConfigException;
  10. use yii\base\Model;
  11. use yii\db\ActiveQuery;
  12. use yii\db\ActiveRecord;
  13. use yii\db\QueryInterface;
  14. /**
  15. * ExistValidator validates that the attribute value exists in a table.
  16. *
  17. * ExistValidator checks if the value being validated can be found in the table column specified by
  18. * the ActiveRecord class [[targetClass]] and the attribute [[targetAttribute]].
  19. * Since version 2.0.14 you can use more convenient attribute [[targetRelation]]
  20. *
  21. * This validator is often used to verify that a foreign key contains a value
  22. * that can be found in the foreign table.
  23. *
  24. * The following are examples of validation rules using this validator:
  25. *
  26. * ```php
  27. * // a1 needs to exist
  28. * ['a1', 'exist']
  29. * // a1 needs to exist, but its value will use a2 to check for the existence
  30. * ['a1', 'exist', 'targetAttribute' => 'a2']
  31. * // a1 and a2 need to exist together, and they both will receive error message
  32. * [['a1', 'a2'], 'exist', 'targetAttribute' => ['a1', 'a2']]
  33. * // a1 and a2 need to exist together, only a1 will receive error message
  34. * ['a1', 'exist', 'targetAttribute' => ['a1', 'a2']]
  35. * // a1 needs to exist by checking the existence of both a2 and a3 (using a1 value)
  36. * ['a1', 'exist', 'targetAttribute' => ['a2', 'a1' => 'a3']]
  37. * // type_id needs to exist in the column "id" in the table defined in ProductType class
  38. * ['type_id', 'exist', 'targetClass' => ProductType::class, 'targetAttribute' => ['type_id' => 'id']],
  39. * // the same as the previous, but using already defined relation "type"
  40. * ['type_id', 'exist', 'targetRelation' => 'type'],
  41. * ```
  42. *
  43. * @author Qiang Xue <qiang.xue@gmail.com>
  44. * @since 2.0
  45. */
  46. class ExistValidator extends Validator
  47. {
  48. /**
  49. * @var string the name of the ActiveRecord class that should be used to validate the existence
  50. * of the current attribute value. If not set, it will use the ActiveRecord class of the attribute being validated.
  51. * @see targetAttribute
  52. */
  53. public $targetClass;
  54. /**
  55. * @var string|array the name of the ActiveRecord attribute that should be used to
  56. * validate the existence of the current attribute value. If not set, it will use the name
  57. * of the attribute currently being validated. You may use an array to validate the existence
  58. * of multiple columns at the same time. The array key is the name of the attribute with the value to validate,
  59. * the array value is the name of the database field to search.
  60. */
  61. public $targetAttribute;
  62. /**
  63. * @var string the name of the relation that should be used to validate the existence of the current attribute value
  64. * This param overwrites $targetClass and $targetAttribute
  65. * @since 2.0.14
  66. */
  67. public $targetRelation;
  68. /**
  69. * @var string|array|\Closure additional filter to be applied to the DB query used to check the existence of the attribute value.
  70. * This can be a string or an array representing the additional query condition (refer to [[\yii\db\Query::where()]]
  71. * on the format of query condition), or an anonymous function with the signature `function ($query)`, where `$query`
  72. * is the [[\yii\db\Query|Query]] object that you can modify in the function.
  73. */
  74. public $filter;
  75. /**
  76. * @var bool whether to allow array type attribute.
  77. */
  78. public $allowArray = false;
  79. /**
  80. * @var string and|or define how target attributes are related
  81. * @since 2.0.11
  82. */
  83. public $targetAttributeJunction = 'and';
  84. /**
  85. * @var bool whether this validator is forced to always use master DB
  86. * @since 2.0.14
  87. */
  88. public $forceMasterDb = true;
  89. /**
  90. * {@inheritdoc}
  91. */
  92. public function init()
  93. {
  94. parent::init();
  95. if ($this->message === null) {
  96. $this->message = Yii::t('yii', '{attribute} is invalid.');
  97. }
  98. }
  99. /**
  100. * {@inheritdoc}
  101. */
  102. public function validateAttribute($model, $attribute)
  103. {
  104. if (!empty($this->targetRelation)) {
  105. $this->checkTargetRelationExistence($model, $attribute);
  106. } else {
  107. $this->checkTargetAttributeExistence($model, $attribute);
  108. }
  109. }
  110. /**
  111. * Validates existence of the current attribute based on relation name
  112. * @param \yii\db\ActiveRecord $model the data model to be validated
  113. * @param string $attribute the name of the attribute to be validated.
  114. */
  115. private function checkTargetRelationExistence($model, $attribute)
  116. {
  117. $exists = false;
  118. /** @var ActiveQuery $relationQuery */
  119. $relationQuery = $model->{'get' . ucfirst($this->targetRelation)}();
  120. if ($this->filter instanceof \Closure) {
  121. call_user_func($this->filter, $relationQuery);
  122. } elseif ($this->filter !== null) {
  123. $relationQuery->andWhere($this->filter);
  124. }
  125. if ($this->forceMasterDb && method_exists($model::getDb(), 'useMaster')) {
  126. $model::getDb()->useMaster(function() use ($relationQuery, &$exists) {
  127. $exists = $relationQuery->exists();
  128. });
  129. } else {
  130. $exists = $relationQuery->exists();
  131. }
  132. if (!$exists) {
  133. $this->addError($model, $attribute, $this->message);
  134. }
  135. }
  136. /**
  137. * Validates existence of the current attribute based on targetAttribute
  138. * @param \yii\base\Model $model the data model to be validated
  139. * @param string $attribute the name of the attribute to be validated.
  140. */
  141. private function checkTargetAttributeExistence($model, $attribute)
  142. {
  143. $targetAttribute = $this->targetAttribute === null ? $attribute : $this->targetAttribute;
  144. $params = $this->prepareConditions($targetAttribute, $model, $attribute);
  145. $conditions = [$this->targetAttributeJunction == 'or' ? 'or' : 'and'];
  146. if (!$this->allowArray) {
  147. foreach ($params as $key => $value) {
  148. if (is_array($value)) {
  149. $this->addError($model, $attribute, Yii::t('yii', '{attribute} is invalid.'));
  150. return;
  151. }
  152. $conditions[] = [$key => $value];
  153. }
  154. } else {
  155. $conditions[] = $params;
  156. }
  157. $targetClass = $this->targetClass === null ? get_class($model) : $this->targetClass;
  158. $query = $this->createQuery($targetClass, $conditions);
  159. if (!$this->valueExists($targetClass, $query, $model->$attribute)) {
  160. $this->addError($model, $attribute, $this->message);
  161. }
  162. }
  163. /**
  164. * Processes attributes' relations described in $targetAttribute parameter into conditions, compatible with
  165. * [[\yii\db\Query::where()|Query::where()]] key-value format.
  166. *
  167. * @param $targetAttribute array|string $attribute the name of the ActiveRecord attribute that should be used to
  168. * validate the existence of the current attribute value. If not set, it will use the name
  169. * of the attribute currently being validated. You may use an array to validate the existence
  170. * of multiple columns at the same time. The array key is the name of the attribute with the value to validate,
  171. * the array value is the name of the database field to search.
  172. * If the key and the value are the same, you can just specify the value.
  173. * @param \yii\base\Model $model the data model to be validated
  174. * @param string $attribute the name of the attribute to be validated in the $model
  175. * @return array conditions, compatible with [[\yii\db\Query::where()|Query::where()]] key-value format.
  176. * @throws InvalidConfigException
  177. */
  178. private function prepareConditions($targetAttribute, $model, $attribute)
  179. {
  180. if (is_array($targetAttribute)) {
  181. if ($this->allowArray) {
  182. throw new InvalidConfigException('The "targetAttribute" property must be configured as a string.');
  183. }
  184. $conditions = [];
  185. foreach ($targetAttribute as $k => $v) {
  186. $conditions[$v] = is_int($k) ? $model->$v : $model->$k;
  187. }
  188. } else {
  189. $conditions = [$targetAttribute => $model->$attribute];
  190. }
  191. $targetModelClass = $this->getTargetClass($model);
  192. if (!is_subclass_of($targetModelClass, 'yii\db\ActiveRecord')) {
  193. return $conditions;
  194. }
  195. /** @var ActiveRecord $targetModelClass */
  196. return $this->applyTableAlias($targetModelClass::find(), $conditions);
  197. }
  198. /**
  199. * @param Model $model the data model to be validated
  200. * @return string Target class name
  201. */
  202. private function getTargetClass($model)
  203. {
  204. return $this->targetClass === null ? get_class($model) : $this->targetClass;
  205. }
  206. /**
  207. * {@inheritdoc}
  208. */
  209. protected function validateValue($value)
  210. {
  211. if ($this->targetClass === null) {
  212. throw new InvalidConfigException('The "targetClass" property must be set.');
  213. }
  214. if (!is_string($this->targetAttribute)) {
  215. throw new InvalidConfigException('The "targetAttribute" property must be configured as a string.');
  216. }
  217. if (is_array($value) && !$this->allowArray) {
  218. return [$this->message, []];
  219. }
  220. $query = $this->createQuery($this->targetClass, [$this->targetAttribute => $value]);
  221. return $this->valueExists($this->targetClass, $query, $value) ? null : [$this->message, []];
  222. }
  223. /**
  224. * Check whether value exists in target table
  225. *
  226. * @param string $targetClass
  227. * @param QueryInterface $query
  228. * @param mixed $value the value want to be checked
  229. * @return bool
  230. */
  231. private function valueExists($targetClass, $query, $value)
  232. {
  233. $db = $targetClass::getDb();
  234. $exists = false;
  235. if ($this->forceMasterDb && method_exists($db, 'useMaster')) {
  236. $db->useMaster(function ($db) use ($query, $value, &$exists) {
  237. $exists = $this->queryValueExists($query, $value);
  238. });
  239. } else {
  240. $exists = $this->queryValueExists($query, $value);
  241. }
  242. return $exists;
  243. }
  244. /**
  245. * Run query to check if value exists
  246. *
  247. * @param QueryInterface $query
  248. * @param mixed $value the value to be checked
  249. * @return bool
  250. */
  251. private function queryValueExists($query, $value)
  252. {
  253. if (is_array($value)) {
  254. return $query->count("DISTINCT [[$this->targetAttribute]]") == count($value) ;
  255. }
  256. return $query->exists();
  257. }
  258. /**
  259. * Creates a query instance with the given condition.
  260. * @param string $targetClass the target AR class
  261. * @param mixed $condition query condition
  262. * @return \yii\db\ActiveQueryInterface the query instance
  263. */
  264. protected function createQuery($targetClass, $condition)
  265. {
  266. /* @var $targetClass \yii\db\ActiveRecordInterface */
  267. $query = $targetClass::find()->andWhere($condition);
  268. if ($this->filter instanceof \Closure) {
  269. call_user_func($this->filter, $query);
  270. } elseif ($this->filter !== null) {
  271. $query->andWhere($this->filter);
  272. }
  273. return $query;
  274. }
  275. /**
  276. * Returns conditions with alias.
  277. * @param ActiveQuery $query
  278. * @param array $conditions array of condition, keys to be modified
  279. * @param null|string $alias set empty string for no apply alias. Set null for apply primary table alias
  280. * @return array
  281. */
  282. private function applyTableAlias($query, $conditions, $alias = null)
  283. {
  284. if ($alias === null) {
  285. $alias = array_keys($query->getTablesUsedInFrom())[0];
  286. }
  287. $prefixedConditions = [];
  288. foreach ($conditions as $columnName => $columnValue) {
  289. if (strpos($columnName, '(') === false) {
  290. $prefixedColumn = "{$alias}.[[" . preg_replace(
  291. '/^' . preg_quote($alias) . '\.(.*)$/',
  292. '$1',
  293. $columnName) . ']]';
  294. } else {
  295. // there is an expression, can't prefix it reliably
  296. $prefixedColumn = $columnName;
  297. }
  298. $prefixedConditions[$prefixedColumn] = $columnValue;
  299. }
  300. return $prefixedConditions;
  301. }
  302. }