diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 07daa9c8b4..ff292e4d77 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -146,6 +146,7 @@ Yii Framework 2 Change Log - New #1393: [Codeception testing framework integration](https://github.com/yiisoft/yii2-codeception) (Ragazzo) - New #1438: [MongoDB integration](https://github.com/yiisoft/yii2-mongodb) ActiveRecord and Query (klimov-paul) - New #1956: Implemented test fixture framework (qiangxue) +- New #2149: Added `yii\base\DynamicModel` to support ad-hoc data validation (qiangxue) - New: Yii framework now comes with core messages in multiple languages - New: Added yii\codeception\DbTestCase (qiangxue) diff --git a/framework/base/DynamicModel.php b/framework/base/DynamicModel.php new file mode 100644 index 0000000000..e4c409ef00 --- /dev/null +++ b/framework/base/DynamicModel.php @@ -0,0 +1,197 @@ + 128]], + * ['email', 'email'], + * ]); + * if ($model->hasErrors()) { + * // validation fails + * } else { + * // validation succeeds + * } + * } + * ``` + * + * The above example shows how to validate `$name` and `$email` with the help of DynamicModel. + * The [[validateData()]] method creates an instance of DynamicModel, defines the attributes + * using the given data (`name` and `email` in this example), and then calls [[Model::validate()]]. + * + * You can check the validation result by [[hasErrors()]], like you do with a normal model. + * You may also access the dynamic attributes defined through the model instance, e.g., + * `$model->name` and `$model->email`. + * + * Alternatively, you may use the following more "classic" syntax to perform ad-hoc data validation: + * + * ```php + * $model = new DynamicModel(compact('name', 'email')); + * $model->addRule(['name', 'email'], 'string', ['max' => 128]) + * ->addRule('email', 'email') + * ->validate(); + * ``` + * + * DynamicModel implements the above ad-hoc data validation feature by supporting the so-called + * "dynamic attributes". It basically allows an attribute to be defined dynamically through its constructor + * or [[defineAttribute()]]. + * + * @author Qiang Xue + * @since 2.0 + */ +class DynamicModel extends Model +{ + private $_attributes = []; + + /** + * Constructors. + * @param array $attributes the dynamic attributes (name-value pairs, or names) being defined + * @param array $config the configuration array to be applied to this object. + */ + public function __construct(array $attributes, $config = []) + { + foreach ($attributes as $name => $value) { + if (is_integer($name)) { + $this->_attributes[$value] = null; + } else { + $this->_attributes[$name] = $value; + } + } + } + + /** + * @inheritdoc + */ + public function __get($name) + { + if (array_key_exists($name, $this->_attributes)) { + return $this->_attributes[$name]; + } else { + return parent::__get($name); + } + } + + /** + * @inheritdoc + */ + public function __set($name, $value) + { + if (array_key_exists($name, $this->_attributes)) { + $this->_attributes[$name] = $value; + } else { + parent::__set($name, $value); + } + } + + /** + * @inheritdoc + */ + public function __isset($name) + { + if (array_key_exists($name, $this->_attributes)) { + return isset($this->_attributes[$name]); + } else { + return parent::__isset($name); + } + } + + /** + * @inheritdoc + */ + public function __unset($name) + { + if (array_key_exists($name, $this->_attributes)) { + unset($this->_attributes[$name]); + } else { + parent::__unset($name); + } + } + + /** + * Defines an attribute. + * @param string $name the attribute name + * @param mixed $value the attribute value + */ + public function defineAttribute($name, $value = null) + { + $this->_attributes[$name] = $value; + } + + /** + * Undefines an attribute. + * @param string $name the attribute name + */ + public function undefineAttribute($name) + { + unset($this->_attributes[$name]); + } + + /** + * Adds a validation rule to this model. + * You can also directly manipulate [[validators]] to add or remove validation rules. + * This method provides a shortcut. + * @param string|array $attributes the attribute(s) to be validated by the rule + * @param mixed $validator the validator for the rule.This can be a built-in validator name, + * a method name of the model class, an anonymous function, or a validator class name. + * @param array $options the options (name-value pairs) to be applied to the validator + * @return static the model itself + */ + public function addRule($attributes, $validator, $options = []) + { + $validators = $this->getValidators(); + $validators->append(Validator::createValidator($validator, $this, (array)$attributes, $options)); + return $this; + } + + /** + * Validates the given data with the specified validation rules. + * This method will create a DynamicModel instance, populate it with the data to be validated, + * create the specified validation rules, and then validate the data using these rules. + * @param array $data the data (name-value pairs) to be validated + * @param array $rules the validation rules. Please refer to [[Model::rules()]] on the format of this parameter. + * @return static the model instance that contains the data being validated + * @throws InvalidConfigException if a validation rule is not specified correctly. + */ + public static function validateData(array $data, $rules = []) + { + /** @var DynamicModel $model */ + $model = new static($data); + if (!empty($rules)) { + $validators = $model->getValidators(); + foreach ($rules as $rule) { + if ($rule instanceof Validator) { + $validators->append($rule); + } elseif (is_array($rule) && isset($rule[0], $rule[1])) { // attributes, validator type + $validator = Validator::createValidator($rule[1], $model, (array) $rule[0], array_slice($rule, 2)); + $validators->append($validator); + } else { + throw new InvalidConfigException('Invalid validation rule: a rule must specify both attribute names and validator type.'); + } + } + $model->validate(); + } + return $model; + } + + /** + * @inheritdoc + */ + public function attributes() + { + return array_keys($this->_attributes); + } +} diff --git a/framework/base/Model.php b/framework/base/Model.php index d4a476ad1d..f12bd82d69 100644 --- a/framework/base/Model.php +++ b/framework/base/Model.php @@ -102,8 +102,8 @@ class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayab * where * * - attribute list: required, specifies the attributes array to be validated, for single attribute you can pass string; - * - validator type: required, specifies the validator to be used. It can be the name of a model - * class method, the name of a built-in validator, or a validator class name (or its path alias). + * - validator type: required, specifies the validator to be used. It can be a built-in validator name, + * a method name of the model class, an anonymous function, or a validator class name. * - on: optional, specifies the [[scenario|scenarios]] array when the validation * rule can be applied. If this option is not set, the rule will apply to all scenarios. * - additional name-value pairs can be specified to initialize the corresponding validator properties. diff --git a/framework/validators/Validator.php b/framework/validators/Validator.php index 4b04b8783b..8440fb2eb4 100644 --- a/framework/validators/Validator.php +++ b/framework/validators/Validator.php @@ -124,8 +124,8 @@ class Validator extends Component /** * Creates a validator object. - * @param string $type the validator type. This can be a method name, - * a built-in validator name, a class name, or a path alias of the validator class. + * @param mixed $type the validator type. This can be a built-in validator name, + * a method name of the model class, an anonymous function, or a validator class name. * @param \yii\base\Model $object the data object to be validated. * @param array|string $attributes list of attributes to be validated. This can be either an array of * the attribute names or a string of comma-separated attribute names. @@ -136,7 +136,7 @@ class Validator extends Component { $params['attributes'] = $attributes; - if (method_exists($object, $type)) { + if ($type instanceof \Closure || method_exists($object, $type)) { // method-based validator $params['class'] = __NAMESPACE__ . '\InlineValidator'; $params['method'] = $type; diff --git a/tests/unit/framework/base/DynamicModelTest.php b/tests/unit/framework/base/DynamicModelTest.php new file mode 100644 index 0000000000..f070e2983d --- /dev/null +++ b/tests/unit/framework/base/DynamicModelTest.php @@ -0,0 +1,52 @@ + + * @since 2.0 + */ +class DynamicModelTest extends TestCase +{ + protected function setUp() + { + parent::setUp(); + $this->mockApplication(); + } + + public function testValidateData() + { + $email = 'invalid'; + $name = 'long name'; + $age = ''; + $model = DynamicModel::validateData(compact('name', 'email', 'age'), [ + [['email', 'name', 'age'], 'required'], + ['email', 'email'], + ['name', 'string', 'max' => 3], + ]); + $this->assertTrue($model->hasErrors()); + $this->assertTrue($model->hasErrors('email')); + $this->assertTrue($model->hasErrors('name')); + $this->assertTrue($model->hasErrors('age')); + } + + public function testDynamicProperty() + { + $email = 'invalid'; + $name = 'long name'; + $model = new DynamicModel(compact('name', 'email')); + $this->assertEquals($email, $model->email); + $this->assertEquals($name, $model->name); + $this->setExpectedException('yii\base\UnknownPropertyException'); + $age = $model->age; + } +}