diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index cf6eb5a..fb51798 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -16,6 +16,7 @@ Yii Framework 2 Change Log - Bug: Fixed incorrect event name for `yii\jui\Spinner` (samdark) - Bug: Json::encode() did not handle objects that implement JsonSerializable interface correctly (cebe) - Bug: Fixed issue with tabular input on ActiveField::radio() and ActiveField::checkbox() (jom) +- Enh #797: Added support for validating multiple columns by `UniqueValidator` and `ExistValidator` (qiangxue) - Enh #1293: Replaced Console::showProgress() with a better approach. See Console::startProgress() for details (cebe) - Enh #1406: DB Schema support for Oracle Database (p0larbeer, qiangxue) - Enh #1437: Added ListView::viewParams (qiangxue) diff --git a/framework/yii/validators/ExistValidator.php b/framework/yii/validators/ExistValidator.php index 04d8af8..c205655 100644 --- a/framework/yii/validators/ExistValidator.php +++ b/framework/yii/validators/ExistValidator.php @@ -30,10 +30,25 @@ class ExistValidator extends Validator */ public $className; /** - * @var string the yii\db\ActiveRecord class attribute name that should be + * @var string|array the ActiveRecord class attribute name that should be * used to look for the attribute value being validated. Defaults to null, - * meaning using the name of the attribute being validated. - * @see className + * meaning using the name of the attribute being validated. Use a string + * to specify the attribute that is different from the attribute being validated + * (often used together with [[className]]). Use an array to validate the existence about + * multiple columns. For example, + * + * ```php + * // a1 needs to exist + * array('a1', 'exist') + * // a1 needs to exist, but its value will use a2 to check for the existence + * array('a1', 'exist', 'attributeName' => 'a2') + * // a1 and a2 need to exist together, and they both will receive error message + * array('a1, a2', 'exist', 'attributeName' => array('a1', 'a2')) + * // a1 and a2 need to exist together, only a1 will receive error message + * array('a1', 'exist', 'attributeName' => array('a1', 'a2')) + * // a1 and a2 need to exist together, a2 will take value 10, only a1 will receive error message + * array('a1', 'exist', 'attributeName' => array('a1', 'a2' => 10)) + * ``` */ public $attributeName; @@ -64,9 +79,7 @@ class ExistValidator extends Validator /** @var \yii\db\ActiveRecordInterface $className */ $className = $this->className === null ? get_class($object) : $this->className; $attributeName = $this->attributeName === null ? $attribute : $this->attributeName; - $query = $className::find(); - $query->where([$attributeName => $value]); - if (!$query->exists()) { + if (!$this->exists($className, $attributeName, $object, $value)) { $this->addError($object, $attribute, $this->message); } } @@ -85,10 +98,33 @@ class ExistValidator extends Validator if ($this->attributeName === null) { throw new InvalidConfigException('The "attributeName" property must be set.'); } + return $this->exists($this->className, $this->attributeName, null, $value) ? null : [$this->message, []]; + } + + /** + * Performs existence check. + * @param string $className the AR class name to be checked against + * @param string|array $attributeName the attribute(s) to be checked + * @param \yii\db\ActiveRecordInterface $object the object whose value is being validated + * @param mixed $value the attribute value currently being validated + * @return boolean whether the data being validated exists in the database already + */ + protected function exists($className, $attributeName, $object, $value) + { /** @var \yii\db\ActiveRecordInterface $className */ - $className = $this->className; $query = $className::find(); - $query->where([$this->attributeName => $value]); - return $query->exists() ? null : [$this->message, []]; + if (is_array($attributeName)) { + $params = []; + foreach ($attributeName as $k => $v) { + if (is_integer($k)) { + $params[$v] = $this->className === null && $object !== null ? $object->$v : $value; + } else { + $params[$k] = $v; + } + } + } else { + $params = [$attributeName => $value]; + } + return $query->where($params)->exists(); } } diff --git a/framework/yii/validators/UniqueValidator.php b/framework/yii/validators/UniqueValidator.php index 53b6739..a497ead 100644 --- a/framework/yii/validators/UniqueValidator.php +++ b/framework/yii/validators/UniqueValidator.php @@ -26,9 +26,25 @@ class UniqueValidator extends Validator */ public $className; /** - * @var string the ActiveRecord class attribute name that should be + * @var string|array the ActiveRecord class attribute name that should be * used to look for the attribute value being validated. Defaults to null, - * meaning using the name of the attribute being validated. + * meaning using the name of the attribute being validated. Use a string + * to specify the attribute that is different from the attribute being validated + * (often used together with [[className]]). Use an array to validate uniqueness about + * multiple columns. For example, + * + * ```php + * // a1 needs to be unique + * array('a1', 'unique') + * // a1 needs to be unique, but its value will use a2 to check for the uniqueness + * array('a1', 'unique', 'attributeName' => 'a2') + * // a1 and a2 need to unique together, and they both will receive error message + * array('a1, a2', 'unique', 'attributeName' => array('a1', 'a2')) + * // a1 and a2 need to unique together, only a1 will receive error message + * array('a1', 'unique', 'attributeName' => array('a1', 'a2')) + * // a1 and a2 need to unique together, a2 will take value 10, only a1 will receive error message + * array('a1', 'unique', 'attributeName' => array('a1', 'a2' => 10)) + * ``` */ public $attributeName; @@ -60,7 +76,20 @@ class UniqueValidator extends Validator $attributeName = $this->attributeName === null ? $attribute : $this->attributeName; $query = $className::find(); - $query->where([$attributeName => $value]); + + if (is_array($attributeName)) { + $params = []; + foreach ($attributeName as $k => $v) { + if (is_integer($k)) { + $params[$v] = $this->className === null ? $object->$v : $value; + } else { + $params[$k] = $v; + } + } + } else { + $params = [$attributeName => $value]; + } + $query->where($params); if (!$object instanceof ActiveRecordInterface || $object->getIsNewRecord()) { // if current $object isn't in the database yet then it's OK just to call exists() @@ -71,7 +100,11 @@ class UniqueValidator extends Validator $objects = $query->limit(2)->all(); $n = count($objects); if ($n === 1) { - if (in_array($attributeName, $className::primaryKey())) { + $keys = array_keys($params); + $pks = $className::primaryKey(); + sort($keys); + sort($pks); + if ($keys === $pks) { // primary key is modified and not unique $exists = $object->getOldPrimaryKey() != $object->getPrimaryKey(); } else { diff --git a/tests/unit/framework/validators/ExistValidatorTest.php b/tests/unit/framework/validators/ExistValidatorTest.php index 45ff5d5..03332ad 100644 --- a/tests/unit/framework/validators/ExistValidatorTest.php +++ b/tests/unit/framework/validators/ExistValidatorTest.php @@ -7,6 +7,8 @@ use Yii; use yii\base\Exception; use yii\validators\ExistValidator; use yiiunit\data\ar\ActiveRecord; +use yiiunit\data\ar\Order; +use yiiunit\data\ar\OrderItem; use yiiunit\data\validators\models\ValidatorTestMainModel; use yiiunit\data\validators\models\ValidatorTestRefModel; use yiiunit\framework\db\DatabaseTestCase; @@ -92,4 +94,44 @@ class ExistValidatorTest extends DatabaseTestCase $val->validateAttribute($m, 'test_val'); $this->assertTrue($m->hasErrors('test_val')); } + + public function testValidateCompositeKeys() + { + $val = new ExistValidator([ + 'className' => OrderItem::className(), + 'attributeName' => ['order_id', 'item_id'], + ]); + // validate old record + $m = OrderItem::find(['order_id' => 1, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertFalse($m->hasErrors('order_id')); + + // validate new record + $m = new OrderItem(['order_id' => 1, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertFalse($m->hasErrors('order_id')); + $m = new OrderItem(['order_id' => 10, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertTrue($m->hasErrors('order_id')); + + $val = new ExistValidator([ + 'className' => OrderItem::className(), + 'attributeName' => ['order_id', 'item_id' => 2], + ]); + // validate old record + $m = Order::find(1); + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + $m = Order::find(1); + $m->id = 10; + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + + $m = new Order(['id' => 1]); + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + $m = new Order(['id' => 10]); + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + } } diff --git a/tests/unit/framework/validators/UniqueValidatorTest.php b/tests/unit/framework/validators/UniqueValidatorTest.php index 707239c..1631243 100644 --- a/tests/unit/framework/validators/UniqueValidatorTest.php +++ b/tests/unit/framework/validators/UniqueValidatorTest.php @@ -6,6 +6,8 @@ namespace yiiunit\framework\validators; use yii\validators\UniqueValidator; use Yii; use yiiunit\data\ar\ActiveRecord; +use yiiunit\data\ar\Order; +use yiiunit\data\ar\OrderItem; use yiiunit\data\validators\models\FakedValidationModel; use yiiunit\data\validators\models\ValidatorTestMainModel; use yiiunit\data\validators\models\ValidatorTestRefModel; @@ -85,4 +87,49 @@ class UniqueValidatorTest extends DatabaseTestCase $m = new ValidatorTestMainModel(); $val->validateAttribute($m, 'testMainVal'); } + + public function testValidateCompositeKeys() + { + $val = new UniqueValidator([ + 'className' => OrderItem::className(), + 'attributeName' => ['order_id', 'item_id'], + ]); + // validate old record + $m = OrderItem::find(['order_id' => 1, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertFalse($m->hasErrors('order_id')); + $m->item_id = 1; + $val->validateAttribute($m, 'order_id'); + $this->assertTrue($m->hasErrors('order_id')); + + // validate new record + $m = new OrderItem(['order_id' => 1, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertTrue($m->hasErrors('order_id')); + $m = new OrderItem(['order_id' => 10, 'item_id' => 2]); + $val->validateAttribute($m, 'order_id'); + $this->assertFalse($m->hasErrors('order_id')); + + $val = new UniqueValidator([ + 'className' => OrderItem::className(), + 'attributeName' => ['order_id', 'item_id' => 2], + ]); + // validate old record + $m = Order::find(1); + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + $m->id = 2; + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + $m->id = 3; + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + + $m = new Order(['id' => 1]); + $val->validateAttribute($m, 'id'); + $this->assertTrue($m->hasErrors('id')); + $m = new Order(['id' => 10]); + $val->validateAttribute($m, 'id'); + $this->assertFalse($m->hasErrors('id')); + } }