├── 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 | [](https://packagist.org/packages/yii2tech/ar-variation)
15 | [](https://packagist.org/packages/yii2tech/ar-variation)
16 | [](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 | = $form->field($model, 'name'); ?>
280 | = $form->field($model, 'price'); ?>
281 |
282 | getVariationModels() as $index => $variationModel): ?>
283 | = $form->field($variationModel, "[{$index}]title")->label($variationModel->getAttributeLabel('title') . ' (' . $variationModel->languageId . ')'); ?>
284 | = $form->field($variationModel, "[{$index}]description")->label($variationModel->getAttributeLabel('description') . ' (' . $variationModel->languageId . ')'); ?>
285 |
286 |
287 |
288 | = Html::submitButton('Save', ['class' => '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 | = $form->field($model, 'name'); ?>
370 | = $form->field($model, 'price'); ?>
371 |
372 | = $form->field($model, "title"); ?>
373 | = $form->field($model, "description")->textarea(); ?>
374 |
375 |
376 | = Html::submitButton('Save', ['class' => '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 | }
--------------------------------------------------------------------------------