From 217f2d0a947b08a3c98d685444ec35f8cb2e628d Mon Sep 17 00:00:00 2001 From: Paul Klimov Date: Thu, 21 Dec 2023 17:24:44 +0200 Subject: [PATCH] initial commit --- .gitattributes | 9 + .github/FUNDING.yml | 4 + .github/ISSUE_TEMPLATE.md | 14 ++ .github/PULL_REQUEST_TEMPLATE.md | 7 + .github/workflows/build.yml | 31 +++ .gitignore | 35 +++ CHANGELOG.md | 7 + LICENSE.md | 32 +++ README.md | 41 ++++ composer.json | 39 ++++ phpunit.xml.dist | 15 ++ src/AttributeTypecastBehavior.php | 288 ++++++++++++++++++++++++ tests/AttributeTypecastBehaviorTest.php | 11 + tests/TestCase.php | 104 +++++++++ tests/bootstrap.php | 14 ++ tests/data/Item.php | 46 ++++ 16 files changed, 697 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100644 src/AttributeTypecastBehavior.php create mode 100644 tests/AttributeTypecastBehaviorTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/bootstrap.php create mode 100644 tests/data/Item.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b3d9906 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Ignore all test and documentation for archive +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.scrutinizer.yml export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/docs export-ignore \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..99679ca --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [klimov-paul] +patreon: klimov_paul diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..2cc6439 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,14 @@ +### What steps will reproduce the problem? + +### What is the expected result? + +### What do you get instead? + +### Additional info + +| Q | A +|-----------------------| --- +| This Package Version | 1.?.? +| Yii Framework Version | 1.1.? +| PHP version | +| Operating system | diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f4af2f3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +| Q | A +| ------------- | --- +| Is bugfix? | ✔️/❌ +| New feature? | ✔️/❌ +| Breaks BC? | ✔️/❌ +| Tests pass? | ✔️/❌ +| Fixed issues | comma-separated list of tickets # fixed by the PR, if any \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..757df1b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,31 @@ +name: build + +on: [push, pull_request] + +jobs: + phpunit: + name: PHP ${{ matrix.php }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: ['7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2'] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, pdo, sqlite, pdo_sqlite + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: | + composer update --prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi + - name: Run unit tests + run: vendor/bin/phpunit --colors=always diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..304f7c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# phpstorm project files +.idea + +# netbeans project files +nbproject + +# zend studio for eclipse project files +.buildpath +.project +.settings + +# windows thumbnail cache +Thumbs.db + +# composer vendor dir +/vendor + +/composer.lock + +# composer itself is not needed +composer.phar + +# Mac DS_Store Files +.DS_Store + +# phpunit itself is not needed +phpunit.phar +# local phpunit config +/phpunit.xml +# phpunit cache +.phpunit.result.cache + +# test runtime files +/.phpunit.cache +/tests/runtime \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..954717a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +Yii1 Model Typecast extension +============================= + +1.0.0 Under Development +----------------------- + +- Initial release. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e641e6f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,32 @@ +This is free software. It is released under the terms of the +following BSD License. + +Copyright © 2023 by Yii1Tech (https://github.com/yii1tech) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + * Neither the name of Yii1Tech nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ce583c --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +

+ + + +

Model Attributes Typecast Extension for Yii 1

+
+

