├── .editorconfig ├── .github └── workflows │ ├── build.yml │ └── ecs.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── ecs.php ├── phpunit.xml.dist ├── src ├── ActiveFixture.php ├── ActiveQuery.php ├── ActiveRecord.php ├── Command.php ├── Connection.php ├── Exception.php ├── Query.php ├── QueryBuilder.php ├── RestDataProvider.php └── conditions │ ├── BetweenConditionBuilder.php │ ├── ConditionBuilderTrait.php │ ├── ConjunctionConditionBuilder.php │ ├── HashConditionBuilder.php │ ├── InConditionBuilder.php │ ├── LikeConditionBuilder.php │ ├── NotConditionBuilder.php │ └── SimpleConditionBuilder.php └── tests ├── RelationTest.php ├── TestCase.php ├── UrlTest.php ├── UrlWithoutPluralisationTest.php ├── bootstrap.php ├── fixtures ├── RelatedRestModelFixture.php ├── RestModelFixture.php └── data │ ├── related-rest-model.php │ └── rest-model.php ├── log └── ArrayTarget.php └── models ├── .gitignore ├── RelatedRestModel.php └── RestModel.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 4 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [ push, pull_request, workflow_dispatch ] 4 | 5 | env: 6 | DEFAULT_COMPOSER_FLAGS: "--prefer-dist --no-interaction --no-progress --optimize-autoloader" 7 | 8 | jobs: 9 | phpunit: 10 | name: PHP ${{ matrix.php }}-${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ ubuntu-latest ] 16 | php: [ "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4" ] 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Install PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php }} 25 | extensions: apc, curl, dom, intl, mbstring, mcrypt 26 | ini-values: date.timezone='UTC' 27 | - name: Determine composer cache directory 28 | id: composer-cache 29 | run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV 30 | - name: Cache dependencies composer installed with composer 31 | uses: actions/cache@v4 32 | with: 33 | path: ${{ env.COMPOSER_CACHE_DIR }} 34 | key: php${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 35 | restore-keys: php${{ matrix.php }}-composer- 36 | - name: Update composer 37 | run: composer self-update 38 | - name: Install dependencies with composer 39 | run: composer update $DEFAULT_COMPOSER_FLAGS 40 | - name: Run unit tests 41 | run: vendor/bin/phpunit --verbose --colors=always 42 | -------------------------------------------------------------------------------- /.github/workflows/ecs.yml: -------------------------------------------------------------------------------- 1 | name: ecs 2 | 3 | on: [ push, pull_request, workflow_dispatch ] 4 | 5 | env: 6 | DEFAULT_COMPOSER_FLAGS: "--prefer-dist --no-interaction --no-progress --optimize-autoloader --ansi --ignore-platform-req=php" 7 | 8 | jobs: 9 | easy-coding-standard: 10 | name: PHP ${{ matrix.php }}-${{ matrix.os }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ ubuntu-latest ] 16 | php: [ "8.0", "8.1", "8.2", "8.3", "8.4" ] 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Install PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php }} 25 | - name: Determine composer cache directory 26 | id: composer-cache 27 | run: echo "COMPOSER_CACHE_DIR=$(composer config cache-dir)" >> $GITHUB_ENV 28 | - name: Cache dependencies composer installed with composer 29 | uses: actions/cache@v4 30 | with: 31 | path: ${{ env.COMPOSER_CACHE_DIR }} 32 | key: php${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 33 | restore-keys: php${{ matrix.php }}-composer- 34 | - name: Update composer 35 | run: composer self-update 36 | - name: Install dependencies with composer 37 | run: composer update $DEFAULT_COMPOSER_FLAGS 38 | - name: Run easy-coding-standard 39 | run: vendor/bin/ecs check src tests --ansi 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # phpstorm project files 2 | .idea 3 | 4 | # netbeans project files 5 | nbproject 6 | 7 | # zend studio for eclipse project files 8 | .buildpath 9 | .project 10 | .settings 11 | 12 | # windows thumbnail cache 13 | Thumbs.db 14 | 15 | # composer vendor dir 16 | /vendor 17 | 18 | # composer itself is not needed and related files 19 | composer.phar 20 | composer.lock 21 | 22 | # Mac DS_Store Files 23 | .DS_Store 24 | 25 | # phpunit itself is not needed 26 | phpunit.phar 27 | # local phpunit config 28 | /phpunit.xml 29 | .phpunit.result.cache 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2016, ApexWire 4 | Copyright © 2017 simialbi 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # REST Client for Yii 2 (ActiveRecord-like model) 2 | This extension provides an interface to work with RESTful API via ActiveRecord-like model in Yii 2. 3 | It is based on [ApexWire's](https://github.com/ApexWire) [yii2-restclient](https://github.com/ApexWire/yii2-restclient). 4 | 5 | 6 | [![Latest Stable Version](https://poser.pugx.org/simialbi/yii2-rest-client/v/stable?format=flat-square)](https://packagist.org/packages/simialbi/yii2-rest-client) 7 | [![Total Downloads](https://poser.pugx.org/simialbi/yii2-rest-client/downloads?format=flat-square)](https://packagist.org/packages/simialbi/yii2-rest-client) 8 | [![License](https://poser.pugx.org/simialbi/yii2-rest-client/license?format=flat-square)](https://packagist.org/packages/simialbi/yii2-rest-client) 9 | ![Build Status](https://github.com/simialbi/yii2-rest-client/workflows/build/badge.svg) 10 | 11 | ## Resources 12 | * [yii2-restclient](https://github.com/ApexWire/yii2-restclient) 13 | * [\yii\db\ActiveRecord](http://www.yiiframework.com/doc-2.0/guide-db-active-record.html) 14 | 15 | ## Installation 16 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 17 | 18 | Either run 19 | 20 | ``` 21 | $ php composer.phar require --prefer-dist simialbi/yii2-rest-client 22 | ``` 23 | 24 | or add 25 | 26 | ``` 27 | "simialbi/yii2-rest-client": "*" 28 | ``` 29 | 30 | to the `require` section of your `composer.json`. 31 | 32 | ## Configuration 33 | To use this extension, configure restclient component in your application config: 34 | 35 | ```php 36 | 'components' => [ 37 | 'rest' => [ 38 | 'class' => 'simialbi\yii2\rest\Connection', 39 | 'baseUrl' => 'https://api.site.com/', 40 | // 'auth' => function (simialbi\yii2\rest\Connection $db) { 41 | // return 'Bearer: '; 42 | // }, 43 | // 'auth' => 'Bearer: ', 44 | // 'usePluralisation' => false, 45 | // 'useFilterKeyword' => false, 46 | // 'enableExceptions' => true, 47 | // 'itemsProperty' => 'items' 48 | ], 49 | ], 50 | ``` 51 | 52 | | Parameter | Default | Description | 53 | | ------------------- | -----------| ------------------------------------------------------------------------------------------------------------- | 54 | | `baseUrl` | `''` | The location of the api. E.g. for http://api.site.com/v1/users the `baseUrl` would be http://api.site.com/v1/ (required) | 55 | | `auth` | | Either a Closure which returns a `string` or a `string`. The rest connection will be passed as parameter. | 56 | | `usePluralisation` | `true` | Whether to use plural version for lists (index action) or not (e.g. http://api.site.com/users instead of `user`) | 57 | | `useFilterKeyword` | `true` | Whether to use "filter" key word in url parameters when filtering (e.g. ?filter[name]=user instead of ?name=user | 58 | | `enableExceptions` | `false` | Whether the connection should throw an exception if response is not 200 or not | 59 | | `itemsProperty` | | If your items are wrapped inside a property (e.g. `items`), set it's name here | 60 | | `requestConfig` | `[]` | Client request configuration | 61 | | `responseConfig` | `[]` | Client response configuration | 62 | | `updateMethod` | `'put'` | The method to use for update operations. | 63 | | `isTestMode` | `false` | Whether we are in test mode or not (prevent execution) | 64 | | `enableQueryCache` | `false` | Whether to enable query caching | 65 | | `queryCacheDuration`| `3600` | The default number of seconds that query results can remain valid in cache | 66 | | `queryCache` | `'cache'` | The cache object or the ID of the cache application component | 67 | 68 | ## Usage 69 | Define your Model 70 | 71 | ```php 72 | Important: Be sure to either define the properties of the object like in the example above (`@property` syntax in phpdoc) 129 | > or override the `attributes()` method to return the allowed attributes as array 130 | 131 | > The same about relations. Be sure to either define them via `@property-read` phpdoc comment or override the `getRelations` 132 | > method. If the related class has not the same namespace as the main class, be sure to use the fully qualified class name 133 | > (e.g. `@property-read \app\models\OtherModel[] $otherModels`) 134 | 135 | ## License 136 | 137 | **yii2-rest-client** is released under MIT license. See bundled [LICENSE](LICENSE) for details. 138 | 139 | ## Acknowledgments 140 | * [ApexWire's](https://github.com/ApexWire) [yii2-restclient](https://github.com/ApexWire/yii2-restclient) 141 | * [Yii2 HiArt](https://github.com/hiqdev/yii2-hiart). 142 | * [mikolajzieba](https://github.com/mikolajzieba) 143 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simialbi/yii2-rest-client", 3 | "type": "yii2-extension", 4 | "description": "REST client (AR-like model) for Yii Framework 2.0 (via yii2-http-client, extends ApexWire/yii2-restclient)", 5 | "keywords": [ 6 | "yii2", 7 | "rest", 8 | "client", 9 | "model", 10 | "active record" 11 | ], 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "ApexWire", 16 | "email": "apexwire@gmail.com" 17 | }, 18 | { 19 | "name": "simialbi", 20 | "email": "simi.albi@outlook.com" 21 | } 22 | ], 23 | "support": { 24 | "source": "https://github.com/simialbi/yii2-rest-client", 25 | "issues": "https://github.com/simialbi/yii2-rest-client/issues" 26 | }, 27 | "minimum-stability": "beta", 28 | "require": { 29 | "php": ">=7.3", 30 | "yiisoft/yii2": "^2.0.14", 31 | "yiisoft/yii2-httpclient": "^2.0.0" 32 | }, 33 | "require-dev": { 34 | "yiisoft/yii2-coding-standards": "~2.0", 35 | "phpunit/phpunit": "^9.6.22", 36 | "symplify/easy-coding-standard": "^12.1" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "simialbi\\yii2\\rest\\": "src" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "yiiunit\\extensions\\rest\\": "tests" 46 | } 47 | }, 48 | "repositories": [ 49 | { 50 | "type": "composer", 51 | "url": "https://asset-packagist.org" 52 | } 53 | ], 54 | "extra": { 55 | "branch-alias": { 56 | "dev-master": "2.0.x-dev" 57 | } 58 | }, 59 | "config": { 60 | "allow-plugins": { 61 | "yiisoft/yii2-composer": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | withConfiguredRule( 12 | ClassDefinitionFixer::class, 13 | [ 14 | 'space_before_parenthesis' => true, 15 | ], 16 | ) 17 | ->withFileExtensions(['php']) 18 | ->withPaths( 19 | [ 20 | __DIR__ . '/src', 21 | __DIR__ . '/tests', 22 | ] 23 | ) 24 | ->withPhpCsFixerSets(false, false, false, false, false, true) 25 | ->withPreparedSets( 26 | true, 27 | false, 28 | false, 29 | false, 30 | true, 31 | true, 32 | true, 33 | false, 34 | true, 35 | false, 36 | false, 37 | true, 38 | true 39 | ) 40 | ->withRules([NoUnusedImportsFixer::class, OrderedTraitsFixer::class]); 41 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | ./tests 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ActiveFixture.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace simialbi\yii2\rest; 11 | 12 | use yii\base\InvalidConfigException; 13 | use yii\helpers\Inflector; 14 | use yii\helpers\StringHelper; 15 | use yii\test\BaseActiveFixture; 16 | 17 | class ActiveFixture extends BaseActiveFixture 18 | { 19 | /** 20 | * @var Connection|array|string the DB connection object or the application component ID of the DB connection. 21 | * After the DbFixture object is created, if you want to change this property, you should only assign it 22 | * with a DB connection object. 23 | * Starting from version 2.0.2, this can also be a configuration array for creating the object. 24 | */ 25 | public $db = 'rest'; 26 | /** 27 | * @var string the name of the model that this fixture is about. If this property is not set, 28 | * the model name will be determined via [[modelClass]]. 29 | * @see modelClass 30 | */ 31 | public $modelName; 32 | 33 | /** 34 | * @var \yii\db\ActiveRecord[] the loaded AR models 35 | */ 36 | private $_models = []; 37 | 38 | 39 | /** 40 | * @throws InvalidConfigException 41 | */ 42 | public function init() 43 | { 44 | parent::init(); 45 | if ($this->modelClass === null && $this->modelName === null) { 46 | throw new InvalidConfigException('Either "modelClass" or "modelName" must be set.'); 47 | } 48 | if ($this->modelName === null) { 49 | $this->modelName = Inflector::camel2id(StringHelper::basename($this->modelClass), '-'); 50 | } 51 | } 52 | 53 | /** 54 | * @throws InvalidConfigException 55 | * @throws \ReflectionException 56 | */ 57 | public function load() 58 | { 59 | $this->data = []; 60 | foreach ($this->getData() as $alias => $row) { 61 | $this->data[$alias] = $row; 62 | } 63 | } 64 | 65 | /** 66 | * @throws InvalidConfigException 67 | */ 68 | protected function getData(): array 69 | { 70 | if ($this->dataFile === null) { 71 | if ($this->dataDirectory !== null) { 72 | $dataFile = $this->modelName . '.php'; 73 | } else { 74 | $class = new \ReflectionClass($this); 75 | $dataFile = dirname($class->getFileName()) . '/data/' . $this->modelName . '.php'; 76 | } 77 | 78 | return $this->loadData($dataFile, false); 79 | } 80 | return parent::getData(); 81 | } 82 | 83 | public function getModel($name) 84 | { 85 | if (!isset($this->data[$name])) { 86 | return null; 87 | } 88 | if (array_key_exists($name, $this->_models)) { 89 | return $this->_models[$name]; 90 | } 91 | 92 | if ($this->modelClass === null) { 93 | throw new InvalidConfigException('The "modelClass" property must be set.'); 94 | } 95 | $row = $this->data[$name]; 96 | /** @var ActiveRecord $modelClass */ 97 | $modelClass = $this->modelClass; 98 | $keys = []; 99 | foreach ($modelClass::primaryKey() as $key) { 100 | $keys[$key] = $row[$key] ?? null; 101 | } 102 | 103 | /** @var ActiveRecord $model */ 104 | $model = new $modelClass(); 105 | $model->setOldAttributes($row); 106 | $model->setAttributes($row, false); 107 | 108 | return $this->_models[$name] = $model; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/ActiveQuery.php: -------------------------------------------------------------------------------- 1 | modelClass; 44 | 45 | if ($db === null) { 46 | $db = $modelClass::getDb(); 47 | } 48 | 49 | if ($this->from === null) { 50 | $this->from($modelClass::modelName()); 51 | } 52 | 53 | return parent::createCommand($db); 54 | } 55 | 56 | /** 57 | * @throws InvalidConfigException 58 | */ 59 | public function prepare($builder): Query 60 | { 61 | if (!empty($this->joinWith)) { 62 | $this->buildJoinWith(); 63 | $this->joinWith = null; 64 | } 65 | 66 | if ($this->primaryModel === null) { 67 | // eager loading 68 | $query = Query::create($this); 69 | } else { 70 | // lazy loading of a relation 71 | $where = $this->where; 72 | 73 | if ($this->via instanceof self) { 74 | // via junction table 75 | $viaModels = $this->via->findJunctionRows([$this->primaryModel]); 76 | $this->filterByModels($viaModels); 77 | } elseif (is_array($this->via)) { 78 | // via relation 79 | /** @var ActiveQuery $viaQuery */ 80 | [$viaName, $viaQuery] = $this->via; 81 | if ($viaQuery->multiple) { 82 | if ($this->primaryModel->isRelationPopulated($viaName)) { 83 | $viaModels = $this->primaryModel->$viaName; 84 | } else { 85 | $viaModels = $viaQuery->all(); 86 | $this->primaryModel->populateRelation($viaName, $viaModels); 87 | } 88 | } else { 89 | if ($this->primaryModel->isRelationPopulated($viaName)) { 90 | $model = $this->primaryModel->$viaName; 91 | } else { 92 | $model = $viaQuery->one(); 93 | $this->primaryModel->populateRelation($viaName, $model); 94 | } 95 | $viaModels = $model === null ? [] : [$model]; 96 | } 97 | $this->filterByModels($viaModels); 98 | } else { 99 | $this->filterByModels([$this->primaryModel]); 100 | } 101 | 102 | $query = Query::create($this); 103 | $this->andWhere($where); 104 | } 105 | 106 | return $query; 107 | } 108 | 109 | public function all($db = null): array 110 | { 111 | return parent::all($db); 112 | } 113 | 114 | /** 115 | * @throws InvalidConfigException 116 | */ 117 | public function one($db = null) 118 | { 119 | $row = parent::one($db); 120 | if ($row !== false) { 121 | $models = $this->populate(isset($row[0]) ? $row : [$row]); 122 | 123 | return reset($models) ?: null; 124 | } 125 | 126 | return null; 127 | } 128 | 129 | /** 130 | * @throws InvalidConfigException 131 | */ 132 | public function populate($rows): array 133 | { 134 | if (empty($rows)) { 135 | return []; 136 | } 137 | 138 | $models = $this->createModels($rows); 139 | if (!empty($this->join) && $this->indexBy === null) { 140 | $models = $this->removeDuplicatedModels($models); 141 | } 142 | if (!empty($this->with)) { 143 | $this->findWith($this->with, $models); 144 | } 145 | if (!$this->asArray) { 146 | foreach ($models as $model) { 147 | $model->afterFind(); 148 | } 149 | } elseif ($this->indexBy !== null) { 150 | $models = ArrayHelper::index($models, $this->indexBy); 151 | } 152 | 153 | return $models; 154 | } 155 | 156 | /** 157 | * Joins with the specified relations. 158 | * 159 | * This method allows you to reuse existing relation definitions to perform JOIN queries. 160 | * Based on the definition of the specified relation(s), the method will append one or multiple 161 | * JOIN statements to the current query. 162 | * 163 | * @param string|array $with the relations to be joined. This can either be a string, representing a relation name 164 | * or an array with the following semantics: 165 | * 166 | * - Each array element represents a single relation. 167 | * - You may specify the relation name as the array key and provide an anonymous functions that 168 | * can be used to modify the relation queries on-the-fly as the array value. 169 | * - If a relation query does not need modification, you may use the relation name as the array value. 170 | * 171 | * Sub-relations can also be specified, see [[with()]] for the syntax. 172 | * 173 | * In the following you find some examples: 174 | * 175 | * ```php 176 | * // find all orders that contain books, and eager loading "books" 177 | * Order::find()->joinWith('books')->all(); 178 | * // find all orders, eager loading "books", and sort the orders and books by the book names. 179 | * Order::find()->joinWith([ 180 | * 'books' => function (\simialbi\yii2\rest\ActiveQuery $query) { 181 | * $query->orderBy('item.name'); 182 | * } 183 | * ])->all(); 184 | * // find all orders that contain books of the category 'Science fiction', using the alias "b" for the books table 185 | * Order::find()->joinWith(['books b'])->where(['b.category' => 'Science fiction'])->all(); 186 | * ``` 187 | * 188 | * @return $this the query object itself 189 | */ 190 | public function joinWith($with): ActiveQuery 191 | { 192 | $this->joinWith[] = (array) $with; 193 | return $this; 194 | } 195 | 196 | /** 197 | * Modifies the current query by adding join fragments based on the given relations. 198 | * 199 | * @param ActiveRecord $model the primary model 200 | * @param array $with the relations to be joined 201 | */ 202 | protected function joinWithRelations(ActiveRecord $model, array $with) 203 | { 204 | foreach ($with as $name => $callback) { 205 | if (is_int($name)) { 206 | $name = $callback; 207 | $callback = null; 208 | } 209 | $primaryModel = $model; 210 | $parent = $this; 211 | if (!isset($relations[$name])) { 212 | $relations[$name] = $relation = $primaryModel->getRelation($name); 213 | /** @var static $relation */ 214 | if ($callback !== null) { 215 | call_user_func($callback, $relation); 216 | } 217 | if (!empty($relation->joinWith)) { 218 | $relation->buildJoinWith(); 219 | } 220 | $this->joinWithRelation($parent, $relation); 221 | } 222 | } 223 | } 224 | 225 | protected function createModels($rows): array 226 | { 227 | if ($this->asArray) { 228 | return $rows; 229 | } else { 230 | $models = []; 231 | /** @var ActiveRecord $class */ 232 | $class = $this->modelClass; 233 | foreach ($rows as $row) { 234 | $model = $class::instantiate($row); 235 | /** @var ActiveRecord $modelClass */ 236 | $modelClass = get_class($model); 237 | $modelClass::populateRecord($model, $row); 238 | if (!empty($this->join)) { 239 | foreach ($this->join as $join) { 240 | if (isset($join[1], $row[$join[1]])) { 241 | $relation = $model->getRelation($join[1]); 242 | $rows = (ArrayHelper::isAssociative($row[$join[1]])) ? [$row[$join[1]]] : $row[$join[1]]; 243 | $relations = $relation->populate($rows); 244 | $model->populateRelation($join[1], $relation->multiple ? $relations : $relations[0]); 245 | } 246 | } 247 | } 248 | $models[] = $model; 249 | } 250 | return $models; 251 | } 252 | } 253 | 254 | /** 255 | * Builds join with clauses 256 | */ 257 | private function buildJoinWith() 258 | { 259 | $join = $this->join; 260 | $this->join = []; 261 | $model = new $this->modelClass(); 262 | foreach ($this->joinWith as $with) { 263 | $this->joinWithRelations($model, $with); 264 | foreach ($with as $name => $callback) { 265 | $this->innerJoin(is_int($name) ? $callback : [ 266 | $name => $callback, 267 | ]); 268 | unset($with[$name]); 269 | } 270 | } 271 | if (!empty($join)) { 272 | // append explicit join to joinWith() 273 | // https://github.com/yiisoft/yii2/issues/2880 274 | $this->join = empty($this->join) ? $join : array_merge($this->join, $join); 275 | } 276 | } 277 | 278 | /** 279 | * Joins a parent query with a child query. 280 | * The current query object will be modified accordingly. 281 | */ 282 | private function joinWithRelation(ActiveQuery $parent, ActiveQuery $child) 283 | { 284 | if (!empty($child->join)) { 285 | foreach ($child->join as $join) { 286 | $this->join[] = $join; 287 | } 288 | } 289 | } 290 | 291 | /** 292 | * Removes duplicated models by checking their primary key values. 293 | * This method is mainly called when a join query is performed, which may cause duplicated rows being returned. 294 | * 295 | * @param array $models the models to be checked 296 | * 297 | * @return array the distinctive models 298 | * @throws InvalidConfigException if model primary key is empty 299 | */ 300 | private function removeDuplicatedModels(array $models): array 301 | { 302 | $hash = []; 303 | /** @var ActiveRecord $class */ 304 | $class = $this->modelClass; 305 | $pks = $class::primaryKey(); 306 | 307 | if (count($pks) > 1) { 308 | // composite primary key 309 | foreach ($models as $i => $model) { 310 | $key = []; 311 | foreach ($pks as $pk) { 312 | if (!isset($model[$pk])) { 313 | // do not continue if the primary key is not part of the result set 314 | break 2; 315 | } 316 | $key[] = $model[$pk]; 317 | } 318 | $key = serialize($key); 319 | if (isset($hash[$key])) { 320 | unset($models[$i]); 321 | } else { 322 | $hash[$key] = true; 323 | } 324 | } 325 | } elseif (empty($pks)) { 326 | /** @var string $class */ 327 | throw new InvalidConfigException("Primary key of '{$class}' can not be empty."); 328 | } else { 329 | // single column primary key 330 | $pk = reset($pks); 331 | foreach ($models as $i => $model) { 332 | if (!isset($model[$pk])) { 333 | // do not continue if the primary key is not part of the result set 334 | break; 335 | } 336 | $key = $model[$pk]; 337 | if (isset($hash[$key])) { 338 | unset($models[$i]); 339 | } elseif ($key !== null) { 340 | $hash[$key] = true; 341 | } 342 | } 343 | } 344 | 345 | return array_values($models); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /src/ActiveRecord.php: -------------------------------------------------------------------------------- 1 | get(Connection::getDriverName()); 53 | } 54 | 55 | /** 56 | * Declares the name of the url path associated with this AR class. 57 | * 58 | * By default this method returns the class name as the path by calling [[Inflector::camel2id()]]. 59 | * For example: 60 | * `Customer` becomes `customer`, and `OrderItem` becomes `order-item`. You may override this method 61 | * if the path is not named after this convention. 62 | * 63 | * @return string the url path 64 | * @throws InvalidConfigException 65 | */ 66 | public static function modelName(): string 67 | { 68 | $path = Inflector::camel2id(StringHelper::basename(get_called_class()), '-'); 69 | return static::getDb()->usePluralisation ? Inflector::pluralize($path) : $path; 70 | } 71 | 72 | public function attributes(): array 73 | { 74 | if (empty($this->_attributeFields)) { 75 | $regex = '#^@property(?:-(read|write))?(?:\s+([^\s]+))?\s+\$([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)#'; 76 | $typeRegex = '#^(bool(ean)?|int(eger)?|float|double|string|array)$#'; 77 | $reflection = new \ReflectionClass($this); 78 | $docLines = preg_split('~\R~u', $reflection->getDocComment()); 79 | foreach ($docLines as $docLine) { 80 | $matches = []; 81 | $docLine = ltrim($docLine, "\t* "); 82 | if (preg_match($regex, $docLine, $matches) && isset($matches[3])) { 83 | if ($matches[1] === 'read' || (!empty($matches[2]) && !preg_match($typeRegex, $matches[2]))) { 84 | continue; 85 | } 86 | $this->_attributeFields[] = $matches[3]; 87 | } 88 | } 89 | } 90 | 91 | return $this->_attributeFields; 92 | } 93 | 94 | /** 95 | * @throws InvalidConfigException 96 | * @throws Exception 97 | */ 98 | public function insert($runValidation = true, $attributes = null): bool 99 | { 100 | if ($runValidation && !$this->validate($attributes)) { 101 | Yii::info('Model not inserted due to validation error.', __METHOD__); 102 | 103 | return false; 104 | } 105 | 106 | return $this->insertInternal($attributes); 107 | } 108 | 109 | /** 110 | * @throws InvalidConfigException 111 | */ 112 | public function update($runValidation = true, $attributeNames = null) 113 | { 114 | if ($runValidation && !$this->validate($attributeNames)) { 115 | Yii::info('Model not inserted due to validation error.', __METHOD__); 116 | 117 | return false; 118 | } 119 | 120 | return $this->updateInternal($attributeNames); 121 | } 122 | 123 | /** 124 | * @throws InvalidConfigException 125 | */ 126 | public function delete() 127 | { 128 | $result = false; 129 | if ($this->beforeDelete()) { 130 | $command = static::getDb()->createCommand(); 131 | $result = $command->delete(static::modelName(), $this->getOldPrimaryKey()); 132 | 133 | $this->setOldAttributes(null); 134 | $this->afterDelete(); 135 | } 136 | 137 | return $result; 138 | } 139 | 140 | /** 141 | * @throws NotSupportedException 142 | */ 143 | public function unlinkAll($name, $delete = false) 144 | { 145 | throw new NotSupportedException('unlinkAll() is not supported by RestClient, use unlink() instead.'); 146 | } 147 | 148 | /** 149 | * @return \simialbi\yii2\rest\ActiveQuery|\yii\db\ActiveQuery|\yii\db\ActiveQueryInterface 150 | */ 151 | public function hasOne($class, $link) 152 | { 153 | return parent::hasOne($class, $link); 154 | } 155 | 156 | /** 157 | * @return \simialbi\yii2\rest\ActiveQuery|\yii\db\ActiveQuery|\yii\db\ActiveQueryInterface 158 | */ 159 | public function hasMany($class, $link) 160 | { 161 | return parent::hasMany($class, $link); 162 | } 163 | 164 | /** 165 | * Inserts an ActiveRecord. 166 | * 167 | * @param array|null $attributes list of attributes that need to be saved. Defaults to `null`, 168 | * meaning all attributes that are loaded from DB will be saved. 169 | * 170 | * @return boolean whether the record is inserted successfully. 171 | * @throws InvalidConfigException 172 | * @throws Exception 173 | */ 174 | protected function insertInternal(?array $attributes): bool 175 | { 176 | if (!$this->beforeSave(true)) { 177 | return false; 178 | } 179 | $values = $this->getDirtyAttributes($attributes); 180 | if (false === ($data = static::getDb()->createCommand()->insert(static::modelName(), $values))) { 181 | return false; 182 | } 183 | foreach ($data as $name => $value) { 184 | $this->setAttribute($name, $value); 185 | $this->setOldAttribute($name, $value); 186 | } 187 | 188 | $changedAttributes = array_fill_keys(array_keys($values), null); 189 | $this->afterSave(true, $changedAttributes); 190 | 191 | return true; 192 | } 193 | 194 | /** 195 | * @throws InvalidConfigException 196 | * @throws \yii\db\Exception 197 | */ 198 | protected function updateInternal($attributes = null) 199 | { 200 | if (!$this->beforeSave(false)) { 201 | return false; 202 | } 203 | $values = $this->getDirtyAttributes($attributes); 204 | if (empty($values)) { 205 | $this->afterSave(false, $values); 206 | 207 | return 0; 208 | } 209 | 210 | $command = static::getDb()->createCommand(); 211 | $rows = $command->update(static::modelName(), $values, $this->getOldPrimaryKey(false)); 212 | 213 | $changedAttributes = []; 214 | foreach ($values as $name => $value) { 215 | $changedAttributes[$name] = $this->getOldAttribute($name); 216 | $this->setOldAttribute($name, $value); 217 | } 218 | $this->afterSave(false, $changedAttributes); 219 | 220 | return $rows; 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/Command.php: -------------------------------------------------------------------------------- 1 | queryCacheDuration = $duration === null ? $this->db->queryCacheDuration : $duration; 73 | $this->queryCacheDependency = $dependency; 74 | return $this; 75 | } 76 | 77 | /** 78 | * Disables query cache for this command. 79 | * @return $this the command object itself 80 | */ 81 | public function noCache(): Command 82 | { 83 | $this->queryCacheDuration = -1; 84 | return $this; 85 | } 86 | 87 | /** 88 | * Returns the raw url by inserting parameter values into the corresponding placeholders. 89 | * Note that the return value of this method should mainly be used for logging purpose. 90 | * It is likely that this method returns an invalid URL due to improper replacement of parameter placeholders. 91 | * @return string the raw URL with parameter values inserted into the corresponding placeholders. 92 | */ 93 | public function getRawUrl(): string 94 | { 95 | return $this->db->handler->get($this->pathInfo, $this->queryParams)->fullUrl; 96 | } 97 | 98 | /** 99 | * Executes the SQL statement and returns ALL rows at once. 100 | * 101 | * @param int|null $fetchMode for compatibility with [[\yii\db\Command]] 102 | * 103 | * @return array all rows of the query result. Each array element is an array representing a row of data. 104 | * An empty array is returned if the query results in nothing. 105 | * @throws InvalidConfigException 106 | */ 107 | public function queryAll(?int $fetchMode = null): array 108 | { 109 | return $this->queryInternal(); 110 | } 111 | 112 | /** 113 | * Executes the SQL statement and returns the first row of the result. 114 | * This method is best used when only the first row of result is needed for a query. 115 | * 116 | * @param int|null $fetchMode for compatibility with [[\yii\db\Command]] 117 | * 118 | * @return array|false the first row (in terms of an array) of the query result. False is returned if the query 119 | * results in nothing. 120 | * @throws \yii\base\InvalidConfigException 121 | */ 122 | public function queryOne(?int $fetchMode = null) 123 | { 124 | $class = $this->modelClass; 125 | 126 | if (!empty($class) && class_exists($class)) { 127 | /** @var ActiveRecord $class */ 128 | $pks = $class::primaryKey(); 129 | 130 | if (count($pks) === 1 && isset($this->queryParams['filter'])) { 131 | $primaryKey = current($pks); 132 | $currentKey = ArrayHelper::remove($this->queryParams['filter'], $primaryKey); 133 | if ($currentKey) { 134 | $this->pathInfo .= '/' . $currentKey; 135 | } 136 | } 137 | } 138 | 139 | return $this->queryInternal(); 140 | } 141 | 142 | /** 143 | * Make request and check for error. 144 | * 145 | * @return mixed 146 | * @throws InvalidConfigException 147 | */ 148 | public function execute(string $method = 'get') 149 | { 150 | return $this->queryInternal($method); 151 | } 152 | 153 | /** 154 | * Creates a new record 155 | * 156 | * @return array|false 157 | * @throws Exception 158 | */ 159 | public function insert(string $model, array $columns) 160 | { 161 | $this->pathInfo = $model; 162 | 163 | return $this->db->post($this->pathInfo, $columns); 164 | } 165 | 166 | /** 167 | * Updates an existing record 168 | * 169 | * @return mixed 170 | */ 171 | public function update(string $model, array $data = [], ?string $id = null) 172 | { 173 | $method = $this->db->updateMethod; 174 | $this->pathInfo = $model; 175 | if ($id) { 176 | $this->pathInfo .= '/' . $id; 177 | } 178 | 179 | return $this->db->$method($this->pathInfo, $data); 180 | } 181 | 182 | /** 183 | * Deletes a record 184 | * 185 | * @return array|false 186 | * @throws Exception 187 | */ 188 | public function delete(string $model, $id = null) 189 | { 190 | $id = (string) $id; 191 | $this->pathInfo = $model; 192 | if ($id) { 193 | $this->pathInfo .= '/' . $id; 194 | } 195 | 196 | return $this->db->delete($this->pathInfo); 197 | } 198 | 199 | /** 200 | * Performs the actual statement 201 | * 202 | * @return mixed 203 | * @throws InvalidConfigException 204 | */ 205 | protected function queryInternal(string $method = 'get') 206 | { 207 | if ($this->db->usePluralisation && strpos($this->pathInfo, '/') === false) { 208 | $this->pathInfo = Inflector::pluralize($this->pathInfo); 209 | } 210 | if (!$this->db->useFilterKeyword) { 211 | $filter = ArrayHelper::remove($this->queryParams, 'filter', []); 212 | $this->queryParams = array_merge($this->queryParams, $filter); 213 | } 214 | $info = $this->db->getQueryCacheInfo($this->queryCacheDuration, $this->queryCacheDependency); 215 | if (is_array($info)) { 216 | /** @var \yii\caching\CacheInterface $cache */ 217 | $cache = $info[0]; 218 | $cacheKey = $this->getCacheKey($method); 219 | $result = $cache->get($cacheKey); 220 | if (is_array($result) && isset($result[0])) { 221 | Yii::debug('Query result served from cache', 'simialbi\yii2\rest\Command::query'); 222 | return $result[0]; 223 | } 224 | } 225 | 226 | $result = $this->db->$method($this->pathInfo, $this->queryParams); 227 | if ($this->db->itemsProperty) { 228 | $result = ArrayHelper::getValue($result, $this->db->itemsProperty, []); 229 | } 230 | if (isset($cache, $cacheKey, $info)) { 231 | $cache->set($cacheKey, [$result], $info[1], $info[2]); 232 | Yii::debug('Saved query result in cache', 'simialbi\yii2\rest\Command::query'); 233 | } 234 | 235 | return $result; 236 | } 237 | 238 | /** 239 | * Returns the cache key for the query. 240 | * 241 | * @return array the cache key 242 | * @since 2.0.16 243 | */ 244 | protected function getCacheKey(string $method): array 245 | { 246 | return [ 247 | __CLASS__, 248 | $method, 249 | $this->pathInfo, 250 | $this->queryParams, 251 | ]; 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | [ 28 | * 'restclient' => [ 29 | * 'class' => 'simialbi\yii2\rest\Connection', 30 | * 'baseUrl' => 'https://api.site.com/', 31 | * ], 32 | * ], 33 | * ``` 34 | * 35 | * @property Client $handler 36 | * @property-read array $queryCacheInfo 37 | * @property-write string|Closure $auth 38 | */ 39 | class Connection extends Component 40 | { 41 | /** 42 | * @event Event an event that is triggered after a DB connection is established 43 | */ 44 | public const EVENT_AFTER_OPEN = 'afterOpen'; 45 | 46 | /** 47 | * @var Client 48 | */ 49 | protected static $_handler = null; 50 | 51 | /** 52 | * @var string base request URL. 53 | */ 54 | public $baseUrl = ''; 55 | 56 | /** 57 | * @var array request object configuration. 58 | */ 59 | public $requestConfig = []; 60 | 61 | /** 62 | * @var array response config configuration. 63 | */ 64 | public $responseConfig = []; 65 | 66 | /** 67 | * @var boolean Whether to use pluralisation or not 68 | */ 69 | public $usePluralisation = true; 70 | 71 | /** 72 | * @var boolean Whether to use filter keyword or not 73 | */ 74 | public $useFilterKeyword = true; 75 | 76 | /** 77 | * @var string The method to use for update operations. Defaults to [[put]]. 78 | */ 79 | public $updateMethod = 'put'; 80 | 81 | /** 82 | * @var boolean Whether the connection should throw an exception if response is not 200 or not 83 | */ 84 | public $enableExceptions = false; 85 | 86 | /** 87 | * @var boolean Whether we are in test mode or not (prevent execution) 88 | */ 89 | public $isTestMode = false; 90 | 91 | /** 92 | * @var bool whether to enable query caching. 93 | * Note that in order to enable query caching, a valid cache component as specified 94 | * by [[queryCache]] must be enabled and [[enableQueryCache]] must be set true. 95 | * Also, only the results of the queries enclosed within [[cache()]] will be cached. 96 | * @see queryCache 97 | * @see cache() 98 | * @see noCache() 99 | */ 100 | public $enableQueryCache = false; 101 | 102 | /** 103 | * @var int the default number of seconds that query results can remain valid in cache. 104 | * Defaults to 3600, meaning 3600 seconds, or one hour. Use 0 to indicate that the cached data will never expire. 105 | * The value of this property will be used when [[cache()]] is called without a cache duration. 106 | * @see enableQueryCache 107 | * @see cache() 108 | */ 109 | public $queryCacheDuration = 3600; 110 | 111 | /** 112 | * @var CacheInterface|string the cache object or the ID of the cache application component 113 | * that is used for query caching. 114 | * @see enableQueryCache 115 | */ 116 | public $queryCache = 'cache'; 117 | 118 | /** 119 | * @var string|null the name of the property in the response where the items are wrapped. If not set, there is no 120 | * wrapping property. 121 | */ 122 | public $itemsProperty; 123 | 124 | /** 125 | * @var string|Closure authorization config 126 | */ 127 | protected $_auth; 128 | 129 | /** 130 | * @var Response 131 | */ 132 | protected $_response; 133 | 134 | /** 135 | * @var array query cache parameters for the [[cache()]] calls 136 | */ 137 | private $_queryCacheInfo = []; 138 | 139 | /** 140 | * Returns the name of the DB driver. Based on the the current [[dsn]], in case it was not set explicitly 141 | * by an end user. 142 | * @return string name of the DB driver 143 | */ 144 | public static function getDriverName(): string 145 | { 146 | return 'rest'; 147 | } 148 | 149 | /** 150 | * Uses query cache for the queries performed with the callable. 151 | * 152 | * When query caching is enabled ([[enableQueryCache]] is true and [[queryCache]] refers to a valid cache), 153 | * queries performed within the callable will be cached and their results will be fetched from cache if available. 154 | * For example, 155 | * 156 | * ```php 157 | * // The customer will be fetched from cache if available. 158 | * // If not, the query will be made against DB and cached for use next time. 159 | * $customer = $db->cache(function (Connection $db) { 160 | * return $db->createCommand('SELECT * FROM customer WHERE id=1')->queryOne(); 161 | * }); 162 | * ``` 163 | * 164 | * Note that query cache is only meaningful for queries that return results. For queries performed with 165 | * [[Command::execute()]], query cache will not be used. 166 | * 167 | * @param callable $callable a PHP callable that contains DB queries which will make use of query cache. 168 | * The signature of the callable is `function (Connection $db)`. 169 | * @param int|null $duration the number of seconds that query results can remain valid in the cache. If this is 170 | * not set, the value of [[queryCacheDuration]] will be used instead. 171 | * Use 0 to indicate that the cached data will never expire. 172 | * @param \yii\caching\Dependency|null $dependency the cache dependency associated with the cached query results. 173 | * 174 | * @return mixed the return result of the callable 175 | * @throws \Exception|\Throwable if there is any exception during query 176 | * @see enableQueryCache 177 | * @see queryCache 178 | * @see noCache() 179 | */ 180 | public function cache(callable $callable, ?int $duration = null, ?\yii\caching\Dependency $dependency = null) 181 | { 182 | $this->_queryCacheInfo[] = [$duration === null ? $this->queryCacheDuration : $duration, $dependency]; 183 | try { 184 | $result = call_user_func($callable, $this); 185 | array_pop($this->_queryCacheInfo); 186 | return $result; 187 | } catch (\Exception|\Throwable $e) { 188 | array_pop($this->_queryCacheInfo); 189 | throw $e; 190 | } 191 | } 192 | 193 | /** 194 | * Disables query cache temporarily. 195 | * 196 | * Queries performed within the callable will not use query cache at all. For example, 197 | * 198 | * ```php 199 | * $db->cache(function (Connection $db) { 200 | * 201 | * // ... queries that use query cache ... 202 | * 203 | * return $db->noCache(function (Connection $db) { 204 | * // this query will not use query cache 205 | * return $db->createCommand('SELECT * FROM customer WHERE id=1')->queryOne(); 206 | * }); 207 | * }); 208 | * ``` 209 | * 210 | * @param callable $callable a PHP callable that contains DB queries which should not use query cache. 211 | * The signature of the callable is `function (Connection $db)`. 212 | * 213 | * @return mixed the return result of the callable 214 | * @throws \Exception|\Throwable if there is any exception during query 215 | * @see enableQueryCache 216 | * @see queryCache 217 | * @see cache() 218 | */ 219 | public function noCache(callable $callable) 220 | { 221 | $this->_queryCacheInfo[] = false; 222 | try { 223 | $result = call_user_func($callable, $this); 224 | array_pop($this->_queryCacheInfo); 225 | return $result; 226 | } catch (\Exception|\Throwable $e) { 227 | array_pop($this->_queryCacheInfo); 228 | throw $e; 229 | } 230 | } 231 | 232 | /** 233 | * Returns the current query cache information. 234 | * This method is used internally by [[Command]]. 235 | * 236 | * @param int|null $duration the preferred caching duration. If null, it will be ignored. 237 | * @param Dependency|null $dependency the preferred caching dependency. If null, it will be ignored. 238 | * 239 | * @return array the current query cache information, or null if query cache is not enabled. 240 | * @throws InvalidConfigException 241 | * @internal 242 | */ 243 | public function getQueryCacheInfo(?int $duration = null, ?\yii\caching\Dependency $dependency = null): ?array 244 | { 245 | if (!$this->enableQueryCache) { 246 | return null; 247 | } 248 | 249 | $info = end($this->_queryCacheInfo); 250 | if (is_array($info)) { 251 | if ($duration === null) { 252 | $duration = $info[0]; 253 | } 254 | if ($dependency === null) { 255 | $dependency = $info[1]; 256 | } 257 | } 258 | 259 | if ($duration === 0 || $duration > 0) { 260 | if (is_string($this->queryCache) && Yii::$app) { 261 | $cache = Yii::$app->get($this->queryCache, false); 262 | } else { 263 | $cache = $this->queryCache; 264 | } 265 | if ($cache instanceof CacheInterface) { 266 | return [$cache, $duration, $dependency]; 267 | } 268 | } 269 | 270 | return null; 271 | } 272 | 273 | /** 274 | * @throws InvalidConfigException 275 | */ 276 | public function init() 277 | { 278 | if (!$this->baseUrl) { 279 | throw new InvalidConfigException('The `baseUrl` config option must be set'); 280 | } 281 | 282 | $this->baseUrl = rtrim($this->baseUrl, '/'); 283 | 284 | parent::init(); 285 | } 286 | 287 | /** 288 | * Closes the connection when this component is being serialized. 289 | * @return array 290 | */ 291 | public function __sleep() 292 | { 293 | return array_keys(get_object_vars($this)); 294 | } 295 | 296 | /** 297 | * Creates a command for execution. 298 | * 299 | * @param array $config the configuration for the Command class 300 | * 301 | * @return Command the DB command 302 | */ 303 | public function createCommand(array $config = []): Command 304 | { 305 | $config['db'] = $this; 306 | return new Command($config); 307 | } 308 | 309 | /** 310 | * Creates new query builder instance. 311 | */ 312 | public function getQueryBuilder(): QueryBuilder 313 | { 314 | return new QueryBuilder($this); 315 | } 316 | 317 | /** 318 | * Performs GET HTTP request. 319 | * 320 | * @param string|array $url URL 321 | * @param array $data request body 322 | * 323 | * @return array|false response 324 | * @throws Exception 325 | */ 326 | public function get($url, array $data = []) 327 | { 328 | array_unshift($data, $url); 329 | return $this->request('get', $data); 330 | } 331 | 332 | /** 333 | * Performs HEAD HTTP request. 334 | * 335 | * @param string|array $url URL 336 | * @param array $data request body 337 | * 338 | * @return HeaderCollection response 339 | * @throws Exception 340 | */ 341 | public function head($url, array $data = []): HeaderCollection 342 | { 343 | array_unshift($data, $url); 344 | $this->request('head', $data); 345 | 346 | return $this->_response->headers; 347 | } 348 | 349 | /** 350 | * Performs POST HTTP request. 351 | * 352 | * @param string|array $url URL 353 | * @param array $data request body 354 | * 355 | * @return array|false response 356 | * @throws Exception 357 | */ 358 | public function post($url, array $data = []) 359 | { 360 | return $this->request('post', $url, $data); 361 | } 362 | 363 | /** 364 | * Performs PUT HTTP request. 365 | * 366 | * @param string|array $url URL 367 | * @param array $data request body 368 | * 369 | * @return array|false response 370 | * @throws Exception 371 | */ 372 | public function put($url, array $data = []) 373 | { 374 | return $this->request('put', $url, $data); 375 | } 376 | 377 | /** 378 | * Performs DELETE HTTP request. 379 | * 380 | * @param string|array $url URL 381 | * @param array $data request body 382 | * 383 | * @return array|false response 384 | * @throws Exception 385 | */ 386 | public function delete($url, array $data = []) 387 | { 388 | return $this->request('delete', $url, $data); 389 | } 390 | 391 | /** 392 | * Returns the request handler (Guzzle client for the moment). 393 | * Creates and setups handler if not set. 394 | */ 395 | public function getHandler(): Client 396 | { 397 | if (static::$_handler === null) { 398 | $requestConfig = $this->requestConfig; 399 | $responseConfig = array_merge([ 400 | 'class' => 'yii\httpclient\Response', 401 | 'format' => Client::FORMAT_JSON, 402 | ], $this->responseConfig); 403 | static::$_handler = new Client([ 404 | 'baseUrl' => $this->baseUrl, 405 | 'requestConfig' => $requestConfig, 406 | 'responseConfig' => $responseConfig, 407 | ]); 408 | } 409 | 410 | return static::$_handler; 411 | } 412 | 413 | /** 414 | * Returns the authorization config. 415 | * 416 | * @return string authorization config 417 | */ 418 | protected function getAuth() 419 | { 420 | if ($this->_auth instanceof Closure) { 421 | $this->_auth = call_user_func($this->_auth, $this); 422 | } 423 | 424 | return $this->_auth; 425 | } 426 | 427 | /** 428 | * Changes the current authorization config. 429 | * 430 | * @param string|Closure $auth authorization config 431 | */ 432 | public function setAuth($auth) 433 | { 434 | $this->_auth = $auth; 435 | } 436 | 437 | /** 438 | * Handles the request with handler. 439 | * Returns array or raw response content, if $raw is true. 440 | * 441 | * @param string $method POST, GET, etc 442 | * @param string|array $url the URL for request, not including proto and site 443 | * @param array $data the request data 444 | * 445 | * @return array|false 446 | * @throws Exception 447 | */ 448 | protected function request(string $method, $url, array $data = []) 449 | { 450 | if (is_array($url)) { 451 | $path = array_shift($url); 452 | $query = http_build_query($url); 453 | 454 | array_unshift($url, $path); 455 | 456 | $path .= '?' . $query; 457 | } else { 458 | $path = $url; 459 | } 460 | 461 | $headers = []; 462 | $method = strtoupper($method); 463 | $profile = $method . ' ' . $this->handler->baseUrl . '/' . $path . '#' . (is_array($data) ? http_build_query($data) : $data); 464 | 465 | if ($auth = $this->getAuth()) { 466 | $headers['Authorization'] = $auth; 467 | } 468 | if ($method === 'head') { 469 | $data = $headers; 470 | $headers = []; 471 | } 472 | 473 | Yii::beginProfile($profile, __METHOD__); 474 | /* @var $request \yii\httpclient\Request */ 475 | 476 | Yii::debug($method, __METHOD__ . '-method'); 477 | Yii::debug($this->handler->baseUrl . '/' . $path, __METHOD__ . '-url'); 478 | Yii::debug($data, __METHOD__ . '-data'); 479 | Yii::debug($headers, __METHOD__ . '-headers'); 480 | 481 | $request = call_user_func([$this->handler, $method], $url, $data, $headers); 482 | try { 483 | $this->_response = $this->isTestMode ? [] : $request->send(); 484 | } catch (\yii\httpclient\Exception $e) { 485 | throw new RestException('Request failed', [], 1, $e); 486 | } 487 | Yii::endProfile($profile, __METHOD__); 488 | 489 | if (!$this->isTestMode && !$this->_response->isOk) { 490 | if ($this->enableExceptions) { 491 | throw new RestException($this->_response->content, $this->_response->headers->toArray()); 492 | } 493 | return false; 494 | } 495 | 496 | return $this->isTestMode ? [] : $this->_response->data; 497 | } 498 | } 499 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace simialbi\yii2\rest; 11 | 12 | class Exception extends \yii\db\Exception 13 | { 14 | public function getName(): string 15 | { 16 | return 'REST Exception'; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | modelClass = $modelClass; 38 | parent::__construct($config); 39 | } 40 | 41 | /** 42 | * @param Query $from 43 | */ 44 | public static function create($from): self 45 | { 46 | $modelClass = ($from->hasProperty('modelClass')) ? $from->modelClass : null; 47 | 48 | return new self($modelClass, [ 49 | 'where' => $from->where, 50 | 'limit' => $from->limit, 51 | 'offset' => $from->offset, 52 | 'orderBy' => $from->orderBy, 53 | 'indexBy' => $from->indexBy, 54 | 'select' => $from->select, 55 | 'selectOption' => $from->selectOption, 56 | 'distinct' => $from->distinct, 57 | 'from' => $from->from, 58 | 'groupBy' => $from->groupBy, 59 | 'join' => $from->join, 60 | 'having' => $from->having, 61 | 'union' => $from->union, 62 | 'params' => $from->params, 63 | ]); 64 | } 65 | 66 | /** 67 | * Prepares for building query. 68 | * This method is called by [[QueryBuilder]] when it starts to build SQL from a query object. 69 | * You may override this method to do some final preparation work when converting a query into a SQL statement. 70 | * 71 | * @param QueryBuilder $builder 72 | * 73 | * @return $this a prepared query instance which will be used by [[QueryBuilder]] to build the SQL 74 | */ 75 | public function prepare($builder): self 76 | { 77 | return $this; 78 | } 79 | 80 | /** 81 | * Returns the number of records. 82 | * 83 | * @param string $q the COUNT expression. Defaults to '*'. 84 | * @param Connection|null $db the database connection used to execute the query. 85 | * If this parameter is not given, the `db` application component will be used. 86 | * 87 | * @return int number of records. 88 | * @throws \yii\base\InvalidConfigException 89 | * @throws \yii\db\Exception 90 | * @throws \yii\base\NotSupportedException 91 | */ 92 | public function count($q = '*', $db = null): int 93 | { 94 | if ($this->emulateExecution) { 95 | return 0; 96 | } 97 | 98 | $result = $this->createCommand($db)->execute('head'); 99 | 100 | /** @var \yii\web\HeaderCollection $result */ 101 | return (int) $result->get('x-pagination-total-count'); 102 | } 103 | 104 | /** 105 | * Creates a DB command that can be used to execute this query. 106 | * 107 | * @param Connection|null $db the connection used to generate the statement. 108 | * If this parameter is not given, the `rest` application component will be used. 109 | * 110 | * @return Command the created DB command instance. 111 | * @throws \yii\base\InvalidConfigException 112 | * @throws \yii\db\Exception 113 | * @throws \yii\base\NotSupportedException 114 | */ 115 | public function createCommand($db = null): Command 116 | { 117 | if ($db === null) { 118 | $db = Yii::$app->get(Connection::getDriverName()); 119 | } 120 | 121 | $commandConfig = $db->getQueryBuilder()->build($this); 122 | $command = $db->createCommand($commandConfig); 123 | $this->setCommandCache($command); 124 | 125 | return $command; 126 | } 127 | 128 | /** 129 | * @throws \yii\base\InvalidConfigException 130 | * @throws \yii\db\Exception 131 | * @throws \yii\base\NotSupportedException 132 | */ 133 | public function exists($db = null): bool 134 | { 135 | if ($this->emulateExecution) { 136 | return false; 137 | } 138 | 139 | $result = $this->createCommand($db)->execute('head'); 140 | 141 | /** @var \yii\web\HeaderCollection $result */ 142 | return $result->get('x-pagination-total-count', 0) > 0; 143 | } 144 | 145 | /** 146 | * Sets the model to read from / write to 147 | * 148 | * @param string $tables 149 | * 150 | * @return $this the query object itself 151 | */ 152 | public function from($tables): Query 153 | { 154 | $this->from = $tables; 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * Getter for modelClass 161 | * @return mixed 162 | */ 163 | public function getModelClass() 164 | { 165 | return $this->_modelClass; 166 | } 167 | 168 | /** 169 | * Setter for modelClass 170 | * 171 | * @param mixed $modelClass 172 | */ 173 | public function setModelClass($modelClass) 174 | { 175 | $this->_modelClass = $modelClass; 176 | } 177 | 178 | /** 179 | * @param Command $command 180 | */ 181 | protected function setCommandCache($command): Command 182 | { 183 | /** @var \yii\db\Command $command */ 184 | $command = parent::setCommandCache($command); 185 | /** @var Command $command */ 186 | return $command; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | 'buildAndCondition', 46 | 'IN' => 'buildInCondition', 47 | ]; 48 | 49 | /** 50 | * @param mixed $connection the database connection. 51 | * @param array $config name-value pairs that will be used to initialize the object properties 52 | */ 53 | public function __construct($connection, array $config = []) 54 | { 55 | parent::__construct($connection, $config); 56 | } 57 | 58 | /** 59 | * Build query data 60 | * 61 | * @param Query $query 62 | * @param array $params 63 | * 64 | * @throws \yii\db\Exception 65 | */ 66 | public function build($query, $params = []): array 67 | { 68 | $query = $query->prepare($this); 69 | 70 | $params = empty($params) ? $query->params : array_merge($params, $query->params); 71 | 72 | $clauses = [ 73 | 'fields' => $this->buildSelect($query->select, $params), 74 | 'pathInfo' => $this->buildFrom($query->from, $params), 75 | 'expand' => $this->buildJoin($query->join, $params), 76 | 'filter' => $this->buildWhere($query->where, $params), 77 | 'sort' => $this->buildOrderBy($query->orderBy), 78 | ]; 79 | 80 | $clauses = array_merge($clauses, $this->buildLimit($query->limit, $query->offset)); 81 | 82 | return [ 83 | 'modelClass' => ArrayHelper::getValue($query, 'modelClass', ''), 84 | 'pathInfo' => ArrayHelper::remove($clauses, 'pathInfo'), 85 | 'queryParams' => array_filter($clauses, function ($value) { 86 | return $value !== '' && $value !== [] && $value !== null && (!is_string($value) || trim($value) !== ''); 87 | }), 88 | ]; 89 | } 90 | 91 | public function buildSelect($columns, &$params, $distinct = false, $selectOption = null): string 92 | { 93 | if (!empty($columns) && is_array($columns)) { 94 | return implode($this->separator, $columns); 95 | } 96 | 97 | return ''; 98 | } 99 | 100 | /** 101 | * @param string $tables 102 | * @param array $params the binding parameters to be populated 103 | * 104 | * @return string the model name 105 | */ 106 | public function buildFrom($tables, &$params): string 107 | { 108 | if (!is_string($tables)) { 109 | return ''; 110 | } 111 | 112 | return trim($tables); 113 | } 114 | 115 | public function buildJoin($joins, &$params): string 116 | { 117 | if (empty($joins)) { 118 | return ''; 119 | } 120 | 121 | $expand = []; 122 | foreach ($joins as $i => $join) { 123 | if (empty($join)) { 124 | continue; 125 | } 126 | if (is_array($join)) { 127 | $expand[] = $join[1]; 128 | continue; 129 | } 130 | $expand[] = $join; 131 | } 132 | 133 | return implode($this->separator, $expand); 134 | } 135 | 136 | /** 137 | * @param string|array $condition 138 | * @param array $params the binding parameters to be populated 139 | * 140 | * @return array the WHERE clause built from [[Query::$where]]. 141 | */ 142 | public function buildWhere($condition, &$params): array 143 | { 144 | return $this->buildCondition($condition, $params); 145 | } 146 | 147 | public function buildCondition($condition, &$params): array 148 | { 149 | if (empty($condition) || !is_array($condition)) { 150 | return []; 151 | } 152 | 153 | $condition = $this->createConditionFromArray($condition); 154 | /** @var \yii\db\conditions\SimpleCondition $condition */ 155 | 156 | return $this->buildExpression($condition, $params); 157 | } 158 | 159 | public function buildExpression(ExpressionInterface $expression, &$params = []): array 160 | { 161 | return (array) parent::buildExpression($expression, $params); 162 | } 163 | 164 | public function buildOrderBy($columns): string 165 | { 166 | if (empty($columns)) { 167 | return ''; 168 | } 169 | 170 | $orders = []; 171 | foreach ($columns as $name => $direction) { 172 | if ($direction instanceof Expression) { 173 | $orders[] = $direction->expression; 174 | } else { 175 | $orders[] = ($direction === SORT_DESC ? '-' : '') . $name; 176 | } 177 | } 178 | 179 | return implode($this->separator, $orders); 180 | } 181 | 182 | public function buildLimit($limit, $offset): array 183 | { 184 | $clauses = []; 185 | if ($this->hasLimit($limit)) { 186 | $clauses['per-page'] = (string) $limit; 187 | } 188 | if ($this->hasOffset($offset)) { 189 | $offset = intval((string) $offset); 190 | $clauses['page'] = ceil($offset / $limit) + 1; 191 | } 192 | 193 | return $clauses; 194 | } 195 | 196 | /** 197 | * @return mixed 198 | */ 199 | public function bindParam($value, &$params) 200 | { 201 | return $value; 202 | } 203 | 204 | /** 205 | * @return string[] 206 | */ 207 | protected function defaultExpressionBuilders(): array 208 | { 209 | return [ 210 | 'yii\db\Query' => 'yii\db\QueryExpressionBuilder', 211 | 'yii\db\PdoValue' => 'yii\db\PdoValueBuilder', 212 | 'yii\db\Expression' => 'yii\db\ExpressionBuilder', 213 | 'yii\db\conditions\ConjunctionCondition' => 'simialbi\yii2\rest\conditions\ConjunctionConditionBuilder', 214 | 'yii\db\conditions\NotCondition' => 'simialbi\yii2\rest\conditions\NotConditionBuilder', 215 | 'yii\db\conditions\AndCondition' => 'simialbi\yii2\rest\conditions\ConjunctionConditionBuilder', 216 | 'yii\db\conditions\OrCondition' => 'simialbi\yii2\rest\conditions\ConjunctionConditionBuilder', 217 | 'yii\db\conditions\BetweenCondition' => 'simialbi\yii2\rest\conditions\BetweenConditionBuilder', 218 | 'yii\db\conditions\InCondition' => 'simialbi\yii2\rest\conditions\InConditionBuilder', 219 | 'yii\db\conditions\LikeCondition' => 'simialbi\yii2\rest\conditions\LikeConditionBuilder', 220 | // 'yii\db\conditions\ExistsCondition' => 'yii\db\conditions\ExistsConditionBuilder', 221 | 'yii\db\conditions\SimpleCondition' => 'simialbi\yii2\rest\conditions\SimpleConditionBuilder', 222 | 'yii\db\conditions\HashCondition' => 'simialbi\yii2\rest\conditions\HashConditionBuilder', 223 | // 'yii\db\conditions\BetweenColumnsCondition' => 'yii\db\conditions\BetweenColumnsConditionBuilder' 224 | ]; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/RestDataProvider.php: -------------------------------------------------------------------------------- 1 | query instanceof QueryInterface) { 33 | throw new InvalidConfigException('The "query" property must be an instance of a class that implements the QueryInterface e.g. simialbi\yii2\rest\Query or its subclasses.'); 34 | } 35 | 36 | return (int) $this->query->count(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/conditions/BetweenConditionBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace simialbi\yii2\rest\conditions; 10 | 11 | use yii\db\conditions\BetweenCondition; 12 | use yii\db\ExpressionInterface; 13 | 14 | /** 15 | * @property \simialbi\yii2\rest\QueryBuilder $queryBuilder 16 | */ 17 | class BetweenConditionBuilder extends \yii\db\conditions\BetweenConditionBuilder 18 | { 19 | use ConditionBuilderTrait; 20 | 21 | public function build(ExpressionInterface $expression, array &$params = []): array 22 | { 23 | /** @var BetweenCondition $expression */ 24 | $operator = $expression->getOperator(); 25 | $column = $expression->getColumn(); 26 | 27 | $phName1 = $this->createPlaceholder($expression->getIntervalStart(), $params); 28 | $phName2 = $this->createPlaceholder($expression->getIntervalEnd(), $params); 29 | 30 | if ($operator === 'BETWEEN') { 31 | return [ 32 | $column => [ 33 | 'gt' => $phName1, 34 | 'lt' => $phName2, 35 | ], 36 | ]; 37 | } else { 38 | return $this->queryBuilder->buildCondition([ 39 | 'or', 40 | ['<', $column, $expression->getIntervalStart()], 41 | ['>', $column, $expression->getIntervalEnd()], 42 | ], $params); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/conditions/ConditionBuilderTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace simialbi\yii2\rest\conditions; 11 | 12 | use yii\data\DataFilter; 13 | use yii\helpers\ArrayHelper; 14 | 15 | trait ConditionBuilderTrait 16 | { 17 | /** 18 | * Returns the operator that is represented by this condition class 19 | * 20 | * @throws \Exception 21 | */ 22 | protected function getOperator(string $operator): string 23 | { 24 | return ArrayHelper::getValue(array_flip((new DataFilter())->filterControls), $operator, 'and'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/conditions/ConjunctionConditionBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace simialbi\yii2\rest\conditions; 10 | 11 | use yii\db\conditions\ConjunctionCondition; 12 | use yii\db\ExpressionInterface; 13 | 14 | /** 15 | * @property \simialbi\yii2\rest\QueryBuilder $queryBuilder 16 | */ 17 | class ConjunctionConditionBuilder extends \yii\db\conditions\ConjunctionConditionBuilder 18 | { 19 | use ConditionBuilderTrait; 20 | 21 | /** 22 | * @throws \Exception 23 | */ 24 | public function build(ExpressionInterface $condition, array &$params = []): array 25 | { 26 | /** @var ConjunctionCondition $condition */ 27 | $parts = $this->buildExpressionsFrom($condition, $params); 28 | 29 | if (empty($parts)) { 30 | return []; 31 | } 32 | 33 | if (count($parts) === 1) { 34 | return $parts; 35 | } 36 | 37 | return [ 38 | $this->getOperator($condition->getOperator()) => $parts, 39 | ]; 40 | } 41 | 42 | /** 43 | * Builds expressions, that are stored in $condition 44 | * 45 | * @param ExpressionInterface|ConjunctionCondition $condition the expression to be built. 46 | * @param array $params the binding parameters. 47 | * 48 | * @return string[] 49 | */ 50 | private function buildExpressionsFrom(ExpressionInterface $condition, array &$params = []): array 51 | { 52 | $parts = []; 53 | foreach ($condition->getExpressions() as $condition) { 54 | if (is_array($condition)) { 55 | $condition = $this->queryBuilder->buildCondition($condition, $params); 56 | } 57 | if ($condition instanceof ExpressionInterface) { 58 | $condition = $this->queryBuilder->buildExpression($condition, $params); 59 | } 60 | if ($condition !== '') { 61 | $parts[] = $condition; 62 | } 63 | } 64 | 65 | return $parts; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/conditions/HashConditionBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace simialbi\yii2\rest\conditions; 10 | 11 | use simialbi\yii2\rest\Query; 12 | use yii\db\conditions\InCondition; 13 | use yii\db\ExpressionInterface; 14 | use yii\helpers\ArrayHelper; 15 | 16 | /** 17 | * @property \simialbi\yii2\rest\QueryBuilder $queryBuilder 18 | */ 19 | class HashConditionBuilder extends \yii\db\conditions\HashConditionBuilder 20 | { 21 | use ConditionBuilderTrait; 22 | 23 | public function build(ExpressionInterface $expression, array &$params = []): array 24 | { 25 | /** @var \yii\db\conditions\HashCondition $expression */ 26 | 27 | $hash = $expression->getHash(); 28 | $parts = []; 29 | foreach ($hash as $column => $value) { 30 | if (ArrayHelper::isTraversable($value) || $value instanceof Query) { 31 | // IN condition 32 | $parts[] = $this->queryBuilder->buildCondition(new InCondition($column, 'IN', $value), $params); 33 | } else { 34 | if ($value === null) { 35 | $parts[] = [ 36 | $column => null, 37 | ]; 38 | } elseif ($value instanceof ExpressionInterface) { 39 | $parts[] = [ 40 | $column => $this->queryBuilder->buildExpression($value, $params), 41 | ]; 42 | } else { 43 | $phName = $this->queryBuilder->bindParam($value, $params); 44 | $parts[] = [ 45 | $column => $phName, 46 | ]; 47 | } 48 | } 49 | } 50 | 51 | return count($parts) === 1 ? $parts[0] : $parts; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/conditions/InConditionBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace simialbi\yii2\rest\conditions; 10 | 11 | use simialbi\yii2\rest\Query; 12 | use yii\db\conditions\InCondition; 13 | use yii\db\ExpressionInterface; 14 | 15 | /** 16 | * @property \simialbi\yii2\rest\QueryBuilder $queryBuilder 17 | */ 18 | class InConditionBuilder extends \yii\db\conditions\InConditionBuilder 19 | { 20 | use ConditionBuilderTrait; 21 | 22 | public function build(ExpressionInterface $expression, array &$params = []): array 23 | { 24 | /** @var InCondition $expression */ 25 | 26 | $operator = $expression->getOperator(); 27 | $column = $expression->getColumn(); 28 | $values = $expression->getValues(); 29 | 30 | if ($column === []) { 31 | return [ 32 | 0 => 1, 33 | ]; 34 | } 35 | 36 | if ($values instanceof Query) { 37 | // TODO 38 | // return $this->buildSubqueryInCondition($operator, $column, $values, $params); 39 | return []; 40 | } 41 | if ($column instanceof \Traversable || ((is_array($column) || $column instanceof \Countable) && count($column) > 1)) { 42 | // TODO 43 | // return $this->buildCompositeInCondition($operator, $column, $values, $params); 44 | return []; 45 | } 46 | 47 | if (is_array($column)) { 48 | $column = reset($column); 49 | } 50 | 51 | $sqlValues = $this->buildValues($expression, $values, $params); 52 | if (empty($sqlValues)) { 53 | return [ 54 | 0 => 1, 55 | ]; 56 | } 57 | 58 | if (count($sqlValues) > 1) { 59 | $operator = ($operator === 'IN') ? 'in' : 'nin'; 60 | return [ 61 | $column => [ 62 | $operator => $sqlValues, 63 | ], 64 | ]; 65 | } 66 | 67 | return $operator === 'IN' 68 | ? [ 69 | $column => reset($sqlValues), 70 | ] 71 | : $this->queryBuilder->buildCondition([ 72 | 'not', [ 73 | $column => reset($sqlValues), 74 | ]], $params); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/conditions/LikeConditionBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace simialbi\yii2\rest\conditions; 10 | 11 | use yii\db\conditions\LikeCondition; 12 | use yii\db\ExpressionInterface; 13 | 14 | /** 15 | * @property \simialbi\yii2\rest\QueryBuilder $queryBuilder 16 | */ 17 | class LikeConditionBuilder extends \yii\db\conditions\LikeConditionBuilder 18 | { 19 | use ConditionBuilderTrait; 20 | 21 | /** 22 | * @throws \Exception 23 | */ 24 | public function build(ExpressionInterface $expression, array &$params = []): array 25 | { 26 | /** @var LikeCondition $expression */ 27 | $operator = $expression->getOperator(); 28 | $column = $expression->getColumn(); 29 | $values = $expression->getValue(); 30 | // $escape = $expression->getEscapingReplacements(); 31 | // if ($escape === null || $escape === []) { 32 | // $escape = $this->escapingReplacements; 33 | // } 34 | 35 | [$andor, $not] = $this->parseOperator($operator); 36 | 37 | if (!is_array($values)) { 38 | $values = [$values]; 39 | } 40 | 41 | if (empty($values)) { 42 | return $not ? [] : $this->queryBuilder->buildCondition([ 43 | 0 => 1, 44 | ], $params); 45 | } 46 | 47 | $parts = []; 48 | foreach ($values as $value) { 49 | if ($value instanceof ExpressionInterface) { 50 | $phName = $this->queryBuilder->buildExpression($value, $params); 51 | } else { 52 | $phName = $this->queryBuilder->bindParam($value, $params); 53 | } 54 | $parts[] = [ 55 | $column => [ 56 | 'like' => reset($phName), 57 | ], 58 | ]; 59 | } 60 | 61 | if (count($parts) === 1) { 62 | return reset($parts); 63 | } 64 | 65 | array_unshift($parts, $this->getOperator($andor)); 66 | 67 | return $this->queryBuilder->buildCondition($parts, $params); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/conditions/NotConditionBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace simialbi\yii2\rest\conditions; 10 | 11 | use yii\db\conditions\NotCondition; 12 | use yii\db\ExpressionInterface; 13 | 14 | /** 15 | * @property \simialbi\yii2\rest\QueryBuilder $queryBuilder 16 | */ 17 | class NotConditionBuilder extends \yii\db\conditions\NotConditionBuilder 18 | { 19 | use ConditionBuilderTrait; 20 | 21 | public function build(ExpressionInterface $expression, array &$params = []): array 22 | { 23 | /** @var NotCondition $expression */ 24 | $operand = $expression->getCondition(); 25 | if (empty($operand)) { 26 | return []; 27 | } 28 | 29 | $expression = $this->queryBuilder->buildCondition($operand, $params); 30 | 31 | return [ 32 | $this->getNegationOperator() => $expression, 33 | ]; 34 | } 35 | 36 | protected function getNegationOperator(): string 37 | { 38 | return 'not'; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/conditions/SimpleConditionBuilder.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace simialbi\yii2\rest\conditions; 10 | 11 | use yii\db\conditions\SimpleCondition; 12 | use yii\db\ExpressionInterface; 13 | 14 | /** 15 | * @property \simialbi\yii2\rest\QueryBuilder $queryBuilder 16 | */ 17 | class SimpleConditionBuilder extends \yii\db\conditions\SimpleConditionBuilder 18 | { 19 | use ConditionBuilderTrait; 20 | 21 | /** 22 | * @throws \Exception 23 | */ 24 | public function build(ExpressionInterface $expression, array &$params = []): array 25 | { 26 | /** @var SimpleCondition $expression */ 27 | $operator = $expression->getOperator(); 28 | $column = $expression->getColumn(); 29 | $value = $expression->getValue(); 30 | 31 | if ($value === null) { 32 | return [ 33 | $column => [ 34 | $this->getOperator($operator) => null, 35 | ], 36 | ]; 37 | } 38 | if ($value instanceof ExpressionInterface) { 39 | return [ 40 | $column => [ 41 | $this->getOperator($operator) => $this->queryBuilder->buildExpression($value, $params), 42 | ], 43 | ]; 44 | } 45 | 46 | $phName = $this->queryBuilder->bindParam($value, $params); 47 | return [ 48 | $column => [ 49 | $this->getOperator($operator) => $phName, 50 | ], 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/RelationTest.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace yiiunit\extensions\rest; 11 | 12 | use Yii; 13 | use yiiunit\extensions\rest\fixtures\RestModelFixture; 14 | use yiiunit\extensions\rest\models\RestModel; 15 | 16 | class RelationTest extends TestCase 17 | { 18 | protected function setUp(): void 19 | { 20 | parent::setUp(); 21 | 22 | $this->mockWebApplication(); 23 | Yii::$app->log->logger->flush(); 24 | } 25 | 26 | public function testRelationGetAll() 27 | { 28 | $fixture = new RestModelFixture(); 29 | $fixture->load(); 30 | 31 | /** @var RestModel $model */ 32 | $model = $fixture->getModel(0); 33 | 34 | $this->assertInstanceOf(RestModel::class, $model); 35 | 36 | Yii::$app->log->logger->flush(); 37 | 38 | // var_dump($model); 39 | $model->getRelatedRests()->all(); 40 | 41 | $logEntry = $this->parseLogs(); 42 | 43 | $this->assertEquals('GET', $logEntry['method']); 44 | $this->assertStringStartsWith('https://api.site.com/related-rest-models?filter%5Brest_model_id%5D=1', $logEntry['url']); 45 | } 46 | 47 | public function testRelationGetOne() 48 | { 49 | $fixture = new RestModelFixture(); 50 | $fixture->load(); 51 | 52 | /** @var RestModel $model */ 53 | $model = $fixture->getModel(0); 54 | 55 | $this->assertInstanceOf(RestModel::class, $model); 56 | 57 | Yii::$app->log->logger->flush(); 58 | 59 | $related = $model->relatedRest; 60 | 61 | $logEntry = $this->parseLogs(); 62 | 63 | $this->assertEquals('GET', $logEntry['method']); 64 | $this->assertStringStartsWith('https://api.site.com/related-rest-models?filter%5Brest_model_id%5D=1', $logEntry['url']); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | 9 | namespace yiiunit\extensions\rest; 10 | 11 | use Yii; 12 | use yii\di\Container; 13 | use yii\helpers\ArrayHelper; 14 | 15 | class TestCase extends \PHPUnit\Framework\TestCase 16 | { 17 | private $_index = 0; 18 | 19 | protected function tearDown(): void 20 | { 21 | parent::tearDown(); 22 | $this->destroyApplication(); 23 | } 24 | 25 | 26 | protected function mockWebApplication(array $config = [], string $appClass = '\yii\web\Application') 27 | { 28 | new $appClass(ArrayHelper::merge([ 29 | 'id' => 'testapp', 30 | 'basePath' => __DIR__, 31 | 'vendorPath' => dirname(__DIR__) . '/vendor', 32 | 'aliases' => [ 33 | '@bower' => '@vendor/bower-asset', 34 | '@npm' => '@vendor/npm-asset', 35 | ], 36 | 'components' => [ 37 | 'request' => [ 38 | 'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq', 39 | 'scriptFile' => __DIR__ . '/index.php', 40 | 'scriptUrl' => '/index.php', 41 | ], 42 | 'rest' => [ 43 | 'class' => 'simialbi\yii2\rest\Connection', 44 | 'baseUrl' => 'https://api.site.com/', 45 | 'isTestMode' => true, 46 | ], 47 | 'log' => [ 48 | 'traceLevel' => 3, 49 | 'targets' => [ 50 | [ 51 | 'class' => 'yiiunit\extensions\rest\log\ArrayTarget', 52 | ], 53 | ], 54 | 'flushInterval' => 0, 55 | ], 56 | ], 57 | ], $config)); 58 | } 59 | 60 | /** 61 | * Destroys application in Yii::$app by setting it to null. 62 | */ 63 | protected function destroyApplication() 64 | { 65 | Yii::$app = null; 66 | Yii::$container = new Container(); 67 | } 68 | 69 | /** 70 | * Parse log from index and returns data 71 | */ 72 | protected function parseLogs(): array 73 | { 74 | $method = ''; 75 | $url = ''; 76 | $data = []; 77 | $headers = []; 78 | 79 | $profile = false; 80 | for (; $this->_index <= count(Yii::$app->log->logger->messages); $this->_index++) { 81 | $message = Yii::$app->log->logger->messages[$this->_index]; 82 | if ($message[2] === 'simialbi\yii2\rest\Connection::request-method') { 83 | $method = $message[0]; 84 | } elseif ($message[2] === 'simialbi\yii2\rest\Connection::request-url') { 85 | $url = $message[0]; 86 | } elseif ($message[2] === 'simialbi\yii2\rest\Connection::request-data') { 87 | $data = $message[0]; 88 | } elseif ($message[2] === 'simialbi\yii2\rest\Connection::request-headers') { 89 | $data = $message[0]; 90 | } elseif ($message[2] === 'simialbi\yii2\rest\Connection::request') { 91 | if ($profile) { 92 | break; 93 | } 94 | $profile = true; 95 | } 96 | } 97 | 98 | return [ 99 | 'method' => $method, 100 | 'url' => $url, 101 | 'data' => $data, 102 | 'headers' => $headers, 103 | ]; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/UrlTest.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace yiiunit\extensions\rest; 11 | 12 | use Yii; 13 | use yiiunit\extensions\rest\models\RestModel; 14 | 15 | class UrlTest extends TestCase 16 | { 17 | protected function setUp(): void 18 | { 19 | parent::setUp(); 20 | 21 | $this->mockWebApplication(); 22 | Yii::$app->log->logger->flush(); 23 | } 24 | 25 | public function testGetOne() 26 | { 27 | RestModel::findOne(1); 28 | 29 | $logEntry = $this->parseLogs(); 30 | 31 | $this->assertEquals('GET', $logEntry['method']); 32 | $this->assertStringStartsWith('https://api.site.com/rest-models/1', $logEntry['url']); 33 | } 34 | 35 | public function testGetAnotherOne() 36 | { 37 | RestModel::find()->where([ 38 | 'id' => 1, 39 | ])->one(); 40 | 41 | $logEntry = $this->parseLogs(); 42 | 43 | $this->assertEquals('GET', $logEntry['method']); 44 | $this->assertStringStartsWith('https://api.site.com/rest-models/1', $logEntry['url']); 45 | } 46 | 47 | public function testFilter() 48 | { 49 | RestModel::find()->where([ 50 | 'name' => 'John', 51 | ])->one(); 52 | 53 | $logEntry = $this->parseLogs(); 54 | 55 | $this->assertEquals('GET', $logEntry['method']); 56 | $this->assertStringStartsWith('https://api.site.com/rest-models?filter%5Bname%5D=John', $logEntry['url']); 57 | } 58 | 59 | public function testWithoutFilterKeyword() 60 | { 61 | $this->mockWebApplication([ 62 | 'components' => [ 63 | 'rest' => [ 64 | 'useFilterKeyword' => false, 65 | ], 66 | ], 67 | ]); 68 | 69 | RestModel::find()->where([ 70 | 'name' => 'John', 71 | ])->one(); 72 | 73 | $logEntry = $this->parseLogs(); 74 | 75 | $this->assertEquals('GET', $logEntry['method']); 76 | $this->assertStringStartsWith('https://api.site.com/rest-models?name=John', $logEntry['url']); 77 | } 78 | 79 | public function testDeleteOne() 80 | { 81 | $model = new RestModel([ 82 | 'oldAttributes' => [ 83 | 'id' => 1, 84 | 'name' => 'test', 85 | 'description' => 'test', 86 | 'created_at' => time(), 87 | 'updated_at' => time(), 88 | 'created_by' => 'simialbi', 89 | 'updated_by' => 'simialbi', 90 | ], 91 | 'attributes' => [ 92 | 'id' => 1, 93 | 'name' => 'test', 94 | 'description' => 'test', 95 | 'created_at' => time(), 96 | 'updated_at' => time(), 97 | 'created_by' => 'simialbi', 98 | 'updated_by' => 'simialbi', 99 | ], 100 | ]); 101 | 102 | $model->delete(); 103 | 104 | $logEntry = $this->parseLogs(); 105 | 106 | $this->assertEquals('DELETE', $logEntry['method']); 107 | $this->assertStringStartsWith('https://api.site.com/rest-models/1', $logEntry['url']); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/UrlWithoutPluralisationTest.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace yiiunit\extensions\rest; 11 | 12 | use Yii; 13 | use yiiunit\extensions\rest\models\RestModel; 14 | 15 | class UrlWithoutPluralisationTest extends TestCase 16 | { 17 | protected function setUp(): void 18 | { 19 | parent::setUp(); 20 | 21 | $this->mockWebApplication([ 22 | 'components' => [ 23 | 'rest' => [ 24 | 'usePluralisation' => false, 25 | ], 26 | ], 27 | ]); 28 | Yii::$app->log->logger->flush(); 29 | } 30 | 31 | public function testGetOne() 32 | { 33 | RestModel::findOne(1); 34 | 35 | $logEntry = $this->parseLogs(); 36 | 37 | $this->assertEquals('GET', $logEntry['method']); 38 | $this->assertStringStartsWith('https://api.site.com/rest-model/1', $logEntry['url']); 39 | } 40 | 41 | public function testGetAnotherOne() 42 | { 43 | RestModel::find()->where([ 44 | 'id' => 1, 45 | ])->one(); 46 | 47 | $logEntry = $this->parseLogs(); 48 | 49 | $this->assertEquals('GET', $logEntry['method']); 50 | $this->assertStringStartsWith('https://api.site.com/rest-model/1', $logEntry['url']); 51 | } 52 | 53 | public function testDeleteOne() 54 | { 55 | $model = new RestModel([ 56 | 'oldAttributes' => [ 57 | 'id' => 1, 58 | 'name' => 'test', 59 | 'description' => 'test', 60 | 'created_at' => time(), 61 | 'updated_at' => time(), 62 | 'created_by' => 'simialbi', 63 | 'updated_by' => 'simialbi', 64 | ], 65 | 'attributes' => [ 66 | 'id' => 1, 67 | 'name' => 'test', 68 | 'description' => 'test', 69 | 'created_at' => time(), 70 | 'updated_at' => time(), 71 | 'created_by' => 'simialbi', 72 | 'updated_by' => 'simialbi', 73 | ], 74 | ]); 75 | 76 | $model->delete(); 77 | 78 | $logEntry = $this->parseLogs(); 79 | 80 | $this->assertEquals('DELETE', $logEntry['method']); 81 | $this->assertStringStartsWith('https://api.site.com/rest-model/1', $logEntry['url']); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace yiiunit\extensions\rest\fixtures; 11 | 12 | use simialbi\yii2\rest\ActiveFixture; 13 | 14 | class RelatedRestModelFixture extends ActiveFixture 15 | { 16 | public $modelClass = 'yiiunit\extensions\rest\models\RelatedRestModel'; 17 | public $depends = ['yiiunit\extensions\rest\fixtures\RestModelFixture']; 18 | } 19 | -------------------------------------------------------------------------------- /tests/fixtures/RestModelFixture.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace yiiunit\extensions\rest\fixtures; 11 | 12 | use simialbi\yii2\rest\ActiveFixture; 13 | 14 | class RestModelFixture extends ActiveFixture 15 | { 16 | public $modelClass = 'yiiunit\extensions\rest\models\RestModel'; 17 | } 18 | -------------------------------------------------------------------------------- /tests/fixtures/data/related-rest-model.php: -------------------------------------------------------------------------------- 1 | 1, 8 | 'rest_model_id' => 1, 9 | 'subject' => 'Related Model 1', 10 | 'message' => 'This is the first related model', 11 | 'created_at' => 1555660035, 12 | 'updated_at' => 1555660035, 13 | 'created_by' => 'simialbi', 14 | 'updated_by' => 'simialbi', 15 | ], 16 | [ 17 | 'id' => 2, 18 | 'rest_model_id' => 1, 19 | 'subject' => 'Related Model 2', 20 | 'message' => 'This is the second related model', 21 | 'created_at' => 1555660035, 22 | 'updated_at' => 1555660035, 23 | 'created_by' => 'simialbi', 24 | 'updated_by' => 'simialbi', 25 | ], 26 | [ 27 | 'id' => 3, 28 | 'rest_model_id' => 1, 29 | 'subject' => 'Related Model 3', 30 | 'message' => 'This is the third related model', 31 | 'created_at' => 1555660035, 32 | 'updated_at' => 1555660035, 33 | 'created_by' => 'simialbi', 34 | 'updated_by' => 'simialbi', 35 | ], 36 | [ 37 | 'id' => 1, 38 | 'rest_model_id' => 2, 39 | 'subject' => 'Related Model 2.1', 40 | 'message' => 'This is the first related model', 41 | 'created_at' => 1555660035, 42 | 'updated_at' => 1555660035, 43 | 'created_by' => 'simialbi', 44 | 'updated_by' => 'simialbi', 45 | ], 46 | [ 47 | 'id' => 1, 48 | 'rest_model_id' => 2, 49 | 'subject' => 'Related Model 2.2', 50 | 'message' => 'This is the second related model', 51 | 'created_at' => 1555660035, 52 | 'updated_at' => 1555660035, 53 | 'created_by' => 'simialbi', 54 | 'updated_by' => 'simialbi', 55 | ], 56 | ]; 57 | -------------------------------------------------------------------------------- /tests/fixtures/data/rest-model.php: -------------------------------------------------------------------------------- 1 | 1, 8 | 'name' => 'Model 1', 9 | 'description' => 'This is the representation of model 1', 10 | 'created_at' => 1555660035, 11 | 'updated_at' => 1555660035, 12 | 'created_by' => 'simialbi', 13 | 'updated_by' => 'simialbi', 14 | ], 15 | [ 16 | 'id' => 2, 17 | 'name' => 'Model 2', 18 | 'description' => 'This is the representation of model 2', 19 | 'created_at' => 1555660035, 20 | 'updated_at' => 1555660035, 21 | 'created_by' => 'simialbi', 22 | 'updated_by' => 'simialbi', 23 | ], 24 | [ 25 | 'id' => 3, 26 | 'name' => 'Model 3', 27 | 'description' => 'This is the representation of model 3', 28 | 'created_at' => 1555660035, 29 | 'updated_at' => 1555660035, 30 | 'created_by' => 'simialbi', 31 | 'updated_by' => 'simialbi', 32 | ], 33 | ]; 34 | -------------------------------------------------------------------------------- /tests/log/ArrayTarget.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace yiiunit\extensions\rest\log; 11 | 12 | use yii\helpers\ArrayHelper; 13 | use yii\log\Target; 14 | 15 | /** 16 | * @package yiiunit\extensions\rest\log 17 | * 18 | * @property-read array $cache 19 | */ 20 | class ArrayTarget extends Target 21 | { 22 | /** 23 | * @var array Stores log data 24 | */ 25 | private $_cache = []; 26 | 27 | /** 28 | * Exports log [[messages]] to a specific destination. 29 | * Child classes must implement this method. 30 | */ 31 | public function export() 32 | { 33 | $this->_cache = ArrayHelper::merge($this->_cache, $this->messages); 34 | } 35 | 36 | /** 37 | * Getter for cache variable 38 | */ 39 | public function getCache(): array 40 | { 41 | return $this->_cache; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/models/.gitignore: -------------------------------------------------------------------------------- 1 | ReservationEvent.php 2 | ReservationObject.php 3 | ReservationProperty.php 4 | ReservationShow.php 5 | -------------------------------------------------------------------------------- /tests/models/RelatedRestModel.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace yiiunit\extensions\rest\models; 11 | 12 | use simialbi\yii2\rest\ActiveRecord; 13 | 14 | /** 15 | * @package yiiunit\extensions\rest\models 16 | * 17 | * @property integer $id 18 | * @property integer $rest_model_id 19 | * @property string $subject 20 | * @property string $message 21 | * @property integer $created_at 22 | * @property integer $updated_at 23 | * @property string $created_by 24 | * @property string $updated_by 25 | */ 26 | class RelatedRestModel extends ActiveRecord 27 | { 28 | public static function primaryKey(): array 29 | { 30 | return ['id']; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/models/RestModel.php: -------------------------------------------------------------------------------- 1 | 7 | * @copyright Copyright © 2019 Simon Karlen 8 | */ 9 | 10 | namespace yiiunit\extensions\rest\models; 11 | 12 | use simialbi\yii2\rest\ActiveRecord; 13 | 14 | /** 15 | * @package yiiunit\extensions\rest\models 16 | * 17 | * @property integer $id 18 | * @property string $name 19 | * @property string $description 20 | * @property integer $created_at 21 | * @property integer $updated_at 22 | * @property string $created_by 23 | * @property string $updated_by 24 | * 25 | * @property-read RelatedRestModel[] $relatedRests 26 | * @property-read RelatedRestModel $relatedRest 27 | */ 28 | class RestModel extends ActiveRecord 29 | { 30 | public static function primaryKey(): array 31 | { 32 | return ['id']; 33 | } 34 | 35 | /** 36 | * Get related rests 37 | */ 38 | public function getRelatedRests(): \yii\db\ActiveQueryInterface 39 | { 40 | return $this->hasMany(RelatedRestModel::class, [ 41 | 'rest_model_id' => 'id', 42 | ]); 43 | } 44 | 45 | /** 46 | * Get related rest 47 | * @return \simialbi\yii2\rest\ActiveQuery|\yii\db\ActiveQuery|\yii\db\ActiveQueryInterface 48 | */ 49 | public function getRelatedRest() 50 | { 51 | return $this->hasOne(RelatedRestModel::class, [ 52 | 'rest_model_id' => 'id', 53 | ]); 54 | } 55 | } 56 | --------------------------------------------------------------------------------