diff --git a/src/AttributeTypecastBehavior.php b/src/AttributeTypecastBehavior.php index 955eca5..9319a92 100644 --- a/src/AttributeTypecastBehavior.php +++ b/src/AttributeTypecastBehavior.php @@ -13,7 +13,6 @@ /** * @property \CModel|\CActiveRecord $owner The owner component that this behavior is attached to. - * @property array $attributeTypes * * @author Paul Klimov * @since 1.0 @@ -24,9 +23,13 @@ class AttributeTypecastBehavior extends CBehavior const TYPE_FLOAT = 'float'; const TYPE_BOOLEAN = 'boolean'; const TYPE_STRING = 'string'; + const TYPE_ARRAY = 'array'; + const TYPE_ARRAY_OBJECT = 'array-object'; + const TYPE_DATETIME = 'datetime'; + const TYPE_TIMESTAMP = 'timestamp'; /** - * @var array|null attribute typecast map in format: attributeName => type. + * @var array|null attribute typecast map in format: attributeName => type. * Type can be set via PHP callable, which accept raw value as an argument and should return * typecast result. * For example: @@ -44,7 +47,7 @@ class AttributeTypecastBehavior extends CBehavior * * If not set, attribute type map will be composed automatically from the owner validation rules. */ - private $_attributeTypes; + public $attributeTypes; /** * @var bool whether to skip typecasting of `null` values. * If enabled attribute value which equals to `null` will not be type-casted (e.g. `null` remains `null`), @@ -74,7 +77,7 @@ class AttributeTypecastBehavior extends CBehavior * Note that changing this option value will have no effect after this behavior has been attached to the model. * @since 2.0.14 */ - public $typecastAfterSave = false; + public $typecastAfterSave = true; /** * @var bool whether to perform typecasting after retrieving owner model data from * the database (after find or refresh). @@ -86,31 +89,26 @@ class AttributeTypecastBehavior extends CBehavior public $typecastAfterFind = true; /** - * @var array internal static cache for auto detected [[attributeTypes]] values - * in format: ownerClassName => attributeTypes + * @var array stashed raw attributes, used to transfer raw non-scalar values from {@see beforeSave()} to {@see afterSave()}. */ - private static $autoDetectedAttributeTypes = []; + private $_stashedAttributes = []; /** - * @return array + * @var array internal static cache for auto detected [[attributeTypes]] values + * in format: ownerClassName => attributeTypes */ - public function getAttributeTypes(): array - { - if ($this->_attributeTypes === null) { - $this->_attributeTypes = $this->detectAttributeTypes(); - } - - return $this->_attributeTypes; - } + private static $autoDetectedAttributeTypes = []; /** - * @param array $attributeTypes + * {@inheritdoc} */ - public function setAttributeTypes(array $attributeTypes): self + public function attach($owner): void { - $this->_attributeTypes = $attributeTypes; + parent::attach($owner); - return $this; + if ($this->attributeTypes === null) { + $this->attributeTypes = $this->detectAttributeTypes(); + } } protected function detectAttributeTypes(): array @@ -173,26 +171,48 @@ public function typecastAttributes($attributeNames = null) */ protected function typecastValue($value, $type) { - if (is_scalar($type)) { - if (is_object($value) && method_exists($value, '__toString')) { - $value = $value->__toString(); - } - - switch ($type) { - case self::TYPE_INTEGER: - return (int) $value; - case self::TYPE_FLOAT: - return (float) $value; - case self::TYPE_BOOLEAN: - return (bool) $value; - case self::TYPE_STRING: - return (string) $value; - default: - throw new InvalidArgumentException("Unsupported type '{$type}'"); - } + if (!is_scalar($type)) { + return call_user_func($type, $value); } - return call_user_func($type, $value); + switch ($type) { + case self::TYPE_INTEGER: + case 'int': + return (int) $value; + case self::TYPE_FLOAT: + return (float) $value; + case self::TYPE_BOOLEAN: + case 'bool': + return (bool) $value; + case self::TYPE_STRING: + return (string) $value; + case self::TYPE_ARRAY: + if ($value === null || is_iterable($value)) { + return $value; + } + + return json_decode($value, true); + case self::TYPE_ARRAY_OBJECT: + if ($value === null || is_iterable($value)) { + return $value; + } + + return new \ArrayObject(json_decode($value, true)); + case self::TYPE_DATETIME: + if ($value === null || $value instanceof \DateTime) { + return $value; + } + + return \DateTime::createFromFormat('Y-m-d H:i:s', (string) $value); + case self::TYPE_TIMESTAMP: + if ($value === null || $value instanceof \DateTime) { + return $value; + } + + return (new \DateTime())->setTimestamp((int) $value); + default: + throw new InvalidArgumentException("Unsupported attribute type '{$type}'"); + } } /** @@ -220,6 +240,76 @@ protected function detectAttributeTypesFromRules(): array return $attributeTypes; } + /** + * Stashes original raw value of attribute for the future restoration. + * + * @param string $name attribute name. + * @param mixed $value attribute raw value. + * @return void + */ + private function stashAttribute(string $name, $value): void + { + $this->_stashedAttributes[$name] = $value; + } + + /** + * Applies all stashed attribute values to the owner. + * + * @return void + */ + private function applyStashedAttributes(): void + { + foreach ($this->_stashedAttributes as $name => $value) { + $this->owner->setAttribute($name, $value); + unset($this->_stashedAttributes[$name]); + } + } + + /** + * Performs typecast for attributes values in the way they are suitable for the saving in database. + * E.g. convert objects and arrays to scalars. + * + * @return void + */ + protected function typecastAttributesForSaving(): void + { + foreach ($this->owner->getAttributes() as $name => $value) { + if ($value === null || is_scalar($value)) { + continue; + } + + if ($value instanceof \CDbExpression) { + continue; + } + + $this->stashAttribute($name, $value); + + if (is_array($value) || $value instanceof \JsonSerializable) { + $this->owner->setAttribute($name, json_encode($value)); + + continue; + } + + if ($value instanceof \DateTime) { + if (isset($this->attributeTypes[$name]) && $this->attributeTypes[$name] === self::TYPE_TIMESTAMP) { + $this->owner->setAttribute($name, $value->getTimestamp()); + } else { + $this->owner->setAttribute($name, $value->format('Y-m-d H:i:s')); + } + + continue; + } + + if ($value instanceof \Traversable) { + $this->owner->setAttribute($name, json_encode(iterator_to_array($value))); + + continue; + } + + $this->owner->setAttribute($name, (string) $value); + } + } + // Event Handlers: /** @@ -234,13 +324,8 @@ public function events(): array } if ($this->getOwner() instanceof CActiveRecord) { - if ($this->typecastBeforeSave) { - $events['onBeforeSave'] = 'beforeSave'; - } - - if ($this->typecastAfterSave) { - $events['onAfterSave'] = 'afterSave'; - } + $events['onBeforeSave'] = 'beforeSave'; + $events['onAfterSave'] = 'afterSave'; if ($this->typecastAfterFind) { $events['onAfterFind'] = 'afterFind'; @@ -267,7 +352,11 @@ public function afterValidate(CEvent $event): void */ public function beforeSave(CModelEvent $event): void { - $this->typecastAttributes(); + if ($this->typecastBeforeSave) { + $this->typecastAttributes(); + } + + $this->typecastAttributesForSaving(); } /** @@ -276,7 +365,11 @@ public function beforeSave(CModelEvent $event): void */ public function afterSave(CEvent $event): void { - $this->typecastAttributes(); + $this->applyStashedAttributes(); + + if ($this->typecastAfterSave) { + $this->typecastAttributes(); + } } /** diff --git a/tests/AttributeTypecastBehaviorTest.php b/tests/AttributeTypecastBehaviorTest.php index dd9c70e..3b91b5b 100644 --- a/tests/AttributeTypecastBehaviorTest.php +++ b/tests/AttributeTypecastBehaviorTest.php @@ -2,6 +2,8 @@ namespace yii1tech\model\typecast\test; +use ArrayObject; +use DateTime; use yii1tech\model\typecast\AttributeTypecastBehavior; use yii1tech\model\typecast\test\data\Item; use yii1tech\model\typecast\test\data\ItemWithTypecast; @@ -151,4 +153,76 @@ public function testSkipNotSelectedAttribute() $model->refresh(); $this->assertSame(58, $model->category_id); } + + /** + * @depends testTypecast + */ + public function testDateTime(): void + { + $createdDateTime = new DateTime('yesterday'); + + $model = new ItemWithTypecast(); + $model->created_date = $createdDateTime; + $model->created_timestamp = $createdDateTime; + $model->save(false); + + $this->assertSame($createdDateTime, $model->created_date); + $this->assertSame($createdDateTime, $model->created_timestamp); + + $model = ItemWithTypecast::model()->findByPk($model->id); + + $this->assertSame($createdDateTime->getTimestamp(), $model->created_date->getTimestamp()); + $this->assertSame($createdDateTime->getTimestamp(), $model->created_timestamp->getTimestamp()); + } + + /** + * @depends testTypecast + */ + public function testArray(): void + { + $array = [ + 'foo' => 'bar', + ]; + + $model = new ItemWithTypecast(); + $model->data_array = $array; + $model->save(false); + + $this->assertSame($array, $model->data_array); + + $model = ItemWithTypecast::model()->findByPk($model->id); + + $this->assertSame($array, $model->data_array); + } + + /** + * @depends testTypecast + */ + public function testArrayObject(): void + { + $array = [ + 'foo' => 'bar', + ]; + $arrayObject = new ArrayObject($array); + + $model = new ItemWithTypecast(); + $model->data_array_object = $arrayObject; + $model->save(false); + + $this->assertSame($arrayObject, $model->data_array_object); + + $model = ItemWithTypecast::model()->findByPk($model->id); + + $this->assertNotSame($arrayObject, $model->data_array_object); + $this->assertSame($arrayObject->getArrayCopy(), $model->data_array_object->getArrayCopy()); + + $model = new ItemWithTypecast(); + $model->data_array_object = $array; + $model->save(false); + + $this->assertSame($array, $model->data_array_object); + + $model = ItemWithTypecast::model()->findByPk($model->id); + $this->assertSame($array, $model->data_array_object->getArrayCopy()); + } } \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 7875580..b293b3a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -81,9 +81,11 @@ protected function setupTestDbData() 'name' => 'string', 'price' => 'float', 'is_active' => 'boolean DEFAULT 0', - 'created_at' => 'integer', + 'created_timestamp' => 'integer', 'created_date' => 'datetime', 'callback' => 'string', + 'data_array' => 'json', + 'data_array_object' => 'json', ]); // Data : @@ -94,14 +96,14 @@ protected function setupTestDbData() 'category_id' => 1, 'name' => 'item1', 'is_active' => 0, - 'created_at' => time(), + 'created_timestamp' => time(), 'created_date' => date('Y-m-d H:i:s'), ], [ 'category_id' => 2, 'name' => 'item2', 'is_active' => 1, - 'created_at' => time(), + 'created_timestamp' => time(), 'created_date' => date('Y-m-d H:i:s'), ], ])->execute(); diff --git a/tests/data/Item.php b/tests/data/Item.php index df4b3bb..45fd183 100644 --- a/tests/data/Item.php +++ b/tests/data/Item.php @@ -10,9 +10,11 @@ * @property string $name * @property float $price * @property bool $is_active - * @property int $created_at + * @property int $created_timestamp * @property string $created_date * @property string $callback + * @property array|string $data_array + * @property \ArrayObject|string $data_array_object */ class Item extends CActiveRecord { diff --git a/tests/data/ItemWithTypecast.php b/tests/data/ItemWithTypecast.php index 53ddd50..4721dd5 100644 --- a/tests/data/ItemWithTypecast.php +++ b/tests/data/ItemWithTypecast.php @@ -37,6 +37,10 @@ public function behaviors(): array 'callback' => function ($value) { return 'callback: ' . $value; }, + 'created_date' => AttributeTypecastBehavior::TYPE_DATETIME, + 'created_timestamp' => AttributeTypecastBehavior::TYPE_TIMESTAMP, + 'data_array' => AttributeTypecastBehavior::TYPE_ARRAY, + 'data_array_object' => AttributeTypecastBehavior::TYPE_ARRAY_OBJECT, ], ], ];