├── composer.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md └── src └── VariationBehavior.php /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yii2tech/ar-variation", 3 | "description": "Provides support for ActiveRecord variation via related models in Yii2", 4 | "keywords": ["yii2", "active", "record", "variation", "variator", "link", "many", "many-to-many", "many to many", "translation", "i18n"], 5 | "type": "yii2-extension", 6 | "license": "BSD-3-Clause", 7 | "support": { 8 | "issues": "https://github.com/yii2tech/ar-variation/issues", 9 | "forum": "http://www.yiiframework.com/forum/", 10 | "wiki": "https://github.com/yii2tech/ar-variation/wiki", 11 | "source": "https://github.com/yii2tech/ar-variation" 12 | }, 13 | "authors": [ 14 | { 15 | "name": "Paul Klimov", 16 | "email": "klimov.paul@gmail.com" 17 | } 18 | ], 19 | "require": { 20 | "yiisoft/yii2": "~2.0.14" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "4.8.27|^5.0|^6.0" 24 | }, 25 | "repositories": [ 26 | { 27 | "type": "composer", 28 | "url": "https://asset-packagist.org" 29 | } 30 | ], 31 | "autoload": { 32 | "psr-4": {"yii2tech\\ar\\variation\\": "src"} 33 | }, 34 | "extra": { 35 | "branch-alias": { 36 | "dev-master": "1.0.x-dev" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Yii 2 ActiveRecord Variation extension Change Log 2 | ================================================= 3 | 4 | 1.0.5, July 30, 2019 5 | -------------------- 6 | 7 | - Bug #24: Fix ambiguous column error while joining multiple `VariationBehavior::$defaultVariationRelation` (klimov-paul) 8 | 9 | 10 | 1.0.4, April 9, 2018 11 | -------------------- 12 | 13 | - Bug #20: Fixed variation relations are not saved in case using Yii 2.0.14 (klimov-paul) 14 | 15 | 16 | 1.0.3, December 23, 2016 17 | ------------------------ 18 | 19 | - Bug #17: Fixed owner validation and saving fails, if default variation relation is initialized with `null` (klimov-paul) 20 | 21 | 22 | 1.0.2, December 8, 2016 23 | ----------------------- 24 | 25 | - Enh #16: Automatic creation and saving of default variation model provided (klimov-paul) 26 | 27 | 28 | 1.0.1, February 10, 2016 29 | ------------------------ 30 | 31 | - Bug #13: Preset value for `VariationBehavior::$defaultVariationRelation` removed (klimov-paul) 32 | - Bug #12: `VariationBehavior` does not use `ActiveRecord::getRelation()` while retrieving relation instance (klimov-paul) 33 | - Bug #11: Relation declared via `VariationBehavior::hasDefaultVariationRelation()` does not support `LEFT JOIN` (klimov-paul) 34 | 35 | 36 | 1.0.0, December 29, 2015 37 | ------------------------ 38 | 39 | - Initial release. 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The Yii framework is free software. It is released under the terms of 2 | the following BSD License. 3 | 4 | Copyright © 2015 by Yii2tech (https://github.com/yii2tech) 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions 9 | are met: 10 | 11 | * Redistributions of source code must retain the above copyright 12 | notice, this list of conditions and the following disclaimer. 13 | * Redistributions in binary form must reproduce the above copyright 14 | notice, this list of conditions and the following disclaimer in 15 | the documentation and/or other materials provided with the 16 | distribution. 17 | * Neither the name of Yii2tech nor the names of its 18 | contributors may be used to endorse or promote products derived 19 | from this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 22 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 23 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 24 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 25 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 26 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 27 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 28 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 30 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 31 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 32 | POSSIBILITY OF SUCH DAMAGE. 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

ActiveRecord Variation Extension for Yii 2

6 |
7 |