+ +This extension provides support for Yii1 Model and ActiveRecord attributes typecast. + +For license information check the [LICENSE](LICENSE.md)-file. + +[![Latest Stable Version](https://img.shields.io/packagist/v/yii1tech/model-typecast.svg)](https://packagist.org/packages/yii1tech/model-typecast) +[![Total Downloads](https://img.shields.io/packagist/dt/yii1tech/model-typecast.svg)](https://packagist.org/packages/yii1tech/model-typecast) +[![Build Status](https://github.com/yii1tech/model-typecast/workflows/build/badge.svg)](https://github.com/yii1tech/model-typecast/actions) + + +Installation +------------ + +The preferred way to install this extension is through [composer](http://getcomposer.org/download/). + +Either run + +``` +php composer.phar require --prefer-dist yii1tech/model-typecast +``` + +or add + +```json +"yii1tech/model-typecast": "*" +``` + +to the "require" section of your composer.json. + + +Usage +----- + +This extension provides support for Yii1 Model and ActiveRecord attributes typecast. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4618db4 --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "yii1tech/model-typecast", + "description": "Allows typecast for Model and ActiveRecord attributes in Yii1", + "keywords": ["yii1", "model", "active", "record", "typecast"], + "license": "BSD-3-Clause", + "support": { + "issues": "https://github.com/yii1tech/model-typecast/issues", + "wiki": "https://github.com/yii1tech/model-typecast/wiki", + "source": "https://github.com/yii1tech/model-typecast" + }, + "authors": [ + { + "name": "Paul Klimov", + "email": "klimov.paul@gmail.com" + } + ], + "require": { + "php": ">=7.1", + "yiisoft/yii": "~1.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0 || ^7.0 || ^8.0 || ^9.3 || ^10.0.7" + }, + "autoload": { + "psr-4": { + "yii1tech\\model\\typecast\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "yii1tech\\model\\typecast\\test\\": "tests" + } + }, + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + } +} \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..1dc5e87 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,15 @@ + + + + + ./tests + + + diff --git a/src/AttributeTypecastBehavior.php b/src/AttributeTypecastBehavior.php new file mode 100644 index 0000000..c183cdc --- /dev/null +++ b/src/AttributeTypecastBehavior.php @@ -0,0 +1,288 @@ + + * @since 1.0 + */ +class AttributeTypecastBehavior extends CBehavior +{ + const TYPE_INTEGER = 'integer'; + const TYPE_FLOAT = 'float'; + const TYPE_BOOLEAN = 'boolean'; + const TYPE_STRING = 'string'; + + /** + * @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: + * + * ```php + * [ + * 'amount' => 'integer', + * 'price' => 'float', + * 'is_active' => 'boolean', + * 'date' => function ($value) { + * return ($value instanceof \DateTime) ? $value->getTimestamp(): (int) $value; + * }, + * ] + * ``` + * + * If not set, attribute type map will be composed automatically from the owner validation rules. + */ + private $_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`), + * otherwise it will be converted according to the type configured at [[attributeTypes]]. + */ + public $skipOnNull = true; + /** + * @var bool whether to perform typecasting after owner model validation. + * Note that typecasting will be performed only if validation was successful, e.g. + * owner model has no errors. + * Note that changing this option value will have no effect after this behavior has been attached to the model. + */ + public $typecastAfterValidate = true; + /** + * @var bool whether to perform typecasting before saving owner model (insert or update). + * This option may be disabled in order to achieve better performance. + * For example, in case of [[\yii\db\ActiveRecord]] usage, typecasting before save + * will grant no benefit an thus can be disabled. + * Note that changing this option value will have no effect after this behavior has been attached to the model. + */ + public $typecastBeforeSave = false; + /** + * @var bool whether to perform typecasting after saving owner model (insert or update). + * This option may be disabled in order to achieve better performance. + * For example, in case of [[\yii\db\ActiveRecord]] usage, typecasting after save + * will grant no benefit an thus can be disabled. + * 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; + /** + * @var bool whether to perform typecasting after retrieving owner model data from + * the database (after find or refresh). + * This option may be disabled in order to achieve better performance. + * For example, in case of [[\yii\db\ActiveRecord]] usage, typecasting after find + * will grant no benefit in most cases an thus can be disabled. + * Note that changing this option value will have no effect after this behavior has been attached to the model. + */ + public $typecastAfterFind = false; + + /** + * @var array internal static cache for auto detected [[attributeTypes]] values + * in format: ownerClassName => attributeTypes + */ + private static $autoDetectedAttributeTypes = []; + + /** + * @return array + */ + public function getAttributeTypes(): array + { + if ($this->_attributeTypes === null) { + $this->_attributeTypes = $this->detectAttributeTypes(); + } + + return $this->_attributeTypes; + } + + /** + * @param array $attributeTypes + */ + public function setAttributeTypes(array $attributeTypes): self + { + $this->_attributeTypes = $attributeTypes; + + return $this; + } + + protected function detectAttributeTypes(): array + { + $ownerClass = get_class($this->owner); + if (!isset(self::$autoDetectedAttributeTypes[$ownerClass])) { + self::$autoDetectedAttributeTypes[$ownerClass] = $this->detectAttributeTypesFromRules(); + } + + return self::$autoDetectedAttributeTypes[$ownerClass]; + } + + /** + * Clears internal static cache of auto detected [[attributeTypes]] values + * over all affected owner classes. + */ + public static function clearAutoDetectedAttributeTypes(): void + { + self::$autoDetectedAttributeTypes = []; + } + + /** + * Typecast owner attributes according to [[attributeTypes]]. + * @param array|null $attributeNames list of attribute names that should be type-casted. + * If this parameter is empty, it means any attribute listed in the [[attributeTypes]] + * should be type-casted. + * @return \CModel|\CActiveRecord owner instance. + */ + public function typecastAttributes($attributeNames = null) + { + $attributeTypes = []; + + if ($attributeNames === null) { + $attributeTypes = $this->attributeTypes; + } else { + foreach ($attributeNames as $attribute) { + if (!isset($this->attributeTypes[$attribute])) { + throw new InvalidArgumentException("There is no type mapping for '{$attribute}'."); + } + $attributeTypes[$attribute] = $this->attributeTypes[$attribute]; + } + } + + foreach ($attributeTypes as $attribute => $type) { + $value = $this->owner->{$attribute}; + if ($this->skipOnNull && $value === null) { + continue; + } + $this->owner->{$attribute} = $this->typecastValue($value, $type); + } + + return $this->owner; + } + + /** + * Casts the given value to the specified type. + * @param mixed $value value to be type-casted. + * @param string|callable $type type name or typecast callable. + * @return mixed typecast result. + */ + 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}'"); + } + } + + return call_user_func($type, $value); + } + + /** + * Composes default value for {@see $attributeTypes} from the owner validation rules. + * @return array attribute type map. + */ + protected function detectAttributeTypesFromRules(): array + { + $attributeTypes = []; + foreach ($this->owner->getValidators() as $validator) { + $type = null; + if ($validator instanceof CBooleanValidator) { + $type = self::TYPE_BOOLEAN; + } elseif ($validator instanceof CNumberValidator) { + $type = $validator->integerOnly ? self::TYPE_INTEGER : self::TYPE_FLOAT; + } elseif ($validator instanceof CStringValidator) { + $type = self::TYPE_STRING; + } + + if ($type !== null) { + $attributeTypes += array_fill_keys($validator->getAttributeNames(), $type); + } + } + + return $attributeTypes; + } + + // Event Handlers: + + /** + * {@inheritdoc} + */ + public function events(): array + { + $events = []; + + if ($this->typecastAfterValidate) { + $events['onAfterValidate'] = 'afterValidate'; + } + + if ($this->getOwner() instanceof CActiveRecord) { + if ($this->typecastBeforeSave) { + $events['onBeforeSave'] = 'beforeSave'; + } + + if ($this->typecastAfterSave) { + $events['onAfterSave'] = 'afterSave'; + } + + if ($this->typecastAfterFind) { + $events['onAfterFind'] = 'afterFind'; + } + } + + return $events; + } + + /** + * Handles owner 'afterValidate' event, ensuring attribute typecasting. + * @param \CModelEvent $event event instance. + */ + public function afterValidate(CModelEvent $event): void + { + if (!$this->owner->hasErrors()) { + $this->typecastAttributes(); + } + } + + /** + * Handles owner 'beforeSave' owner event, ensuring attribute typecasting. + * @param \CModelEvent $event event instance. + */ + public function beforeSave(CModelEvent $event): void + { + $this->typecastAttributes(); + } + + /** + * Handles owner 'afterSave' event, ensuring attribute typecasting. + * @param \CModelEvent $event event instance. + */ + public function afterSave(CModelEvent $event): void + { + $this->typecastAttributes(); + } + + /** + * Handles owner 'afterFind' event, ensuring attribute typecasting. + * @param \CModelEvent $event event instance. + */ + public function afterFind(CModelEvent $event): void + { + $this->typecastAttributes(); + } +} \ No newline at end of file diff --git a/tests/AttributeTypecastBehaviorTest.php b/tests/AttributeTypecastBehaviorTest.php new file mode 100644 index 0000000..4cb4335 --- /dev/null +++ b/tests/AttributeTypecastBehaviorTest.php @@ -0,0 +1,11 @@ +assertTrue(true); + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..dc475eb --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,104 @@ +mockApplication(); + + $this->setupTestDbData(); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->destroyApplication(); + + parent::tearDown(); + } + + /** + * Populates Yii::app() with a new application + * The application will be destroyed on tearDown() automatically. + * @param array $config The application configuration, if needed + * @param string $appClass name of the application class to create + */ + protected function mockApplication($config = [], $appClass = CConsoleApplication::class) + { + Yii::setApplication(null); + + new $appClass(CMap::mergeArray([ + 'id' => 'testapp', + 'basePath' => __DIR__, + 'components' => [ + 'db' => [ + 'class' => \CDbConnection::class, + 'connectionString' => 'sqlite::memory:', + ], + 'cache' => [ + 'class' => \CDummyCache::class, + ], + ], + ], $config)); + } + + /** + * Destroys Yii application by setting it to null. + */ + protected function destroyApplication() + { + Yii::setApplication(null); + } + + /** + * Setup tables for test ActiveRecord + */ + protected function setupTestDbData() + { + $db = Yii::app()->getDb(); + + // Structure : + $db->createCommand() + ->createTable('item', [ + 'id' => 'pk', + 'category_id' => 'integer', + 'name' => 'string', + 'is_deleted' => 'boolean DEFAULT 0', + 'created_at' => 'integer', + 'created_date' => 'datetime', + ]); + + // Data : + $builder = $db->getCommandBuilder(); + + $builder->createMultipleInsertCommand('item', [ + [ + 'category_id' => 1, + 'name' => 'item1', + 'is_deleted' => 0, + 'created_at' => time(), + 'created_date' => date('Y-m-d H:i:s'), + ], + [ + 'category_id' => 2, + 'name' => 'item2', + 'is_deleted' => 1, + 'created_at' => time(), + 'created_date' => date('Y-m-d H:i:s'), + ], + ])->execute(); + } +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..f238b2d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,14 @@ + [ + 'class' => AttributeTypecastBehavior::class, + ], + ]; + } +} \ No newline at end of file