From 2b9b0c71660d5f56407e4df268fc8c8844edb089 Mon Sep 17 00:00:00 2001 From: bscheshirwork Date: Mon, 14 Aug 2017 01:03:10 +0300 Subject: [PATCH] Fixes #14151: Added `AttributesBehavior` that assigns values specified to one or multiple attributes of an AR object when certain events happen --- framework/CHANGELOG.md | 1 + framework/behaviors/AttributesBehavior.php | 181 ++++++++++++++++++ .../framework/behaviors/AttributesBehaviorTest.php | 208 +++++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 framework/behaviors/AttributesBehavior.php create mode 100644 tests/framework/behaviors/AttributesBehaviorTest.php diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 3972e95..7e993fb 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -4,6 +4,7 @@ Yii Framework 2 Change Log 2.0.13 under development ------------------------ +- New #14151: Added `AttributesBehavior` that assigns values specified to one or multiple attributes of an AR object when certain events happen (bscheshirwork) - Bug #6526: Fixed `yii\db\Command::batchInsert()` casting of double values correctly independent of the locale (cebe, leammas) - Bug #14542: Ensured only ASCII characters are in CSRF cookie value since binary data causes issues with ModSecurity and some browsers (samdark) - Enh #14022: `yii\web\UrlManager::setBaseUrl()` now supports aliases (dmirogin) diff --git a/framework/behaviors/AttributesBehavior.php b/framework/behaviors/AttributesBehavior.php new file mode 100644 index 0000000..fbc2b4c --- /dev/null +++ b/framework/behaviors/AttributesBehavior.php @@ -0,0 +1,181 @@ + AttributesBehavior::className(), + * 'attributes' => [ + * 'attribute1' => [ + * ActiveRecord::EVENT_BEFORE_INSERT => new Expression('NOW()'), + * ActiveRecord::EVENT_BEFORE_UPDATE => \Yii::$app->formatter->asDatetime('2017-07-13'), + * ], + * 'attribute2' => [ + * ActiveRecord::EVENT_BEFORE_VALIDATE => [$this, 'storeAttributes'], + * ActiveRecord::EVENT_AFTER_VALIDATE => [$this, 'restoreAttributes'], + * ], + * 'attribute3' => [ + * ActiveRecord::EVENT_BEFORE_VALIDATE => $fn2 = [$this, 'getAttribute2'], + * ActiveRecord::EVENT_AFTER_VALIDATE => $fn2, + * ], + * 'attribute4' => [ + * ActiveRecord::EVENT_BEFORE_DELETE => function($event) {static::disabled() || $event->isValid = false;}, + * ], + * ], + * ], + * ]; + * } + * ``` + * + * Because attribute values will be set automatically by this behavior, they are usually not user input and should therefore + * not be validated, i.e. they should not appear in the [[\yii\base\Model::rules()|rules()]] method of the model. + * + * @author Luciano Baraglia + * @author Qiang Xue + * @author Bogdan Stepanenko + * @since 2.0.13 + */ +class AttributesBehavior extends Behavior +{ + /** + * @var array list of attributes that are to be automatically filled with the values specified via enclosed arrays. + * The array keys are the ActiveRecord attributes upon which the events are to be updated, + * and the array values are the array of corresponding events(s). For this enclosed array: + * the array keys are the ActiveRecord events upon which the attributes are to be updated, + * and the array values are the value that will be assigned to the current attributes. This can be an anonymous function, + * callable in array format (e.g. `[$this, 'methodName']`), an [[\yii\db\Expression|Expression]] object representing a DB expression + * (e.g. `new Expression('NOW()')`), scalar, string or an arbitrary value. If the former, the return value of the + * function will be assigned to the attributes. + * + * ```php + * [ + * 'attribute1' => [ + * ActiveRecord::EVENT_BEFORE_INSERT => new Expression('NOW()'), + * ActiveRecord::EVENT_BEFORE_UPDATE => \Yii::$app->formatter->asDatetime('2017-07-13'), + * ], + * 'attribute2' => [ + * ActiveRecord::EVENT_BEFORE_VALIDATE => [$this, 'storeAttributes'], + * ActiveRecord::EVENT_AFTER_VALIDATE => [$this, 'restoreAttributes'], + * ], + * 'attribute3' => [ + * ActiveRecord::EVENT_BEFORE_VALIDATE => $fn2 = [$this, 'getAttribute2'], + * ActiveRecord::EVENT_AFTER_VALIDATE => $fn2, + * ], + * 'attribute4' => [ + * ActiveRecord::EVENT_BEFORE_DELETE => function($event) {static::disabled() || $event->isValid = false;}, + * ], + * ] + * ``` + */ + public $attributes = []; + /** + * @var array list of order of attributes that are to be automatically filled with the event. + * The array keys are the ActiveRecord events upon which the attributes are to be updated, + * and the array values are represent the order corresponding attributes. + * The rest of the attributes are processed at the end. + * If the [[attributes]] for this attribute do not specify this event, it is ignored + * + * ```php + * [ + * ActiveRecord::EVENT_BEFORE_VALIDATE => ['attribute1', 'attribute2'], + * ActiveRecord::EVENT_AFTER_VALIDATE => ['attribute2', 'attribute1'], + * ] + * ``` + */ + public $order = []; + /** + * @var bool whether to skip this behavior when the `$owner` has not been modified + */ + public $skipUpdateOnClean = true; + /** + * @var bool whether to preserve non-empty attribute values. + */ + public $preserveNonEmptyValues = false; + + + /** + * @inheritdoc + */ + public function events() + { + return array_fill_keys( + array_reduce($this->attributes, function ($carry, $item) { + return array_merge($carry, array_keys($item)); + }, []), + 'evaluateAttributes' + ); + } + + /** + * Evaluates the attributes values and assigns it to the current attributes. + * @param Event $event + */ + public function evaluateAttributes($event) + { + if ($this->skipUpdateOnClean + && $event->name == ActiveRecord::EVENT_BEFORE_UPDATE + && empty($this->owner->dirtyAttributes) + ) { + return; + } + $attributes = array_keys(array_filter($this->attributes, function ($carry) use ($event) { + return array_key_exists($event->name, $carry); + })); + if (!empty($this->order[$event->name])) { + $attributes = array_merge( + array_intersect((array)$this->order[$event->name], $attributes), + array_diff($attributes, (array)$this->order[$event->name])); + } + foreach ($attributes as $attribute) { + if ($this->preserveNonEmptyValues && !empty($this->owner->$attribute)) { + continue; + } + $this->owner->$attribute = $this->getValue($attribute, $event); + } + } + + /** + * Returns the value for the current attributes. + * This method is called by [[evaluateAttributes()]]. Its return value will be assigned + * to the target attribute corresponding to the triggering event. + * @param string $attribute target attribute name + * @param Event $event the event that triggers the current attribute updating. + * @return mixed the attribute value + */ + protected function getValue($attribute, $event) + { + if (!isset($this->attributes[$attribute][$event])) { + return null; + } + $value = $this->attributes[$attribute][$event]; + if ($value instanceof Closure || is_array($value) && is_callable($value)) { + return call_user_func($value, $attribute, $event); + } + + return $value; + } +} diff --git a/tests/framework/behaviors/AttributesBehaviorTest.php b/tests/framework/behaviors/AttributesBehaviorTest.php new file mode 100644 index 0000000..e6fc089 --- /dev/null +++ b/tests/framework/behaviors/AttributesBehaviorTest.php @@ -0,0 +1,208 @@ +mockApplication([ + 'components' => [ + 'db' => [ + 'class' => '\yii\db\Connection', + 'dsn' => 'sqlite::memory:', + ], + ], + ]); + + $columns = [ + 'id' => 'pk', + 'name' => 'string', + 'alias' => 'string', + ]; + Yii::$app->getDb()->createCommand()->createTable('test_attribute', $columns)->execute(); + } + + public function tearDown() + { + Yii::$app->getDb()->close(); + parent::tearDown(); + } + + // Tests : + + /** + * @return array + */ + public function preserveNonEmptyValuesDataProvider() + { + return [ + [ + 'John Doe', + false, + 'John Doe', + null, + ], + [ + 'John Doe', + false, + 'John Doe', + 'Johnny', + ], + [ + 'John Doe', + true, + 'John Doe', + null, + ], + [ + 'Johnny', + true, + 'John Doe', + 'Johnny', + ], + ]; + } + + /** + * @dataProvider preserveNonEmptyValuesDataProvider + */ + public function testPreserveNonEmptyValues( + $aliasExpected, + $preserveNonEmptyValues, + $name, + $alias + ) + { + $model = new ActiveRecordWithAttributesBehavior(); + $model->attributesBehavior->preserveNonEmptyValues = $preserveNonEmptyValues; + $model->name = $name; + $model->alias = $alias; + $model->validate(); + + $this->assertEquals($aliasExpected, $model->alias); + } + + /** + * @return array + */ + public function orderProvider() + { + return [ + [ + 'Johnny', + [ActiveRecordWithAttributesBehavior::EVENT_BEFORE_VALIDATE => ['name', 'alias']], + // 1: name = alias; 2: alias = name; check alias + 'John Doe', // name + 'Johnny', // alias + ], + [ + 'John Doe', + [ActiveRecordWithAttributesBehavior::EVENT_BEFORE_VALIDATE => ['alias', 'name']], + // 2: alias = name; 1: name = alias; check alias + 'John Doe', // name + 'Johnny', // alias + ], + ]; + } + + /** + * @dataProvider orderProvider + */ + public function testOrder( + $aliasExpected, + $order, + $name, + $alias + ) + { + $model = new ActiveRecordWithAttributesBehavior(); + $model->attributesBehavior->order = $order; + $model->name = $name; + $model->alias = $alias; + $model->validate(); + + $this->assertEquals($aliasExpected, $model->alias); + } +} + +/** + * Test Active Record class with [[AttributesBehavior]] behavior attached. + * + * @property int $id + * @property string $name + * @property string $alias + * + * @property AttributesBehavior $attributesBehavior + */ +class ActiveRecordWithAttributesBehavior extends ActiveRecord +{ + /** + * @inheritdoc + */ + public function behaviors() + { + return [ + 'attributes' => [ + 'class' => AttributesBehavior::className(), + 'attributes' => [ + 'alias' => [ + self::EVENT_BEFORE_VALIDATE => function ($event) { + return $event->sender->name; + }, + ], + 'name' => [ + self::EVENT_BEFORE_VALIDATE => function ($event) { + return $event->sender->alias; + }, + ], + ], + ], + ]; + } + + /** + * @inheritdoc + */ + public static function tableName() + { + return 'test_attribute'; + } + + /** + * @return AttributesBehavior + */ + public function getAttributesBehavior() + { + return $this->getBehavior('attributes'); + } +}