8 | 9 | This extension provides support for ActiveRecord variation via related models. 10 | In particular it allows implementing i18n feature for ActiveRecord. 11 | 12 | For license information check the [LICENSE](LICENSE.md)-file. 13 | 14 | [![Latest Stable Version](https://poser.pugx.org/yii2tech/ar-variation/v/stable.png)](https://packagist.org/packages/yii2tech/ar-variation) 15 | [![Total Downloads](https://poser.pugx.org/yii2tech/ar-variation/downloads.png)](https://packagist.org/packages/yii2tech/ar-variation) 16 | [![Build Status](https://travis-ci.org/yii2tech/ar-variation.svg?branch=master)](https://travis-ci.org/yii2tech/ar-variation) 17 | 18 | 19 | Installation 20 | ------------ 21 | 22 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/). 23 | 24 | Either run 25 | 26 | ``` 27 | php composer.phar require --prefer-dist yii2tech/ar-variation 28 | ``` 29 | 30 | or add 31 | 32 | ```json 33 | "yii2tech/ar-variation": "*" 34 | ``` 35 | 36 | to the require section of your composer.json. 37 | 38 | 39 | Usage 40 | ----- 41 | 42 | This extension provides support for ActiveRecord variation via related models. 43 | Variation means some particular entity have an attributes (fields), which values should vary depending on actual 44 | selected option. In database structure variation is implemented as many-to-many relation with extra columns at 45 | junction entity. 46 | 47 | The most common example of such case is i18n feature: imagine we have an item, which title and description should 48 | be provided on several different languages. In relational database there will be 2 different tables for this case: 49 | one for the item and second - for the item translation, which have item id and language id along with actual title 50 | and description. A DDL for such solution will be following: 51 | 52 | ```sql 53 | CREATE TABLE `Language` 54 | ( 55 | `id` varchar(5) NOT NULL, 56 | `name` varchar(64) NOT NULL, 57 | `locale` varchar(5) NOT NULL, 58 | PRIMARY KEY (`id`) 59 | ) ENGINE InnoDB; 60 | 61 | CREATE TABLE `Item` 62 | ( 63 | `id` integer NOT NULL AUTO_INCREMENT, 64 | `name` varchar(64) NOT NULL, 65 | `price` integer, 66 | PRIMARY KEY (`id`) 67 | ) ENGINE InnoDB; 68 | 69 | CREATE TABLE `ItemTranslation` 70 | ( 71 | `itemId` integer NOT NULL, 72 | `languageId` varchar(5) NOT NULL, 73 | `title` varchar(64) NOT NULL, 74 | `description` TEXT, 75 | PRIMARY KEY (`itemId`, `languageId`) 76 | FOREIGN KEY (`itemId`) REFERENCES `Item` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 77 | FOREIGN KEY (`languageId`) REFERENCES `Language` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 78 | ) ENGINE InnoDB; 79 | ``` 80 | 81 | Usually in most cases there is no need for 'Item' to know about all its translations - it is enough to fetch 82 | only one, which is used as web application interface language. 83 | 84 | This extension provides [[\yii2tech\ar\variation\VariationBehavior]] ActiveRecord behavior for such solution 85 | support in Yii2. You'll have to create an ActiveRecord class for 'Language', 'Item' and 'ItemTranslation' and 86 | attach [[\yii2tech\ar\variation\VariationBehavior]] in the following way: 87 | 88 | ```php 89 | class Item extends ActiveRecord 90 | { 91 | public function behaviors() 92 | { 93 | return [ 94 | 'translations' => [ 95 | 'class' => VariationBehavior::className(), 96 | 'variationsRelation' => 'translations', 97 | 'defaultVariationRelation' => 'defaultTranslation', 98 | 'variationOptionReferenceAttribute' => 'languageId', 99 | 'optionModelClass' => Language::className(), 100 | 'defaultVariationOptionReference' => function() {return Yii::$app->language;}, 101 | 'variationAttributeDefaultValueMap' => [ 102 | 'title' => 'name' 103 | ], 104 | ], 105 | ]; 106 | } 107 | 108 | public static function tableName() 109 | { 110 | return 'Item'; 111 | } 112 | 113 | /** 114 | * @return \yii\db\ActiveQuery 115 | */ 116 | public function getTranslations() 117 | { 118 | return $this->hasMany(ItemTranslation::className(), ['itemId' => 'id']); 119 | } 120 | 121 | /** 122 | * @return \yii\db\ActiveQuery 123 | */ 124 | public function getDefaultTranslation() 125 | { 126 | return $this->hasDefaultVariationRelation(); // convert "has many translations" into "has one defaultTranslation" 127 | } 128 | } 129 | ``` 130 | 131 | Pay attention to the fact behavior is working through the 'has many' relation declared in the main ActiveRecord to 132 | the variation ActiveRecord. In the above example it will be relation 'translations'. You also have to declare default 133 | variation relation as 'has one', this can be easily done via [[\yii2tech\ar\variation\VariationBehavior::hasDefaultVariationRelation()]] 134 | method. Such relation inherits all information from the source one and applies extra condition on variation option reference, 135 | which is determined by [[\yii2tech\ar\variation\VariationBehavior::defaultVariationOptionReference]]. This reference should 136 | provide default value, which matches value of [[\yii2tech\ar\variation\VariationBehavior::variationOptionReferenceAttribute]] of 137 | the variation entity. 138 | 139 | 140 | ## Accessing variation attributes 141 | 142 | Having `defaultVariationRelation` is important for the usage of the variation attributes. 143 | Being applied [[\yii2tech\ar\variation\VariationBehavior]] allows access to the variation fields as they were 144 | the main one: 145 | 146 | ```php 147 | $item = Item::findOne(1); 148 | echo $item->title; // equal to `$item->defaultTranslation->title` 149 | echo $item->description; // equal to `$item->defaultTranslation->description` 150 | ``` 151 | 152 | If it could be the main entity don't have a variation for particular option, you can use [[\yii2tech\ar\variation\VariationBehavior::$variationAttributeDefaultValueMap]] 153 | to provide the default value for the variation fields as it was done for 'title' in the above example: 154 | 155 | ```php 156 | $item = new Item(); // of course there is no translation for the new item 157 | $item->name = 'new item'; 158 | echo $item->title; // outputs 'new item' 159 | ``` 160 | 161 | 162 | ## Querying variations 163 | 164 | As it has been already said [[\yii2tech\ar\variation\VariationBehavior]] works through relations. Thus, in order to make 165 | variation attributes feature work, it will perform an extra query to retrieve the default variation model, which may 166 | produce performance impact in case you are working with several models. 167 | In order to reduce number of queries you may use `with()` on the default variation relation: 168 | 169 | ```php 170 | $items = Item::find()->with('defaultTranslation')->all(); // only 2 queries will be performed 171 | foreach ($items as $item) { 172 | echo $item->title . '
'; 173 | } 174 | ``` 175 | 176 | You may as well use main variations relation in `with()`. In this case default variation will be fetched from it without 177 | extra query: 178 | 179 | ```php 180 | $items = Item::find()->with('translations')->all(); // only 2 queries will be performed 181 | foreach ($items as $item) { 182 | echo $item->title . '
'; // no extra query 183 | var_dump($item->defaultTranslation); // no extra query, `defaultTranslation` is populated from `translations` 184 | } 185 | ``` 186 | 187 | If you are using relational database you can also use [[\yii\db\ActiveQuery::joinWith()]]: 188 | 189 | ```php 190 | $items = Item::find()->joinWith('defaultTranslation')->all(); 191 | ``` 192 | 193 | You may apply 'with' for the variation relation as default scope for the main ActiveRecord query: 194 | 195 | ```php 196 | class Item extends ActiveRecord 197 | { 198 | // ... 199 | 200 | public static function find() 201 | { 202 | return parent::find()->with('defaultTranslation'); 203 | } 204 | } 205 | ``` 206 | 207 | 208 | ## Access particular variation 209 | 210 | You can always access default variation model via `getDefaultVariationModel()` method: 211 | 212 | ```php 213 | $item = Item::findOne(1); 214 | $variationModel = $item->getDefaultVariationModel(); // get default variation instance 215 | echo $item->defaultVariationModel->title; // default variation is also available as virtual property 216 | ``` 217 | 218 | However, in some cases there is a need of accessing particular variation, but not default one. 219 | This can be done via `getVariationModel()` method: 220 | 221 | ```php 222 | $item = Item::findOne(1); 223 | $frenchTranslation = $item->getVariationModel('fr'); 224 | $russianTranslation = $item->getVariationModel('ru'); 225 | ``` 226 | 227 | > Note: method `getVariationModel()` will load [[\yii2tech\ar\variation\VariationBehavior::variationsRelation]] relation 228 | fully, which may reduce performance. You should always prefer usage of [[getDefaultVariationModel()]] method if possible. 229 | You may also use eager loading for `variationsRelation` with extra condition filtering the results in order to save 230 | performance. 231 | 232 | 233 | ## Creating variation setup web interface 234 | 235 | Usage of [[\yii2tech\ar\variation\VariationBehavior]] simplifies management of variations and creating a web interface 236 | for their setup. 237 | 238 | The web controller for variation management may look like following: 239 | 240 | ```php 241 | use yii\base\Model; 242 | use yii\web\Controller; 243 | use Yii; 244 | 245 | class ItemController extends Controller 246 | { 247 | public function actionCreate() 248 | { 249 | $model = new Item(); 250 | 251 | $post = Yii::$app->request->post(); 252 | if ($model->load($post) && Model::loadMultiple($model->getVariationModels(), $post) && $model->save()) { 253 | return $this->redirect(['index']); 254 | } 255 | 256 | return $this->render('create', [ 257 | 'model' => $model, 258 | ]); 259 | } 260 | } 261 | ``` 262 | 263 | Note that variation models should be populated with data from request manually, but they will be validated and saved 264 | automatically - you don't need to do this manually. Automatic processing of variation models will be performed only, if 265 | they have been fetched before owner validation or saving triggered. Thus it will not affect pure owner validation or saving. 266 | 267 | The form view file can be following: 268 | 269 | ```php 270 | 277 | 278 | 279 | field($model, 'name'); ?> 280 | field($model, 'price'); ?> 281 | 282 | getVariationModels() as $index => $variationModel): ?> 283 | field($variationModel, "[{$index}]title")->label($variationModel->getAttributeLabel('title') . ' (' . $variationModel->languageId . ')'); ?> 284 | field($variationModel, "[{$index}]description")->label($variationModel->getAttributeLabel('description') . ' (' . $variationModel->languageId . ')'); ?> 285 | 286 | 287 |
288 | 'btn btn-primary']) ?> 289 |
290 | 291 | 292 | ``` 293 | 294 | 295 | ## Saving default variation 296 | 297 | It is not necessary to process all possible variations at once - you can operate only single variation model, validating 298 | and saving it. For example: you can provide a web interface where user can setup only the translation for the current language. 299 | Doing so it is better to setup [[\yii2tech\ar\variation\VariationBehavior::$variationAttributeDefaultValueMap]] value, allowing 300 | magic access to the variation attributes. 301 | Being fetched default variation model will be validated and saved along with the main model: 302 | 303 | ```php 304 | $item = Item::findOne($id); 305 | 306 | $item->title = ''; // setup of `$item->defaultTranslation->title` 307 | var_dump($item->validate()); // outputs: `false` 308 | 309 | $item->title = 'new title'; 310 | $item->save(); // invokes `$item->defaultTranslation->save()` 311 | ``` 312 | 313 | In case attribute in mentioned at [[\yii2tech\ar\variation\VariationBehavior::$variationAttributeDefaultValueMap]], it 314 | will be available for setting as well, even if default variation model does not exists: in such case it will be 315 | created automatically. For example: 316 | 317 | ```php 318 | $item = new Item(); 319 | $item->name = 'new name'; 320 | $item->title = 'translation title'; // setup of `$item->defaultTranslation` attribute, creating default variation model 321 | $item->description = 'translation description'; 322 | $item->save(); // saving both main model and default variation model 323 | ``` 324 | 325 | Marking variation attributes at the main model as 'safe' you can create a web interface, which sets up them in a simple way. 326 | Model code should look like following: 327 | 328 | ```php 329 | class Item extends ActiveRecord 330 | { 331 | public function behaviors() 332 | { 333 | return [ 334 | 'translations' => [ 335 | 'class' => VariationBehavior::className(), 336 | // ... 337 | 'variationAttributeDefaultValueMap' => [ 338 | 'title' => 'name', 339 | 'description' => null, 340 | ], 341 | ], 342 | ]; 343 | } 344 | 345 | public function rules() 346 | { 347 | return [ 348 | // ... 349 | [['title', 'description'], 'safe'] // allow 'title' and 'description' to be populated via main model 350 | ]; 351 | } 352 | 353 | // ... 354 | } 355 | ``` 356 | 357 | Inside the view you can use variation attributes at the main model directly: 358 | 359 | ```php 360 | 367 | 368 | 369 | field($model, 'name'); ?> 370 | field($model, 'price'); ?> 371 | 372 | field($model, "title"); ?> 373 | field($model, "description")->textarea(); ?> 374 | 375 |
376 | 'btn btn-primary']) ?> 377 |
378 | 379 | 380 | ``` 381 | 382 | Then the controller code will be simple: 383 | 384 | ```php 385 | use yii\web\Controller; 386 | use Yii; 387 | 388 | class ItemController extends Controller 389 | { 390 | public function actionCreate() 391 | { 392 | $model = new Item(); 393 | 394 | if ($model->load(Yii::$app->request->post()) && $model->save()) { 395 | // variation attributes are populated automatically 396 | // and variation model saved 397 | return $this->redirect(['index']); 398 | } 399 | 400 | return $this->render('create', [ 401 | 'model' => $model, 402 | ]); 403 | } 404 | } 405 | ``` 406 | 407 | 408 | ## Additional variation conditions 409 | 410 | There are case, when variation options or variation entities have extra filtering conditions or attributes. 411 | For example: assume we have a database of the developers with their payment rates, which varies per particular 412 | work type. Work types are grouped by categories: 'front-end', 'back-end', 'database' etc. And payment rates should 413 | be set for regular working time and for over-timing separately. 414 | The DDL for such use case can be following: 415 | 416 | ```sql 417 | CREATE TABLE `Developer` 418 | ( 419 | `id` integer NOT NULL AUTO_INCREMENT, 420 | `name` varchar(64) NOT NULL, 421 | PRIMARY KEY (`id`) 422 | ) ENGINE InnoDB; 423 | 424 | CREATE TABLE `WorkTypeGroup` 425 | ( 426 | `id` integer NOT NULL AUTO_INCREMENT, 427 | `name` varchar(64) NOT NULL, 428 | PRIMARY KEY (`id`) 429 | ) ENGINE InnoDB; 430 | 431 | CREATE TABLE `WorkType` 432 | ( 433 | `id` integer NOT NULL AUTO_INCREMENT, 434 | `name` varchar(64) NOT NULL, 435 | `groupId` integer NOT NULL, 436 | PRIMARY KEY (`id`) 437 | FOREIGN KEY (`groupId`) REFERENCES `WorkTypeGroup` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 438 | ) ENGINE InnoDB; 439 | 440 | CREATE TABLE `DeveloperPaymentRate` 441 | ( 442 | `developerId` integer NOT NULL, 443 | `workTypeId` varchar(5) NOT NULL, 444 | `paymentRate` integer NOT NULL, 445 | `isOvertime` integer(1) NOT NULL, 446 | PRIMARY KEY (`developerId`, `workTypeId`) 447 | FOREIGN KEY (`developerId`) REFERENCES `Developer` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 448 | FOREIGN KEY (`workTypeId`) REFERENCES `WorkType` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 449 | ) ENGINE InnoDB; 450 | ``` 451 | 452 | In this case you may want to setup 'front-end' and 'back-end' separately (using different web interface or something). 453 | You can apply an extra filtering condition for the 'option' Active Record query using [[\yii2tech\ar\variation\VariationBehavior::optionQueryFilter]]: 454 | 455 | ```php 456 | class Developer extends ActiveRecord 457 | { 458 | public function behaviors() 459 | { 460 | return [ 461 | 'frontEndPaymentRates' => [ 462 | 'class' => VariationBehavior::className(), 463 | 'variationsRelation' => 'paymentRates', 464 | 'variationOptionReferenceAttribute' => 'workTypeId', 465 | 'optionModelClass' => WorkType::className(), 466 | 'optionQueryFilter' => [ 467 | 'groupId' => WorkType::GROUP_FRONT_END // add 'where' condition to the `WorkType` query 468 | ], 469 | ], 470 | 'backEndPaymentRates' => [ 471 | 'class' => VariationBehavior::className(), 472 | 'variationsRelation' => 'paymentRates', 473 | 'variationOptionReferenceAttribute' => 'workTypeId', 474 | 'optionModelClass' => WorkType::className(), 475 | // you can use a PHP callable as filter as well: 476 | 'optionQueryFilter' => function ($query) { 477 | $query->andWhere(['groupId' => WorkType::GROUP_BACK_END]); 478 | } 479 | ], 480 | ]; 481 | } 482 | // ... 483 | } 484 | ``` 485 | 486 | In this case you'll have to access `getVariationModels()` from the behavior instance rather then the owner directly: 487 | 488 | ```php 489 | $developer = new Developer(); 490 | $developer->getBehavior('frontEndPaymentRates')->getVariationModels(); // get 'front-end' payment rates 491 | $developer->getBehavior('backEndPaymentRates')->getVariationModels(); // get 'back-end' payment rates 492 | ``` 493 | 494 | You may as well separate variations using 'overtime' conditions: setup regular time and overtime payment rates in 495 | different process. For such purpose you'll have to declare 2 separated relations for 'regular time' and 'overtime' 496 | payment rates: 497 | 498 | ```php 499 | class Developer extends ActiveRecord 500 | { 501 | public function behaviors() 502 | { 503 | return [ 504 | 'regularPaymentRates' => [ 505 | 'class' => VariationBehavior::className(), 506 | 'variationsRelation' => 'regularPaymentRates', 507 | 'variationOptionReferenceAttribute' => 'workTypeId', 508 | 'optionModelClass' => WorkType::className(), 509 | ], 510 | 'overtimePaymentRates' => [ 511 | 'class' => VariationBehavior::className(), 512 | 'variationsRelation' => 'overtimePaymentRates', 513 | 'variationOptionReferenceAttribute' => 'workTypeId', 514 | 'optionModelClass' => WorkType::className(), 515 | ], 516 | ]; 517 | } 518 | 519 | public function getPaymentRates() 520 | { 521 | return $this->hasMany(PaymentRates::className(), ['developerId' => 'id']); // basic 'payment rates' relation 522 | } 523 | 524 | public function getRegularPaymentRates() 525 | { 526 | return $this->getPaymentRates()->andWhere(['isOvertime' => false]); // regular payment rates 527 | } 528 | 529 | public function getOvertimePaymentRates() 530 | { 531 | return $this->getPaymentRates()->andWhere(['isOvertime' => true]); // overtime payment rates 532 | } 533 | 534 | // ... 535 | } 536 | ``` 537 | 538 | In this case variation will be loaded only for particular rate type and saved with corresponding value of the `isOvertime` 539 | flag attribute. However, automatic detection of the extra variation model attributes will work only for 'hash' query conditions. 540 | If you have a complex variation option filtering logic, you'll need to setup [[\yii2tech\ar\variation\VariationBehavior::variationModelDefaultAttributes]] 541 | manually. 542 | 543 | In the example above you may not want to save empty variation data in database: if particular developer have no particular 544 | 'front-end' skill like 'AngularJS' he have no payment rate for it and thus there is no reason to save an empty 'PaymentRate' 545 | record for it. 546 | You may use [[\yii2tech\ar\variation\VariationBehavior::variationSaveFilter]] to determine which variation record should 547 | be saved or not. For example: 548 | 549 | ```php 550 | class Developer extends ActiveRecord 551 | { 552 | public function behaviors() 553 | { 554 | return [ 555 | 'paymentRates' => [ 556 | 'class' => VariationBehavior::className(), 557 | 'variationsRelation' => 'regularPaymentRates', 558 | 'variationOptionReferenceAttribute' => 'workTypeId', 559 | 'optionModelClass' => WorkType::className(), 560 | 'variationSaveFilter' => function ($model) { 561 | return !empty($model->paymentRate); 562 | }, 563 | ], 564 | ]; 565 | } 566 | 567 | // ... 568 | } 569 | ``` 570 | -------------------------------------------------------------------------------- /src/VariationBehavior.php: -------------------------------------------------------------------------------- 1 | [ 29 | * 'class' => VariationBehavior::className(), 30 | * 'variationsRelation' => 'translations', 31 | * 'defaultVariationRelation' => 'defaultTranslation', 32 | * 'variationOptionReferenceAttribute' => 'languageId', 33 | * 'optionModelClass' => Language::className(), 34 | * 'defaultVariationOptionReference' => 1, 35 | * 'variationAttributeDefaultValueMap' => [ 36 | * 'title' => 'name' 37 | * ], 38 | * ], 39 | * ]; 40 | * } 41 | * 42 | * public function getTranslations() 43 | * { 44 | * return $this->hasMany(ItemTranslation::className(), ['itemId' => 'id']); 45 | * } 46 | * 47 | * public function getDefaultTranslation() 48 | * { 49 | * return $this->hasDefaultVariationRelation(); 50 | * } 51 | * } 52 | * ``` 53 | * 54 | * @property BaseActiveRecord $owner 55 | * @property BaseActiveRecord[] $variationModels list of all possible variation models. 56 | * 57 | * @author Paul Klimov 58 | * @since 1.0 59 | */ 60 | class VariationBehavior extends Behavior 61 | { 62 | /** 63 | * @var string name of relation, which corresponds all variations. 64 | */ 65 | public $variationsRelation = 'variations'; 66 | /** 67 | * @var string name of relation, which corresponds default variation. 68 | */ 69 | public $defaultVariationRelation; 70 | /** 71 | * @var array map, which marks the a source for the default value for the variation model attributes. 72 | * Format: `variationModelAttributeName => valueSource`. 73 | * Each map value can be: 74 | * 75 | * - null, returns `null` as variation attribute value 76 | * - string, returns value of the specified attribute from parent model as variation attribute value 77 | * - callable, returns result of callback invocation as variation attribute value 78 | * 79 | * For example: 80 | * 81 | * ```php 82 | * [ 83 | * 'title' => 'name', 84 | * 'content' => 'defaultContent', 85 | * 'brief' => null, 86 | * 'summary' => function() {return Yii::t('app', 'Not available')}, 87 | * ]; 88 | * ``` 89 | * 90 | * Default value map will be used if default variation model not exists, or 91 | * its requested attribute value is empty. 92 | * 93 | * In case attribute in mentioned at this map it will be available for setting as well, even if 94 | * default variation model does not exists: in such case it will be created automatically. 95 | */ 96 | public $variationAttributeDefaultValueMap = []; 97 | /** 98 | * @var string name of attribute, which store option primary key reference. 99 | */ 100 | public $variationOptionReferenceAttribute = 'optionId'; 101 | /** 102 | * @var string name of ActiveRecord class, which determines possible variation options. 103 | */ 104 | public $optionModelClass; 105 | /** 106 | * @var mixed|callable additional filter to be applied to the DB query used to find {@see optionModelClass} instances. 107 | * This could be a callable with the signature `function (\yii\db\QueryInterface $query)`, or a direct filter condition 108 | * for the {@see \yii\db\QueryInterface::where()} method. 109 | */ 110 | public $optionQueryFilter; 111 | /** 112 | * @var mixed|callable callback for the function, which should return default 113 | * variation option primary key id. 114 | */ 115 | public $defaultVariationOptionReference; 116 | /** 117 | * @var array|callable|null list of the attributes, which should be applied for newly created variation model. 118 | * This could be a callable with the signature `function (\yii\db\BaseActiveRecord $model)` or array of attribute values. 119 | * If not set attributes will be automatically determined from the {@see variationsRelation} relation `where` condition. 120 | */ 121 | public $variationModelDefaultAttributes; 122 | /** 123 | * @var callable|null PHP callback, which should determine whether particular variation model should be saved or not. 124 | * Callable should have a following signature: `bool function (\yii\db\BaseActiveRecord $model)`. 125 | * For example: 126 | * 127 | * ```php 128 | * function ($model) { 129 | * return !empty($model->title) || !empty($model->description); 130 | * } 131 | * ``` 132 | */ 133 | public $variationSaveFilter; 134 | 135 | /** 136 | * @var \yii\db\ActiveQueryInterface[]|null list of all possible variation models. 137 | */ 138 | private $_variationModels; 139 | /** 140 | * @var array backup of value of the records related via variation relations in format: `[relationName => models]`. 141 | * Backup is performed at the {@see beforeSave()} to bypass {@see BaseActiveRecord::resetDependentRelations()} later. 142 | * @since 1.0.4 143 | */ 144 | private $_variationRelationsBackup = []; 145 | 146 | 147 | /** 148 | * Declares has-one relation {@see defaultVariationRelation} from {@see variationsRelation} relation. 149 | * @return \yii\db\ActiveQueryInterface the relational query object. 150 | */ 151 | public function hasDefaultVariationRelation() 152 | { 153 | $variationsRelation = $this->owner->getRelation($this->variationsRelation); 154 | $variationsRelation->multiple = false; 155 | $condition = [$this->variationOptionReferenceAttribute => $this->getDefaultVariationOptionReference()]; 156 | 157 | $condition = $this->normalizeQueryFilterCondition($variationsRelation, $condition); 158 | 159 | if (method_exists($variationsRelation, 'andOnCondition')) { 160 | try { 161 | $variationsRelation->andOnCondition($condition); 162 | } catch (NotSupportedException $exception) { 163 | // particular ActiveQuery may extend `yii\db\ActiveQuery` but do not support `on` conditions 164 | $variationsRelation->andWhere($condition); 165 | } 166 | } else { 167 | $variationsRelation->andWhere($condition); 168 | } 169 | 170 | return $variationsRelation; 171 | } 172 | 173 | /** 174 | * @return mixed default variation option reference value. 175 | * @throws InvalidConfigException on empty {@see defaultVariationOptionReference} 176 | */ 177 | public function getDefaultVariationOptionReference() 178 | { 179 | if ($this->defaultVariationOptionReference === null) { 180 | throw new InvalidConfigException('"' . get_class($this) . '::defaultVariationOptionReference" must be set.'); 181 | } 182 | if (is_scalar($this->defaultVariationOptionReference)) { 183 | return $this->defaultVariationOptionReference; 184 | } 185 | return call_user_func($this->defaultVariationOptionReference, $this->owner); 186 | } 187 | 188 | /** 189 | * Returns default variation model, matching {@see defaultVariationRelation} relation. 190 | * @param bool $autoCreate whether to automatically create model - if it does not exist. 191 | * @return BaseActiveRecord|null default variation model, `null` - if not found. 192 | */ 193 | public function getDefaultVariationModel($autoCreate = false) 194 | { 195 | $model = $this->findDefaultVariationModel(); 196 | if ($autoCreate && !is_object($model)) { 197 | return $this->createDefaultVariationModel(); 198 | } 199 | return $model; 200 | } 201 | 202 | /** 203 | * Finds existing default variation model. 204 | * @return BaseActiveRecord|null default variation model, `null` - if not found. 205 | */ 206 | private function findDefaultVariationModel() 207 | { 208 | if ($this->defaultVariationRelation !== null) { 209 | if ($this->owner->isRelationPopulated($this->defaultVariationRelation) || !$this->owner->isRelationPopulated($this->variationsRelation)) { 210 | return $this->owner->{$this->defaultVariationRelation}; 211 | } else { 212 | $defaultOptionReference = $this->getDefaultVariationOptionReference(); 213 | foreach ($this->owner->{$this->variationsRelation} as $model) { 214 | if ($model->{$this->variationOptionReferenceAttribute} == $defaultOptionReference) { 215 | $this->owner->populateRelation($this->defaultVariationRelation, $model); 216 | return $model; 217 | } 218 | } 219 | } 220 | } 221 | return null; 222 | } 223 | 224 | /** 225 | * Creates new instance for default variation model, populating {@see defaultVariationRelation} relation. 226 | * @return BaseActiveRecord|null default variation model, `null` if creation is impossible. 227 | * @since 1.0.2 228 | */ 229 | private function createDefaultVariationModel() 230 | { 231 | if ($this->defaultVariationRelation === null) { 232 | return null; 233 | } 234 | 235 | $relation = $this->owner->getRelation($this->defaultVariationRelation); 236 | /* @var $modelClass BaseActiveRecord */ 237 | $modelClass = $relation->modelClass; 238 | $model = new $modelClass(); 239 | 240 | $defaultOptionReference = $this->getDefaultVariationOptionReference(); 241 | $model->{$this->variationOptionReferenceAttribute} = $defaultOptionReference; 242 | 243 | $this->owner->populateRelation($this->defaultVariationRelation, $model); 244 | return $model; 245 | } 246 | 247 | /** 248 | * Sets models related to the main one as variations. 249 | * @param BaseActiveRecord[]|null $models variation models. 250 | * @return $this self reference. 251 | */ 252 | public function setVariationModels($models) 253 | { 254 | $this->_variationModels = $models; 255 | return $this; 256 | } 257 | 258 | /** 259 | * Returns models related to the main one as variations. 260 | * This method adjusts set of related models creating missing variations. 261 | * @return BaseActiveRecord[] list of variation models. 262 | */ 263 | public function getVariationModels() 264 | { 265 | if (is_array($this->_variationModels)) { 266 | return $this->_variationModels; 267 | } 268 | 269 | $variationModels = $this->owner->{$this->variationsRelation}; 270 | 271 | $variationModels = $this->adjustVariationModels($variationModels); 272 | $this->_variationModels = $variationModels; 273 | return $variationModels; 274 | } 275 | 276 | /** 277 | * @return bool whether the variation models have been initialized or not. 278 | */ 279 | public function getIsVariationModelsInitialized() 280 | { 281 | return !empty($this->_variationModels); 282 | } 283 | 284 | /** 285 | * Returns variation model, matching given option primary key. 286 | * Note: this method will load {@see variationsRelation} relation fully. 287 | * @param mixed $optionPk option entity primary key. 288 | * @return BaseActiveRecord|null variation model. 289 | */ 290 | public function getVariationModel($optionPk) 291 | { 292 | foreach ($this->getVariationModels() as $model) { 293 | if ($model->{$this->variationOptionReferenceAttribute} == $optionPk) { 294 | return $model; 295 | } 296 | } 297 | return null; 298 | } 299 | 300 | /** 301 | * Adjusts given variation models to be adequate to the {@see optionModelClass} records. 302 | * @param BaseActiveRecord[] $initialVariationModels set of initial variation models, found by relation 303 | * @return BaseActiveRecord[] list of {@see BaseActiveRecord} 304 | */ 305 | private function adjustVariationModels(array $initialVariationModels) 306 | { 307 | $options = $this->findOptionModels(); 308 | 309 | $variationsRelation = $this->owner->getRelation($this->variationsRelation); 310 | 311 | $optionReferenceAttribute = $this->variationOptionReferenceAttribute; 312 | list($ownerReferenceAttribute) = array_keys($variationsRelation->link); 313 | 314 | /* @var $variationModels BaseActiveRecord[] */ 315 | /* @var $confirmedInitialVariationModels BaseActiveRecord[] */ 316 | $variationModels = []; 317 | $confirmedInitialVariationModels = []; 318 | foreach ($options as $option) { 319 | $matchFound = false; 320 | foreach ($initialVariationModels as $initialVariationModel) { 321 | if ($option->getPrimaryKey() == $initialVariationModel->$optionReferenceAttribute) { 322 | $variationModels[] = $initialVariationModel; 323 | $confirmedInitialVariationModels[] = $initialVariationModel; 324 | $matchFound = true; 325 | break; 326 | } 327 | } 328 | if (!$matchFound) { 329 | $variationClassName = $variationsRelation->modelClass; 330 | $variationModel = new $variationClassName(); 331 | $variationModel->$optionReferenceAttribute = $option->getPrimaryKey(); 332 | $variationModel->$ownerReferenceAttribute = $this->owner->getPrimaryKey(); 333 | $this->fillUpVariationModelDefaults($variationModel); 334 | $variationModels[] = $variationModel; 335 | } 336 | } 337 | 338 | if (count($confirmedInitialVariationModels) < count($initialVariationModels)) { 339 | foreach ($initialVariationModels as $initialVariationModel) { 340 | $matchFound = false; 341 | foreach ($confirmedInitialVariationModels as $confirmedInitialVariationModel) { 342 | if ($confirmedInitialVariationModel->getPrimaryKey() == $initialVariationModel->getPrimaryKey()) { 343 | $matchFound = true; 344 | break; 345 | } 346 | } 347 | if (!$matchFound) { 348 | $initialVariationModel->delete(); 349 | } 350 | } 351 | } 352 | 353 | return $variationModels; 354 | } 355 | 356 | /** 357 | * Fills up default attributes for the variation model. 358 | * @param BaseActiveRecord $variationModel model instance. 359 | * @throws InvalidConfigException on invalid configuration. 360 | */ 361 | private function fillUpVariationModelDefaults($variationModel) 362 | { 363 | if ($this->variationModelDefaultAttributes === null) { 364 | $variationsRelation = $this->owner->getRelation($this->variationsRelation); 365 | if (isset($variationsRelation->where)) { 366 | foreach ((array)$variationsRelation->where as $attribute => $value) { 367 | if ($variationModel->hasAttribute($attribute)) { 368 | $variationModel->{$attribute} = $value; 369 | } 370 | } 371 | } 372 | return; 373 | } 374 | 375 | if (is_callable($this->variationModelDefaultAttributes, true)) { 376 | call_user_func($this->variationModelDefaultAttributes, $variationModel); 377 | return; 378 | } 379 | if (!is_array($this->variationModelDefaultAttributes)) { 380 | throw new InvalidConfigException('"' . get_class($this) . '::variationModelDefaultAttributes" must be a valid callable or an array.'); 381 | } 382 | foreach ($this->variationModelDefaultAttributes as $attribute => $value) { 383 | $variationModel->{$attribute} = $value; 384 | } 385 | } 386 | 387 | /** 388 | * Finds available variation option models. 389 | * @return BaseActiveRecord[] option models list. 390 | */ 391 | private function findOptionModels() 392 | { 393 | /* @var $optionModelClass BaseActiveRecord */ 394 | $optionModelClass = $this->optionModelClass; 395 | $query = $optionModelClass::find(); 396 | if ($this->optionQueryFilter !== null) { 397 | if (is_callable($this->optionQueryFilter)) { 398 | call_user_func($this->optionQueryFilter, $query); 399 | } else { 400 | $query->andWhere($this->optionQueryFilter); 401 | } 402 | } 403 | return $query->all(); 404 | } 405 | 406 | /** 407 | * @param string $name variation attribute name. 408 | * @return mixed|null attribute value. 409 | * @throws InvalidConfigException on invalid attribute map. 410 | */ 411 | private function fetchVariationAttributeDefaultValue($name) 412 | { 413 | $default = $this->variationAttributeDefaultValueMap[$name]; 414 | if ($default === null) { 415 | return null; 416 | } 417 | if (!is_scalar($default)) { 418 | if (!is_callable($default)) { 419 | throw new InvalidConfigException("Default value map for '{$name}' should be a scalar or valid callback."); 420 | } 421 | return call_user_func($default, $this->owner); 422 | } 423 | return $this->owner->{$default}; 424 | } 425 | 426 | /** 427 | * Normalizes raw filter condition, adding table alias for relation database query. 428 | * @param \yii\db\ActiveQueryInterface $query active query instance. 429 | * @param array $condition raw filter condition. 430 | * @return array normalized condition. 431 | * @since 1.0.5 432 | */ 433 | private function normalizeQueryFilterCondition($query, $condition) 434 | { 435 | if (method_exists($query, 'getTablesUsedInFrom')) { 436 | $fromTables = $query->getTablesUsedInFrom(); 437 | $alias = array_keys($fromTables)[0]; 438 | 439 | foreach ($condition as $attribute => $value) { 440 | if (is_numeric($attribute) || strpos($attribute, '.') !== false) { 441 | continue; 442 | } 443 | 444 | unset($condition[$attribute]); 445 | if (strpos($attribute, '[[') === false) { 446 | $attribute = '[[' . $attribute . ']]'; 447 | } 448 | $attribute = $alias . '.' . $attribute; 449 | $condition[$attribute] = $value; 450 | } 451 | } 452 | 453 | return $condition; 454 | } 455 | 456 | // Property Access Extension: 457 | 458 | /** 459 | * PHP getter magic method. 460 | * This method is overridden so that variation attributes can be accessed like properties. 461 | * 462 | * @param string $name property name 463 | * @throws UnknownPropertyException if the property is not defined 464 | * @return mixed property value 465 | */ 466 | public function __get($name) 467 | { 468 | try { 469 | return parent::__get($name); 470 | } catch (UnknownPropertyException $exception) { 471 | if ($this->owner !== null) { 472 | $model = $this->getDefaultVariationModel(); 473 | if (is_object($model) && $model->hasAttribute($name)) { 474 | $result = $model->$name; 475 | if (empty($result) && array_key_exists($name, $this->variationAttributeDefaultValueMap)) { 476 | return $this->fetchVariationAttributeDefaultValue($name); 477 | } 478 | return $result; 479 | } elseif (array_key_exists($name, $this->variationAttributeDefaultValueMap)) { 480 | return $this->fetchVariationAttributeDefaultValue($name); 481 | } 482 | } 483 | 484 | throw $exception; 485 | } 486 | } 487 | 488 | /** 489 | * PHP setter magic method. 490 | * This method is overridden so that variation attributes can be accessed like properties. 491 | * @param string $name property name 492 | * @param mixed $value property value 493 | * @throws UnknownPropertyException if the property is not defined 494 | */ 495 | public function __set($name, $value) 496 | { 497 | try { 498 | parent::__set($name, $value); 499 | } catch (UnknownPropertyException $exception) { 500 | if ($this->owner !== null) { 501 | $model = $this->getDefaultVariationModel(true); 502 | if (is_object($model) && $model->hasAttribute($name)) { 503 | $model->$name = $value; 504 | return; 505 | } 506 | } 507 | throw $exception; 508 | } 509 | } 510 | 511 | /** 512 | * {@inheritdoc} 513 | */ 514 | public function canGetProperty($name, $checkVars = true) 515 | { 516 | if (parent::canGetProperty($name, $checkVars)) { 517 | return true; 518 | } 519 | if (array_key_exists($name, $this->variationAttributeDefaultValueMap)) { 520 | return true; 521 | } 522 | if ($this->owner == null) { 523 | return false; 524 | } 525 | $model = $this->getDefaultVariationModel(); 526 | return is_object($model) && $model->hasAttribute($name); 527 | } 528 | 529 | /** 530 | * {@inheritdoc} 531 | */ 532 | public function canSetProperty($name, $checkVars = true) 533 | { 534 | if (parent::canSetProperty($name, $checkVars)) { 535 | return true; 536 | } 537 | if ($this->owner == null) { 538 | return false; 539 | } 540 | if (array_key_exists($name, $this->variationAttributeDefaultValueMap)) { 541 | return true; 542 | } 543 | $model = $this->getDefaultVariationModel(); 544 | return is_object($model) && $model->hasAttribute($name); 545 | } 546 | 547 | // Events : 548 | 549 | /** 550 | * {@inheritdoc} 551 | */ 552 | public function events() 553 | { 554 | return [ 555 | Model::EVENT_AFTER_VALIDATE => 'afterValidate', 556 | BaseActiveRecord::EVENT_BEFORE_INSERT => 'beforeSave', 557 | BaseActiveRecord::EVENT_BEFORE_UPDATE => 'beforeSave', 558 | BaseActiveRecord::EVENT_AFTER_INSERT => 'afterSave', 559 | BaseActiveRecord::EVENT_AFTER_UPDATE => 'afterSave', 560 | ]; 561 | } 562 | 563 | /** 564 | * Handles owner 'afterValidate' event, ensuring variation models are validated as well 565 | * in case they have been fetched. 566 | * @param \yii\base\Event $event event instance. 567 | */ 568 | public function afterValidate($event) 569 | { 570 | if ($this->getIsVariationModelsInitialized()) { 571 | $variationModels = $this->getVariationModels(); 572 | } elseif ($this->defaultVariationRelation !== null && $this->owner->isRelationPopulated($this->defaultVariationRelation)) { 573 | $defaultVariationModel = $this->owner->{$this->defaultVariationRelation}; 574 | if (!is_object($defaultVariationModel)) { 575 | return; 576 | } 577 | $variationModels = [$defaultVariationModel]; 578 | } else { 579 | return; 580 | } 581 | 582 | foreach ($variationModels as $variationModel) { 583 | if (!$variationModel->validate()) { 584 | $this->owner->addErrors($variationModel->getErrors()); 585 | } 586 | } 587 | } 588 | 589 | /** 590 | * Handles owner 'beforeInsert' and 'beforeUpdate' events, preparing backup for variation relations. 591 | * @param \yii\base\ModelEvent $event event instance. 592 | * @since 1.0.4 593 | */ 594 | public function beforeSave($event) 595 | { 596 | // Backup to bypass {@see BaseActiveRecord::resetDependentRelations()} : 597 | $this->_variationRelationsBackup = []; 598 | if ($this->owner->isRelationPopulated($this->variationsRelation)) { 599 | $this->_variationRelationsBackup[$this->variationsRelation] = $this->owner->{$this->variationsRelation}; 600 | } 601 | if ($this->owner->isRelationPopulated($this->defaultVariationRelation)) { 602 | $this->_variationRelationsBackup[$this->defaultVariationRelation] = $this->owner->{$this->defaultVariationRelation}; 603 | } 604 | } 605 | 606 | /** 607 | * Handles owner 'afterInsert' and 'afterUpdate' events, ensuring variation models are saved 608 | * in case they have been fetched before. 609 | * @param \yii\base\Event $event event instance. 610 | */ 611 | public function afterSave($event) 612 | { 613 | // Apply backup : 614 | foreach ($this->_variationRelationsBackup as $relationName => $models) { 615 | if (!$this->owner->isRelationPopulated($relationName)) { 616 | $this->owner->populateRelation($relationName, $models); 617 | } 618 | } 619 | 620 | if ($this->getIsVariationModelsInitialized()) { 621 | $variationModels = $this->getVariationModels(); 622 | } elseif ($this->defaultVariationRelation !== null && $this->owner->isRelationPopulated($this->defaultVariationRelation)) { 623 | $defaultVariationModel = $this->owner->{$this->defaultVariationRelation}; 624 | if (!is_object($defaultVariationModel)) { 625 | return; 626 | } 627 | $variationModels = [$this->owner->{$this->defaultVariationRelation}]; 628 | } else { 629 | return; 630 | } 631 | 632 | $variationsRelation = $this->owner->getRelation($this->variationsRelation); 633 | list($ownerReferenceAttribute) = array_keys($variationsRelation->link); 634 | 635 | foreach ($variationModels as $variationModel) { 636 | $variationModel->{$ownerReferenceAttribute} = $this->owner->getPrimaryKey(); 637 | if ($this->variationSaveFilter === null || call_user_func($this->variationSaveFilter, $variationModel)) { 638 | $variationModel->save(false); 639 | } else { 640 | if (!$variationModel->getIsNewRecord()) { 641 | $variationModel->delete(); 642 | } 643 | } 644 | } 645 | } 646 | } --------------------------------------------------------------------------------