├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── composer.json
└── src
├── SoftDeleteBehavior.php
└── SoftDeleteQueryBehavior.php
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | Yii 2 ActiveRecord Soft Delete extension Change Log
2 | ===================================================
3 |
4 | 1.0.4, July 30, 2019
5 | --------------------
6 |
7 | - Enh #18: Added `SoftDeleteBehavior::$useRestoreAttributeValuesAsDefaults` allowing set default soft-delete attribute values on record insertion (leandrogehlen, klimov-paul)
8 |
9 |
10 | 1.0.3, January 25, 2019
11 | -----------------------
12 |
13 | - Enh: Created `SoftDeleteQueryBehavior` allowing records filtering using "soft delete" criteria (klimov-paul)
14 |
15 |
16 | 1.0.2, May 30, 2018
17 | -------------------
18 |
19 | - Bug: Fixed `safeDelete()` method does not catch `\Throwable` (klimov-paul)
20 | - Enh #13: Methods `softDelete()` and `restore()` now supports optimistic locking (klimov-paul)
21 | - Enh #15: Methods `softDelete()` and `restore()` now supports transactional operations (tunecino, klimov-paul)
22 |
23 |
24 | 1.0.1, December 8, 2016
25 | -----------------------
26 |
27 | - Bug #4: Fixed model attributes changes at 'beforeSoftDelete' and 'beforeRestore' events are not applied (klimov-paul)
28 |
29 |
30 | 1.0.0, December 26, 2015
31 | ------------------------
32 |
33 | - Initial release.
34 |
--------------------------------------------------------------------------------
/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 Soft Delete Extension for Yii2
6 |
7 |
8 |
9 | This extension provides support for ActiveRecord soft delete.
10 |
11 | For license information check the [LICENSE](LICENSE.md)-file.
12 |
13 | [](https://packagist.org/packages/yii2tech/ar-softdelete)
14 | [](https://packagist.org/packages/yii2tech/ar-softdelete)
15 | [](https://travis-ci.org/yii2tech/ar-softdelete)
16 |
17 |
18 | Installation
19 | ------------
20 |
21 | The preferred way to install this extension is through [composer](http://getcomposer.org/download/).
22 |
23 | Either run
24 |
25 | ```
26 | php composer.phar require --prefer-dist yii2tech/ar-softdelete
27 | ```
28 |
29 | or add
30 |
31 | ```json
32 | "yii2tech/ar-softdelete": "*"
33 | ```
34 |
35 | to the require section of your composer.json.
36 |
37 |
38 | Usage
39 | -----
40 |
41 | This extension provides support for so called "soft" deletion of the ActiveRecord, which means record is not deleted
42 | from database, but marked with some flag or status, which indicates it is no longer active, instead.
43 |
44 | This extension provides [[\yii2tech\ar\softdelete\SoftDeleteBehavior]] ActiveRecord behavior for such solution
45 | support in Yii2. You may attach it to your model class in the following way:
46 |
47 | ```php
48 | [
59 | 'class' => SoftDeleteBehavior::className(),
60 | 'softDeleteAttributeValues' => [
61 | 'isDeleted' => true
62 | ],
63 | ],
64 | ];
65 | }
66 | }
67 | ```
68 |
69 | There are 2 ways of "soft" delete applying:
70 | - using `softDelete()` separated method
71 | - mutating regular `delete()` method
72 |
73 | Usage of `softDelete()` is recommended, since it allows marking the record as "deleted", while leaving regular `delete()`
74 | method intact, which allows you to perform "hard" delete if necessary. For example:
75 |
76 | ```php
77 | softDelete(); // mark record as "deleted"
82 |
83 | $item = Item::findOne($id);
84 | var_dump($item->isDeleted); // outputs "true"
85 |
86 | $item->delete(); // perform actual deleting of the record
87 | $item = Item::findOne($id);
88 | var_dump($item); // outputs "null"
89 | ```
90 |
91 | However, you may want to mutate regular ActiveRecord `delete()` method in the way it performs "soft" deleting instead
92 | of actual removing of the record. It is a common solution in such cases as applying "soft" delete functionality for
93 | existing code. For such functionality you should enable [[\yii2tech\ar\softdelete\SoftDeleteBehavior::$replaceRegularDelete]]
94 | option in behavior configuration:
95 |
96 | ```php
97 | [
108 | 'class' => SoftDeleteBehavior::className(),
109 | 'softDeleteAttributeValues' => [
110 | 'isDeleted' => true
111 | ],
112 | 'replaceRegularDelete' => true // mutate native `delete()` method
113 | ],
114 | ];
115 | }
116 | }
117 | ```
118 |
119 | Now invocation of the `delete()` method will mark record as "deleted" instead of removing it:
120 |
121 | ```php
122 | delete(); // no record removal, mark record as "deleted" instead
127 |
128 | $item = Item::findOne($id);
129 | var_dump($item->isDeleted); // outputs "true"
130 | ```
131 |
132 | **Heads up!** In case you mutate regular ActiveRecord `delete()` method, it will be unable to function with ActiveRecord
133 | transactions feature, e.g. scenarios with [[\yii\db\ActiveRecord::OP_DELETE]] or [[\yii\db\ActiveRecord::OP_ALL]]
134 | transaction levels:
135 |
136 | ```php
137 | [
148 | 'class' => SoftDeleteBehavior::className(),
149 | 'replaceRegularDelete' => true // mutate native `delete()` method
150 | ],
151 | ];
152 | }
153 |
154 | public function transactions()
155 | {
156 | return [
157 | 'some' => self::OP_DELETE,
158 | ];
159 | }
160 | }
161 |
162 | $item = Item::findOne($id);
163 | $item->setScenario('some');
164 | $item->delete(); // nothing happens!
165 | ```
166 |
167 |
168 | ## Querying "soft" deleted records
169 |
170 | Obviously, in order to find only "deleted" or only "active" records you should add corresponding condition to your search query:
171 |
172 | ```php
173 | where(['isDeleted' => false])
178 | ->all();
179 |
180 | // returns "deleted" records
181 | $deletedItems = Item::find()
182 | ->where(['isDeleted' => true])
183 | ->all();
184 | ```
185 |
186 | However, you can use [[yii2tech\ar\softdelete\SoftDeleteQueryBehavior]] to facilitate composition of such queries.
187 | The easiest way to apply this behavior is its manual attachment to the query instance at [[\yii\db\BaseActiveRecord::find()]]
188 | method. For example:
189 |
190 | ```php
191 | [
204 | 'class' => SoftDeleteBehavior::className(),
205 | // ...
206 | ],
207 | ];
208 | }
209 |
210 | /**
211 | * @return \yii\db\ActiveQuery|SoftDeleteQueryBehavior
212 | */
213 | public static function find()
214 | {
215 | $query = parent::find();
216 | $query->attachBehavior('softDelete', SoftDeleteQueryBehavior::className());
217 | return $query;
218 | }
219 | }
220 | ```
221 |
222 | In case you already define custom query class for your active record, you can move behavior attachment there.
223 | For example:
224 |
225 | ```php
226 | [
239 | 'class' => SoftDeleteBehavior::className(),
240 | // ...
241 | ],
242 | ];
243 | }
244 |
245 | /**
246 | * @return ItemQuery|SoftDeleteQueryBehavior
247 | */
248 | public static function find()
249 | {
250 | return new ItemQuery(get_called_class());
251 | }
252 | }
253 |
254 | class ItemQuery extends \yii\db\ActiveQuery
255 | {
256 | public function behaviors()
257 | {
258 | return [
259 | 'softDelete' => [
260 | 'class' => SoftDeleteQueryBehavior::className(),
261 | ],
262 | ];
263 | }
264 | }
265 | ```
266 |
267 | Once being attached [[yii2tech\ar\softdelete\SoftDeleteQueryBehavior]] provides named scopes for the records filtering using
268 | "soft" deleted criteria. For example:
269 |
270 | ```php
271 | deleted()->all();
275 |
276 | // Find all "active" records:
277 | $notDeletedItems = Item::find()->notDeleted()->all();
278 |
279 | // find all comments for not "deleted" items:
280 | $comments = Comment::find()
281 | ->innerJoinWith(['item' => function ($query) {
282 | $query->notDeleted();
283 | }])
284 | ->all();
285 | ```
286 |
287 | You may easily create listing filter for "deleted" records using `filterDeleted()` method:
288 |
289 | ```php
290 | filterDeleted(Yii::$app->request->get('filter_deleted'))
295 | ->all();
296 | ```
297 |
298 | This method applies `notDeleted()` scope on empty filter value, `deleted()` - on positive filter value, and no scope (e.g.
299 | show both "deleted" and "active" records) on negative (zero) value.
300 |
301 | > Note: [[yii2tech\ar\softdelete\SoftDeleteQueryBehavior]] has been designed to properly handle joins and avoid ambiguous
302 | column errors, however, there still can be cases, which it will be unable to handle properly. Be prepared to specify
303 | "soft deleted" conditions manually in case you are writing complex query, involving several tables with "soft delete" feature.
304 |
305 | By default [[yii2tech\ar\softdelete\SoftDeleteQueryBehavior]] composes filter criteria for its scopes using the information from
306 | [[yii2tech\ar\softdelete\SoftDeleteBehavior::$softDeleteAttributeValues]]. Thus you may need to manually configure filter conditions
307 | in case you are using sophisticated logic for "soft" deleted records marking. For example:
308 |
309 | ```php
310 | [
323 | 'class' => SoftDeleteBehavior::className(),
324 | 'softDeleteAttributeValues' => [
325 | 'statusId' => 'deleted',
326 | ],
327 | ],
328 | ];
329 | }
330 |
331 | /**
332 | * @return \yii\db\ActiveQuery|SoftDeleteQueryBehavior
333 | */
334 | public static function find()
335 | {
336 | $query = parent::find();
337 |
338 | $query->attachBehavior('softDelete', [
339 | 'class' => SoftDeleteQueryBehavior::className(),
340 | 'deletedCondition' => [
341 | 'statusId' => 'deleted',
342 | ],
343 | 'notDeletedCondition' => [
344 | 'statusId' => 'active',
345 | ],
346 | ]);
347 |
348 | return $query;
349 | }
350 | }
351 | ```
352 |
353 | > Tip: you may apply a condition, which filters "not deleted" records, to the ActiveQuery as default scope, overriding
354 | `find()` method. Also remember, that you may reset such default scope using `onCondition()` and `where()` methods
355 | with empty condition.
356 |
357 | ```php
358 | [
370 | 'class' => SoftDeleteBehavior::className(),
371 | 'softDeleteAttributeValues' => [
372 | 'isDeleted' => true
373 | ],
374 | ],
375 | ];
376 | }
377 |
378 | /**
379 | * @return \yii\db\ActiveQuery|SoftDeleteQueryBehavior
380 | */
381 | public static function find()
382 | {
383 | $query = parent::find();
384 |
385 | $query->attachBehavior('softDelete', SoftDeleteQueryBehavior::className());
386 |
387 | return $query->notDeleted();
388 | }
389 | }
390 |
391 | $notDeletedItems = Item::find()->all(); // returns only not "deleted" records
392 |
393 | $allItems = Item::find()
394 | ->onCondition([]) // resets "not deleted" scope for relational databases
395 | ->all(); // returns all records
396 |
397 | $allItems = Item::find()
398 | ->where([]) // resets "not deleted" scope for NOSQL databases
399 | ->all(); // returns all records
400 | ```
401 |
402 |
403 | ## Smart deletion
404 |
405 | Usually "soft" deleting feature is used to prevent the database history loss, ensuring data, which been in use and
406 | perhaps have a references or dependencies, is kept in the system. However sometimes actual deleting is allowed for
407 | such data as well.
408 | For example: usually user account records should not be deleted but only marked as "inactive", however if you browse
409 | through users list and found accounts, which has been registered long ago, but don't have at least single log-in in the
410 | system, these records have no value for the history and can be removed from database to save disk space.
411 |
412 | You can make "soft" deletion to be "smart" and detect, if the record can be removed from the database or only marked as "deleted".
413 | This can be done via [[\yii2tech\ar\softdelete\SoftDeleteBehavior::$allowDeleteCallback]]. For example:
414 |
415 | ```php
416 | [
427 | 'class' => SoftDeleteBehavior::className(),
428 | 'softDeleteAttributeValues' => [
429 | 'isDeleted' => true
430 | ],
431 | 'allowDeleteCallback' => function ($user) {
432 | return $user->lastLoginDate === null; // allow delete user, if he has never logged in
433 | }
434 | ],
435 | ];
436 | }
437 | }
438 |
439 | $user = User::find()->where(['lastLoginDate' => null])->limit(1)->one();
440 | $user->softDelete(); // removes the record!!!
441 |
442 | $user = User::find()->where(['not' =>['lastLoginDate' => null]])->limit(1)->one();
443 | $user->softDelete(); // marks record as "deleted"
444 | ```
445 |
446 | [[\yii2tech\ar\softdelete\SoftDeleteBehavior::$allowDeleteCallback]] logic is applied in case [[\yii2tech\ar\softdelete\SoftDeleteBehavior::$replaceRegularDelete]]
447 | is enabled as well.
448 |
449 |
450 | ## Handling foreign key constraints
451 |
452 | In case of usage of the relational database, which supports foreign keys, like MySQL, PostgreSQL etc., "soft" deletion
453 | is widely used for keeping foreign keys consistence. For example: if user performs a purchase at the online shop, information
454 | about this purchase should remain in the system for the future bookkeeping. The DDL for such data structure may look like
455 | following one:
456 |
457 | ```sql
458 | CREATE TABLE `Customer`
459 | (
460 | `id` integer NOT NULL AUTO_INCREMENT,
461 | `name` varchar(64) NOT NULL,
462 | `address` varchar(64) NOT NULL,
463 | `phone` varchar(20) NOT NULL,
464 | PRIMARY KEY (`id`)
465 | ) ENGINE InnoDB;
466 |
467 | CREATE TABLE `Purchase`
468 | (
469 | `id` integer NOT NULL AUTO_INCREMENT,
470 | `customerId` integer NOT NULL,
471 | `itemId` integer NOT NULL,
472 | `amount` integer NOT NULL,
473 | PRIMARY KEY (`id`)
474 | FOREIGN KEY (`customerId`) REFERENCES `Customer` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
475 | FOREIGN KEY (`itemId`) REFERENCES `Item` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
476 | ) ENGINE InnoDB;
477 | ```
478 |
479 | Thus, while set up a foreign key from 'purchase' to 'user', 'ON DELETE RESTRICT' mode is used. So on attempt to delete
480 | a user record, which have at least one purchase, a database error will occur. However, if user record have no external
481 | reference, it can be deleted.
482 |
483 | Usage of [[\yii2tech\ar\softdelete\SoftDeleteBehavior::$allowDeleteCallback]] for such use case is not very practical.
484 | It will require performing extra queries to determine, if external references exist or not, eliminating the benefits of
485 | the foreign keys database feature.
486 |
487 | Method [\yii2tech\ar\softdelete\SoftDeleteBehavior::safeDelete()]] attempts to invoke regular [[\yii\db\BaseActiveRecord::delete()]]
488 | method, and, if it fails with exception, falls back to [[yii2tech\ar\softdelete\SoftDeleteBehavior::softDelete()]].
489 |
490 | ```php
491 | purchases)); // outputs; "1"
496 | $customer->safeDelete(); // performs "soft" delete!
497 | var_dump($customer->isDeleted) // outputs: "true"
498 |
499 | // if there is NO foreign key reference :
500 | $customer = Customer::findOne(53);
501 | var_dump(count($customer->purchases)); // outputs; "0"
502 | $customer->safeDelete(); // performs actual delete!
503 | $customer = Customer::findOne(53);
504 | var_dump($customer); // outputs: "null"
505 | ```
506 |
507 | By default `safeDelete()` method catches [[\yii\db\IntegrityException]] exception, which means soft deleting will be
508 | performed on foreign constraint violation DB exception. You may specify another exception class here to customize fallback
509 | error level. For example: usage of [[\Throwable]] will cause soft-delete fallback on any error during regular deleting.
510 |
511 |
512 | ## Record restoration
513 |
514 | At some point you may want to "restore" records, which have been marked as "deleted" in the past.
515 | You may use `restore()` method for this:
516 |
517 | ```php
518 | softDelete(); // mark record as "deleted"
523 |
524 | $item = Item::findOne($id);
525 | $item->restore(); // restore record
526 | var_dump($item->isDeleted); // outputs "false"
527 | ```
528 |
529 | By default attribute values, which should be applied for record restoration are automatically detected from [[\yii2tech\ar\softdelete\SoftDeleteBehavior::$softDeleteAttributeValues]],
530 | however it is better you specify them explicitly via [[\yii2tech\ar\softdelete\SoftDeleteBehavior::$restoreAttributeValues]].
531 |
532 | > Tip: if you enable [[\yii2tech\ar\softdelete\SoftDeleteBehavior::$useRestoreAttributeValuesAsDefaults]], attribute values,
533 | which marks restored record, will be automatically applied at new record insertion.
534 |
535 |
536 | ## Events
537 |
538 | By default [[\yii2tech\ar\softdelete\SoftDeleteBehavior::softDelete()]] triggers [[\yii\db\BaseActiveRecord::EVENT_BEFORE_DELETE]]
539 | and [[\yii\db\BaseActiveRecord::EVENT_AFTER_DELETE]] events in the same way they are triggered at regular `delete()`.
540 |
541 | Also [[\yii2tech\ar\softdelete\SoftDeleteBehavior]] triggers several additional events in the scope of the owner ActiveRecord:
542 |
543 | - [[\yii2tech\ar\softdelete\SoftDeleteBehavior::EVENT_BEFORE_SOFT_DELETE]] - triggered before "soft" delete is made.
544 | - [[\yii2tech\ar\softdelete\SoftDeleteBehavior::EVENT_AFTER_SOFT_DELETE]] - triggered after "soft" delete is made.
545 | - [[\yii2tech\ar\softdelete\SoftDeleteBehavior::EVENT_BEFORE_RESTORE]] - triggered before record is restored from "deleted" state.
546 | - [[\yii2tech\ar\softdelete\SoftDeleteBehavior::EVENT_AFTER_RESTORE]] - triggered after record is restored from "deleted" state.
547 |
548 | You may attach the event handlers for these events to your ActiveRecord object:
549 |
550 | ```php
551 | on(SoftDeleteBehavior::EVENT_BEFORE_SOFT_DELETE, function($event) {
555 | $event->isValid = false; // prevent "soft" delete to be performed
556 | });
557 | ```
558 |
559 | You may also handle these events inside your ActiveRecord class by declaring the corresponding methods:
560 |
561 | ```php
562 | [
573 | 'class' => SoftDeleteBehavior::className(),
574 | // ...
575 | ],
576 | ];
577 | }
578 |
579 | public function beforeSoftDelete()
580 | {
581 | $this->deletedAt = time(); // log the deletion date
582 | return true;
583 | }
584 |
585 | public function beforeRestore()
586 | {
587 | return $this->deletedAt > (time() - 3600); // allow restoration only for the records, being deleted during last hour
588 | }
589 | }
590 | ```
591 |
592 |
593 | ## Transactional operations
594 |
595 | You can explicitly enclose [[\yii2tech\ar\softdelete\SoftDeleteBehavior::softDelete()]] method call in a transactional block, like following:
596 |
597 | ```php
598 | getDb()->beginTransaction();
603 | try {
604 | $item->softDelete();
605 | // ...other DB operations...
606 | $transaction->commit();
607 | } catch (\Exception $e) { // PHP < 7.0
608 | $transaction->rollBack();
609 | throw $e;
610 | } catch (\Throwable $e) { // PHP >= 7.0
611 | $transaction->rollBack();
612 | throw $e;
613 | }
614 | ```
615 |
616 | Alternatively you can use [[\yii\db\ActiveRecord::transactions()]] method to specify the list of operations, which should be performed inside the transaction block.
617 | Method [[\yii2tech\ar\softdelete\SoftDeleteBehavior::softDelete()]] responds both to [[\yii\db\ActiveRecord::OP_UPDATE]] and [[\yii\db\ActiveRecord::OP_DELETE]].
618 | In case current model scenario includes at least of those constants, soft-delete will be performed inside the transaction block.
619 |
620 | > Note: method [[\yii2tech\ar\softdelete\SoftDeleteBehavior::safeDelete()]] uses its own internal transaction logic, which may
621 | conflict with automatic transactional operations. Make sure you do not run this method in the scenario, which is affected by
622 | [[\yii\db\ActiveRecord::transactions()]].
623 |
624 |
625 | ## Optimistic locks
626 |
627 | Soft-delete supports optimistic lock in the same way as regular [[\yii\db\ActiveRecord::save()]] method.
628 | In case you have specified version attribute via [[\yii\db\ActiveRecord::optimisticLock()]], [[\yii2tech\ar\softdelete\SoftDeleteBehavior::softDelete()]]
629 | will throw [[\yii\db\StaleObjectException]] exception in case of version number outdated.
630 | For example, in case you ActiveRecord is defined as following:
631 |
632 | ```php
633 | [
647 | 'class' => SoftDeleteBehavior::className(),
648 | 'softDeleteAttributeValues' => [
649 | 'isDeleted' => true
650 | ],
651 | ],
652 | ];
653 | }
654 |
655 | /**
656 | * {@inheritdoc}
657 | */
658 | public function optimisticLock()
659 | {
660 | return 'version';
661 | }
662 | }
663 | ```
664 |
665 | You can create delete link in following way:
666 |
667 | ```php
668 |
673 | ...
674 | = Html::a('delete', ['delete', 'id' => $model->id, 'version' => $model->version], ['data-method' => 'post']) ?>
675 | ...
676 | ```
677 |
678 | Then you can catch [[\yii\db\StaleObjectException]] exception inside controller action code to resolve the conflict:
679 |
680 | ```php
681 | findModel($id);
691 | $model->version = $version;
692 |
693 | try {
694 | $model->softDelete();
695 | return $this->redirect(['index']);
696 | } catch (StaleObjectException $e) {
697 | // logic to resolve the conflict
698 | }
699 | }
700 |
701 | // ...
702 | }
703 | ```
704 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yii2tech/ar-softdelete",
3 | "description": "Provides support for ActiveRecord soft delete in Yii2",
4 | "keywords": ["yii2", "active", "record", "soft", "smart", "delete", "integrity"],
5 | "type": "yii2-extension",
6 | "license": "BSD-3-Clause",
7 | "support": {
8 | "issues": "https://github.com/yii2tech/ar-softdelete/issues",
9 | "forum": "http://www.yiiframework.com/forum/",
10 | "wiki": "https://github.com/yii2tech/ar-softdelete/wiki",
11 | "source": "https://github.com/yii2tech/ar-softdelete"
12 | },
13 | "authors": [
14 | {
15 | "name": "Paul Klimov",
16 | "email": "klimov.paul@gmail.com"
17 | }
18 | ],
19 | "require": {
20 | "yiisoft/yii2": "~2.0.13"
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\\softdelete\\": "src"}
33 | },
34 | "extra": {
35 | "branch-alias": {
36 | "dev-master": "1.0.x-dev"
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/SoftDeleteBehavior.php:
--------------------------------------------------------------------------------
1 | [
28 | * 'class' => SoftDeleteBehavior::className(),
29 | * 'softDeleteAttributeValues' => [
30 | * 'isDeleted' => true
31 | * ],
32 | * ],
33 | * ];
34 | * }
35 | * }
36 | * ```
37 | *
38 | * Basic usage example:
39 | *
40 | * ```php
41 | * $item = Item::findOne($id);
42 | * $item->softDelete(); // mark record as "deleted"
43 | *
44 | * $item = Item::findOne($id);
45 | * var_dump($item->isDeleted); // outputs "true"
46 | *
47 | * $item->restore(); // restores record from "deleted"
48 | *
49 | * $item = Item::findOne($id);
50 | * var_dump($item->isDeleted); // outputs "false"
51 | * ```
52 | *
53 | * @see SoftDeleteQueryBehavior
54 | *
55 | * @property BaseActiveRecord $owner owner ActiveRecord instance.
56 | * @property bool $replaceRegularDelete whether to perform soft delete instead of regular delete.
57 | * If enabled {@see BaseActiveRecord::delete()} will perform soft deletion instead of actual record deleting.
58 | * @property bool $useRestoreAttributeValuesAsDefaults whether to use {@see restoreAttributeValues} as defaults on record insertion.
59 | *
60 | * @author Paul Klimov
61 | * @since 1.0
62 | */
63 | class SoftDeleteBehavior extends Behavior
64 | {
65 | /**
66 | * @event ModelEvent an event that is triggered before deleting a record.
67 | * You may set {@see ModelEvent::$isValid} to be false to stop the deletion.
68 | */
69 | const EVENT_BEFORE_SOFT_DELETE = 'beforeSoftDelete';
70 | /**
71 | * @event Event an event that is triggered after a record is deleted.
72 | */
73 | const EVENT_AFTER_SOFT_DELETE = 'afterSoftDelete';
74 | /**
75 | * @event ModelEvent an event that is triggered before record is restored from "deleted" state.
76 | * You may set {@see ModelEvent::$isValid} to be false to stop the restoration.
77 | */
78 | const EVENT_BEFORE_RESTORE = 'beforeRestore';
79 | /**
80 | * @event Event an event that is triggered after a record is restored from "deleted" state.
81 | */
82 | const EVENT_AFTER_RESTORE = 'afterRestore';
83 |
84 | /**
85 | * @var array values of the owner attributes, which should be applied on soft delete, in format: [attributeName => attributeValue].
86 | * Those may raise a flag:
87 | *
88 | * ```php
89 | * ['isDeleted' => true]
90 | * ```
91 | *
92 | * or switch status:
93 | *
94 | * ```php
95 | * ['statusId' => Item::STATUS_DELETED]
96 | * ```
97 | *
98 | * Attribute value can be a callable:
99 | *
100 | * ```php
101 | * ['isDeleted' => function ($model) {return time()}]
102 | * ```
103 | */
104 | public $softDeleteAttributeValues = [
105 | 'isDeleted' => true
106 | ];
107 | /**
108 | * @var array|null values of the owner attributes, which should be applied on restoration from "deleted" state,
109 | * in format: `[attributeName => attributeValue]`. If not set value will be automatically detected from {@see softDeleteAttributeValues}.
110 | */
111 | public $restoreAttributeValues;
112 | /**
113 | * @var bool whether to invoke owner {@see BaseActiveRecord::beforeDelete()} and {@see BaseActiveRecord::afterDelete()}
114 | * while performing soft delete. This option affects only {@see softDelete()} method.
115 | */
116 | public $invokeDeleteEvents = true;
117 | /**
118 | * @var callable|null callback, which execution determines if record should be "hard" deleted instead of being marked
119 | * as deleted. Callback should match following signature: `bool function(BaseActiveRecord $model)`
120 | * For example:
121 | *
122 | * ```php
123 | * function ($user) {
124 | * return $user->lastLoginDate === null;
125 | * }
126 | * ```
127 | */
128 | public $allowDeleteCallback;
129 | /**
130 | * @var string class name of the exception, which should trigger a fallback to {@see softDelete()} method from {@see safeDelete()}.
131 | * By default {@see \yii\db\IntegrityException} is used, which means soft deleting will be performed on foreign constraint
132 | * violation DB exception.
133 | * You may specify another exception class here to customize fallback error level. For example: usage of {@see \Throwable}
134 | * will cause soft-delete fallback on any error during regular deleting.
135 | * @see safeDelete()
136 | */
137 | public $deleteFallbackException = 'yii\db\IntegrityException';
138 |
139 | /**
140 | * @var bool whether to perform soft delete instead of regular delete.
141 | * If enabled {@see BaseActiveRecord::delete()} will perform soft deletion instead of actual record deleting.
142 | */
143 | private $_replaceRegularDelete = false;
144 |
145 | /**
146 | * @var bool whether to use {@see restoreAttributeValues} as defaults on record insertion.
147 | * @since 1.0.4
148 | */
149 | private $_useRestoreAttributeValuesAsDefaults = false;
150 |
151 |
152 | /**
153 | * @return bool whether to perform soft delete instead of regular delete.
154 | */
155 | public function getReplaceRegularDelete()
156 | {
157 | return $this->_replaceRegularDelete;
158 | }
159 |
160 | /**
161 | * @param bool $replaceRegularDelete whether to perform soft delete instead of regular delete.
162 | */
163 | public function setReplaceRegularDelete($replaceRegularDelete)
164 | {
165 | $this->_replaceRegularDelete = $replaceRegularDelete;
166 |
167 | if (is_object($this->owner)) {
168 | $owner = $this->owner;
169 | $this->detach();
170 | $this->attach($owner);
171 | }
172 | }
173 |
174 | /**
175 | * @return bool whether to use {@see restoreAttributeValues} as defaults on record insertion.
176 | * @since 1.0.4
177 | */
178 | public function getUseRestoreAttributeValuesAsDefaults()
179 | {
180 | return $this->_useRestoreAttributeValuesAsDefaults;
181 | }
182 |
183 | /**
184 | * @param bool $useRestoreAttributeValuesAsDefaults whether to use {@see restoreAttributeValues} as defaults on record insertion.
185 | * @since 1.0.4
186 | */
187 | public function setUseRestoreAttributeValuesAsDefaults($useRestoreAttributeValuesAsDefaults)
188 | {
189 | $this->_useRestoreAttributeValuesAsDefaults = $useRestoreAttributeValuesAsDefaults;
190 |
191 | if (is_object($this->owner)) {
192 | $owner = $this->owner;
193 | $this->detach();
194 | $this->attach($owner);
195 | }
196 | }
197 |
198 | /**
199 | * Marks the owner as deleted.
200 | * @return int|false the number of rows marked as deleted, or false if the soft deletion is unsuccessful for some reason.
201 | * Note that it is possible the number of rows deleted is 0, even though the soft deletion execution is successful.
202 | * @throws StaleObjectException if optimistic locking is enabled and the data being updated is outdated.
203 | * @throws \Throwable in case soft delete failed in transactional mode.
204 | */
205 | public function softDelete()
206 | {
207 | if ($this->isDeleteAllowed()) {
208 | return $this->owner->delete();
209 | }
210 |
211 | $softDeleteCallback = function () {
212 | if ($this->invokeDeleteEvents && !$this->owner->beforeDelete()) {
213 | return false;
214 | }
215 |
216 | $result = $this->softDeleteInternal();
217 |
218 | if ($this->invokeDeleteEvents) {
219 | $this->owner->afterDelete();
220 | }
221 |
222 | return $result;
223 | };
224 |
225 | if (!$this->isTransactional(ActiveRecord::OP_DELETE) && !$this->isTransactional(ActiveRecord::OP_UPDATE)) {
226 | return call_user_func($softDeleteCallback);
227 | }
228 |
229 | $transaction = $this->beginTransaction();
230 | try {
231 | $result = call_user_func($softDeleteCallback);
232 | if ($result === false) {
233 | $transaction->rollBack();
234 | } else {
235 | $transaction->commit();
236 | }
237 | return $result;
238 | } catch (\Exception $exception) {
239 | // PHP < 7.0
240 | } catch (\Throwable $exception) {
241 | // PHP >= 7.0
242 | }
243 |
244 | $transaction->rollBack();
245 | throw $exception;
246 | }
247 |
248 | /**
249 | * Marks the owner as deleted.
250 | * @return int|false the number of rows marked as deleted, or false if the soft deletion is unsuccessful for some reason.
251 | * @throws StaleObjectException if optimistic locking is enabled and the data being updated is outdated.
252 | */
253 | protected function softDeleteInternal()
254 | {
255 | $result = false;
256 | if ($this->beforeSoftDelete()) {
257 | $attributes = $this->owner->getDirtyAttributes();
258 | foreach ($this->softDeleteAttributeValues as $attribute => $value) {
259 | if (!is_scalar($value) && is_callable($value)) {
260 | $value = call_user_func($value, $this->owner);
261 | }
262 | $attributes[$attribute] = $value;
263 | }
264 | $result = $this->updateAttributes($attributes);
265 | $this->afterSoftDelete();
266 | }
267 |
268 | return $result;
269 | }
270 |
271 | /**
272 | * This method is invoked before soft deleting a record.
273 | * The default implementation raises the {@see EVENT_BEFORE_SOFT_DELETE} event.
274 | * @return bool whether the record should be deleted. Defaults to true.
275 | */
276 | public function beforeSoftDelete()
277 | {
278 | if (method_exists($this->owner, 'beforeSoftDelete')) {
279 | if (!$this->owner->beforeSoftDelete()) {
280 | return false;
281 | }
282 | }
283 |
284 | $event = new ModelEvent();
285 | $this->owner->trigger(self::EVENT_BEFORE_SOFT_DELETE, $event);
286 |
287 | return $event->isValid;
288 | }
289 |
290 | /**
291 | * This method is invoked after soft deleting a record.
292 | * The default implementation raises the {@see EVENT_AFTER_SOFT_DELETE} event.
293 | * You may override this method to do postprocessing after the record is deleted.
294 | * Make sure you call the parent implementation so that the event is raised properly.
295 | */
296 | public function afterSoftDelete()
297 | {
298 | if (method_exists($this->owner, 'afterSoftDelete')) {
299 | $this->owner->afterSoftDelete();
300 | }
301 | $this->owner->trigger(self::EVENT_AFTER_SOFT_DELETE);
302 | }
303 |
304 | /**
305 | * @return bool whether owner "hard" deletion allowed or not.
306 | */
307 | protected function isDeleteAllowed()
308 | {
309 | if ($this->allowDeleteCallback === null) {
310 | return false;
311 | }
312 | return call_user_func($this->allowDeleteCallback, $this->owner);
313 | }
314 |
315 | // Restore :
316 |
317 | /**
318 | * Restores record from "deleted" state, after it has been "soft" deleted.
319 | * @return int|false the number of restored rows, or false if the restoration is unsuccessful for some reason.
320 | * @throws StaleObjectException if optimistic locking is enabled and the data being updated is outdated.
321 | * @throws \Throwable in case restore failed in transactional mode.
322 | */
323 | public function restore()
324 | {
325 | $restoreCallback = function () {
326 | $result = false;
327 | if ($this->beforeRestore()) {
328 | $result = $this->restoreInternal();
329 | $this->afterRestore();
330 | }
331 | return $result;
332 | };
333 |
334 | if (!$this->isTransactional(ActiveRecord::OP_UPDATE)) {
335 | return call_user_func($restoreCallback);
336 | }
337 |
338 | $transaction = $this->beginTransaction();
339 | try {
340 | $result = call_user_func($restoreCallback);
341 | if ($result === false) {
342 | $transaction->rollBack();
343 | } else {
344 | $transaction->commit();
345 | }
346 | return $result;
347 | } catch (\Exception $exception) {
348 | // PHP < 7.0
349 | } catch (\Throwable $exception) {
350 | // PHP >= 7.0
351 | }
352 |
353 | $transaction->rollBack();
354 | throw $exception;
355 | }
356 |
357 | /**
358 | * Performs restoration for soft-deleted record.
359 | * @return int the number of restored rows.
360 | * @throws InvalidConfigException on invalid configuration.
361 | * @throws StaleObjectException if optimistic locking is enabled and the data being updated is outdated.
362 | */
363 | protected function restoreInternal()
364 | {
365 | $restoreAttributeValues = $this->detectRestoreAttributeValues();
366 |
367 | $attributes = $this->owner->getDirtyAttributes();
368 | foreach ($restoreAttributeValues as $attribute => $value) {
369 | if (!is_scalar($value) && is_callable($value)) {
370 | $value = call_user_func($value, $this->owner);
371 | }
372 | $attributes[$attribute] = $value;
373 | }
374 |
375 | return $this->updateAttributes($attributes);
376 | }
377 |
378 | /**
379 | * This method is invoked before record is restored from "deleted" state.
380 | * The default implementation raises the {@see EVENT_BEFORE_RESTORE} event.
381 | * @return bool whether the record should be restored. Defaults to `true`.
382 | */
383 | public function beforeRestore()
384 | {
385 | if (method_exists($this->owner, 'beforeRestore')) {
386 | if (!$this->owner->beforeRestore()) {
387 | return false;
388 | }
389 | }
390 |
391 | $event = new ModelEvent();
392 | $this->owner->trigger(self::EVENT_BEFORE_RESTORE, $event);
393 |
394 | return $event->isValid;
395 | }
396 |
397 | /**
398 | * This method is invoked after record is restored from "deleted" state.
399 | * The default implementation raises the {@see EVENT_AFTER_RESTORE} event.
400 | * You may override this method to do postprocessing after the record is restored.
401 | * Make sure you call the parent implementation so that the event is raised properly.
402 | */
403 | public function afterRestore()
404 | {
405 | if (method_exists($this->owner, 'afterRestore')) {
406 | $this->owner->afterRestore();
407 | }
408 | $this->owner->trigger(self::EVENT_AFTER_RESTORE);
409 | }
410 |
411 | /**
412 | * Attempts to perform regular {@see BaseActiveRecord::delete()}, if it fails with exception, falls back to {@see softDelete()}.
413 | * If owner database supports transactions, regular deleting attempt will be enclosed in transaction with rollback
414 | * in case of failure.
415 | * @return false|int number of affected rows.
416 | * @throws \Throwable on failure.
417 | */
418 | public function safeDelete()
419 | {
420 | try {
421 | $transaction = $this->beginTransaction();
422 |
423 | $result = $this->owner->delete();
424 | if (isset($transaction)) {
425 | $transaction->commit();
426 | }
427 |
428 | return $result;
429 | } catch (\Exception $exception) {
430 | // PHP < 7.0
431 | } catch (\Throwable $exception) {
432 | // PHP >= 7.0
433 | }
434 |
435 | if (isset($transaction)) {
436 | $transaction->rollback();
437 | }
438 |
439 | $fallbackExceptionClass = $this->deleteFallbackException;
440 | if ($exception instanceof $fallbackExceptionClass) {
441 | return $this->softDeleteInternal();
442 | }
443 |
444 | throw $exception;
445 | }
446 |
447 | /**
448 | * Returns a value indicating whether the specified operation is transactional in the current owner scenario.
449 | * @return bool whether the specified operation is transactional in the current owner scenario.
450 | * @since 1.0.2
451 | */
452 | private function isTransactional($operation)
453 | {
454 | if (!$this->owner->hasMethod('isTransactional')) {
455 | return false;
456 | }
457 |
458 | return $this->owner->isTransactional($operation);
459 | }
460 |
461 | /**
462 | * Begins new database transaction if owner allows it.
463 | * @return \yii\db\Transaction|null transaction instance or `null` if not available.
464 | */
465 | private function beginTransaction()
466 | {
467 | $db = $this->owner->getDb();
468 | if ($db->hasMethod('beginTransaction')) {
469 | return $db->beginTransaction();
470 | }
471 | return null;
472 | }
473 |
474 | /**
475 | * Updates owner attributes taking {@see BaseActiveRecord::optimisticLock()} into account.
476 | * @param array $attributes the owner attributes (names or name-value pairs) to be updated
477 | * @return int the number of rows affected.
478 | * @throws StaleObjectException if optimistic locking is enabled and the data being updated is outdated.
479 | * @since 1.0.2
480 | */
481 | private function updateAttributes(array $attributes)
482 | {
483 | $owner = $this->owner;
484 |
485 | $lock = $owner->optimisticLock();
486 | if ($lock === null) {
487 | return $owner->updateAttributes($attributes);
488 | }
489 |
490 | $condition = $owner->getOldPrimaryKey(true);
491 |
492 | $attributes[$lock] = $owner->{$lock} + 1;
493 | $condition[$lock] = $owner->{$lock};
494 |
495 | $rows = $owner->updateAll($attributes, $condition);
496 | if (!$rows) {
497 | throw new StaleObjectException('The object being updated is outdated.');
498 | }
499 |
500 | foreach ($attributes as $name => $value) {
501 | $owner->{$name} = $value;
502 | $owner->setOldAttribute($name, $value);
503 | }
504 |
505 | return $rows;
506 | }
507 |
508 | /**
509 | * Detects values of the owner attributes, which should be applied on restoration from "deleted" state.
510 | * @return array values of the owner attributes in format `[attributeName => attributeValue]`
511 | * @throws InvalidConfigException if unable to detect restore attribute values.
512 | * @since 1.0.4
513 | */
514 | private function detectRestoreAttributeValues()
515 | {
516 | if ($this->restoreAttributeValues !== null) {
517 | return $this->restoreAttributeValues;
518 | }
519 |
520 | $restoreAttributeValues = [];
521 | foreach ($this->softDeleteAttributeValues as $name => $value) {
522 | if (is_bool($value)) {
523 | $restoreValue = !$value;
524 | } elseif (is_int($value)) {
525 | if ($value === 1) {
526 | $restoreValue = 0;
527 | } elseif ($value === 0) {
528 | $restoreValue = 1;
529 | } else {
530 | $restoreValue = $value + 1;
531 | }
532 | } elseif (!is_scalar($value) && is_callable($value)) {
533 | $restoreValue = null;
534 | } else {
535 | throw new InvalidConfigException('Unable to automatically determine restore attribute values, "' . get_class($this) . '::$restoreAttributeValues" should be explicitly set.');
536 | }
537 | $restoreAttributeValues[$name] = $restoreValue;
538 | }
539 |
540 | return $restoreAttributeValues;
541 | }
542 |
543 | // Events :
544 |
545 | /**
546 | * {@inheritdoc}
547 | */
548 | public function events()
549 | {
550 | $events = [];
551 |
552 | if ($this->getReplaceRegularDelete()) {
553 | $events[BaseActiveRecord::EVENT_BEFORE_DELETE] = 'beforeDelete';
554 | }
555 |
556 | if ($this->getUseRestoreAttributeValuesAsDefaults()) {
557 | $events[BaseActiveRecord::EVENT_BEFORE_INSERT] = 'beforeInsert';
558 | }
559 |
560 | return $events;
561 | }
562 |
563 | /**
564 | * Handles owner 'beforeDelete' owner event, applying soft delete and preventing actual deleting.
565 | * @param ModelEvent $event event instance.
566 | */
567 | public function beforeDelete($event)
568 | {
569 | if (!$this->isDeleteAllowed()) {
570 | $this->softDeleteInternal();
571 | $event->isValid = false;
572 | }
573 | }
574 |
575 | /**
576 | * Handles owner 'beforeInsert' owner event, applying {@see restoreAttributeValues} to the new record.
577 | * @param ModelEvent $event event instance.
578 | * @since 1.0.4
579 | */
580 | public function beforeInsert($event)
581 | {
582 | foreach ($this->detectRestoreAttributeValues() as $attribute => $value) {
583 | if (isset($this->owner->{$attribute})) {
584 | continue;
585 | }
586 |
587 | if (!is_scalar($value) && is_callable($value)) {
588 | $value = call_user_func($value, $this->owner);
589 | }
590 | $this->owner->{$attribute} = $value;
591 | }
592 | }
593 | }
--------------------------------------------------------------------------------
/src/SoftDeleteQueryBehavior.php:
--------------------------------------------------------------------------------
1 | [
33 | * 'class' => SoftDeleteBehavior::className(),
34 | * // ...
35 | * ],
36 | * ];
37 | * }
38 | *
39 | * public static function find()
40 | * {
41 | * $query = parent::find();
42 | * $query->attachBehavior('softDelete', SoftDeleteQueryBehavior::className());
43 | * return $query;
44 | * }
45 | * }
46 | * ```
47 | *
48 | * In case you already define custom query class for your active record, you can move behavior attachment there.
49 | * For example:
50 | *
51 | * ```php
52 | * use yii2tech\ar\softdelete\SoftDeleteBehavior;
53 | * use yii2tech\ar\softdelete\SoftDeleteQueryBehavior;
54 | *
55 | * class Item extend \yii\db\ActiveRecord
56 | * {
57 | * // ...
58 | * public function behaviors()
59 | * {
60 | * return [
61 | * 'softDeleteBehavior' => [
62 | * 'class' => SoftDeleteBehavior::className(),
63 | * // ...
64 | * ],
65 | * ];
66 | * }
67 | *
68 | * public static function find()
69 | * {
70 | * return new ItemQuery(get_called_class());
71 | * }
72 | * }
73 | *
74 | * class ItemQuery extends \yii\db\ActiveQuery
75 | * {
76 | * public function behaviors()
77 | * {
78 | * return [
79 | * 'softDelete' => [
80 | * 'class' => SoftDeleteQueryBehavior::className(),
81 | * ],
82 | * ];
83 | * }
84 | * }
85 | * ```
86 | *
87 | * Basic usage example:
88 | *
89 | * ```php
90 | * // Find all soft-deleted records:
91 | * $deletedItems = Item::find()->deleted()->all();
92 | *
93 | * // Find all not soft-deleted records:
94 | * $notDeletedItems = Item::find()->notDeleted()->all();
95 | *
96 | * // Filter records by soft-deleted criteria:
97 | * $filteredItems = Item::find()->filterDeleted(Yii::$app->request->get('filter_deleted'))->all();
98 | * ```
99 | *
100 | * @see SoftDeleteBehavior
101 | *
102 | * @property \yii\db\ActiveQueryInterface|\yii\db\ActiveQueryTrait $owner owner ActiveQuery instance.
103 | * @property array $deletedCondition filter condition for 'soft-deleted' records.
104 | * @property array $notDeletedCondition filter condition for not 'soft-deleted' records.
105 | *
106 | * @author Paul Klimov
107 | * @since 1.0.3
108 | */
109 | class SoftDeleteQueryBehavior extends Behavior
110 | {
111 | /**
112 | * @var array filter condition for 'soft-deleted' records.
113 | */
114 | private $_deletedCondition;
115 | /**
116 | * @var array filter condition for not 'soft-deleted' records.
117 | */
118 | private $_notDeletedCondition;
119 |
120 | /**
121 | * @return array filter condition for 'soft-deleted' records.
122 | */
123 | public function getDeletedCondition()
124 | {
125 | if ($this->_deletedCondition === null) {
126 | $this->_deletedCondition = $this->defaultDeletedCondition();
127 | }
128 |
129 | return $this->_deletedCondition;
130 | }
131 |
132 | /**
133 | * @param array $deletedCondition filter condition for 'soft-deleted' records.
134 | */
135 | public function setDeletedCondition($deletedCondition)
136 | {
137 | $this->_deletedCondition = $deletedCondition;
138 | }
139 |
140 | /**
141 | * @return array filter condition for not 'soft-deleted' records.
142 | */
143 | public function getNotDeletedCondition()
144 | {
145 | if ($this->_notDeletedCondition === null) {
146 | $this->_notDeletedCondition = $this->defaultNotDeletedCondition();
147 | }
148 |
149 | return $this->_notDeletedCondition;
150 | }
151 |
152 | /**
153 | * @param array $notDeletedCondition filter condition for not 'soft-deleted' records.
154 | */
155 | public function setNotDeletedCondition($notDeletedCondition)
156 | {
157 | $this->_notDeletedCondition = $notDeletedCondition;
158 | }
159 |
160 | /**
161 | * Filters query to return only 'soft-deleted' records.
162 | * @return \yii\db\ActiveQueryInterface|static query instance.
163 | */
164 | public function deleted()
165 | {
166 | return $this->addFilterCondition($this->getDeletedCondition());
167 | }
168 |
169 | /**
170 | * Filters query to return only not 'soft-deleted' records.
171 | * @return \yii\db\ActiveQueryInterface|static query instance.
172 | */
173 | public function notDeleted()
174 | {
175 | return $this->addFilterCondition($this->getNotDeletedCondition());
176 | }
177 |
178 | /**
179 | * Applies `deleted()` or `notDeleted()` scope to the query regardless to passed filter value.
180 | * If an empty value is passed - only not deleted records will be queried.
181 | * If value matching non empty int passed - only deleted records will be queried.
182 | * If non empty value matching int zero passed (e.g. `0`, `'0'`, `'all'`, `false`) - all records will be queried.
183 | * @param mixed $deleted filter value.
184 | * @return \yii\db\ActiveQueryInterface|static
185 | */
186 | public function filterDeleted($deleted)
187 | {
188 | if ($deleted === '' || $deleted === null || $deleted === []) {
189 | return $this->notDeleted();
190 | }
191 |
192 | if ((int) $deleted) {
193 | return $this->deleted();
194 | }
195 |
196 | return $this->owner;
197 | }
198 |
199 | /**
200 | * Adds given filter condition to the owner query.
201 | * @param array $condition filter condition.
202 | * @return \yii\db\ActiveQueryInterface|static owner query instance.
203 | */
204 | protected function addFilterCondition($condition)
205 | {
206 | $condition = $this->normalizeFilterCondition($condition);
207 |
208 | if (method_exists($this->owner, 'andOnCondition')) {
209 | return $this->owner->andOnCondition($condition);
210 | }
211 |
212 | return $this->owner->andWhere($condition);
213 | }
214 |
215 | /**
216 | * Generates default filter condition for 'deleted' records.
217 | * @see deletedCondition
218 | * @return array filter condition.
219 | */
220 | protected function defaultDeletedCondition()
221 | {
222 | $modelInstance = $this->getModelInstance();
223 |
224 | $condition = [];
225 | foreach ($modelInstance->softDeleteAttributeValues as $attribute => $value) {
226 | if (!is_scalar($value) && is_callable($value)) {
227 | $value = call_user_func($value, $modelInstance);
228 | }
229 | $condition[$attribute] = $value;
230 | }
231 |
232 | return $condition;
233 | }
234 |
235 | /**
236 | * Generates default filter condition for not 'deleted' records.
237 | * @see notDeletedCondition
238 | * @return array filter condition.
239 | * @throws InvalidConfigException on invalid configuration.
240 | */
241 | protected function defaultNotDeletedCondition()
242 | {
243 | $modelInstance = $this->getModelInstance();
244 |
245 | $condition = [];
246 |
247 | if ($modelInstance->restoreAttributeValues === null) {
248 | foreach ($modelInstance->softDeleteAttributeValues as $attribute => $value) {
249 | if (is_bool($value)) {
250 | $restoreValue = !$value;
251 | } elseif (is_int($value)) {
252 | if ($value === 1) {
253 | $restoreValue = 0;
254 | } elseif ($value === 0) {
255 | $restoreValue = 1;
256 | } else {
257 | $restoreValue = $value + 1;
258 | }
259 | } elseif (!is_scalar($value) && is_callable($value)) {
260 | $restoreValue = null;
261 | } else {
262 | throw new InvalidConfigException('Unable to automatically determine not delete condition, "' . get_class($this) . '::$notDeletedCondition" should be explicitly set.');
263 | }
264 |
265 | $condition[$attribute] = $restoreValue;
266 | }
267 | } else {
268 | foreach ($modelInstance->restoreAttributeValues as $attribute => $value) {
269 | if (!is_scalar($value) && is_callable($value)) {
270 | $value = call_user_func($value, $modelInstance);
271 | }
272 | $condition[$attribute] = $value;
273 | }
274 | }
275 |
276 | return $condition;
277 | }
278 |
279 | /**
280 | * Returns static instance for the model, which owner query is related to.
281 | * @return \yii\db\BaseActiveRecord|SoftDeleteBehavior
282 | */
283 | protected function getModelInstance()
284 | {
285 | return call_user_func([$this->owner->modelClass, 'instance']);
286 | }
287 |
288 | /**
289 | * Normalizes raw filter condition adding table alias for relation database query.
290 | * @param array $condition raw filter condition.
291 | * @return array normalized condition.
292 | */
293 | protected function normalizeFilterCondition($condition)
294 | {
295 | if (method_exists($this->owner, 'getTablesUsedInFrom')) {
296 | $fromTables = $this->owner->getTablesUsedInFrom();
297 | $alias = array_keys($fromTables)[0];
298 |
299 | foreach ($condition as $attribute => $value) {
300 | if (is_numeric($attribute) || strpos($attribute, '.') !== false) {
301 | continue;
302 | }
303 |
304 | unset($condition[$attribute]);
305 | if (strpos($attribute, '[[') === false) {
306 | $attribute = '[[' . $attribute . ']]';
307 | }
308 | $attribute = $alias . '.' . $attribute;
309 | $condition[$attribute] = $value;
310 | }
311 | }
312 |
313 | return $condition;
314 | }
315 | }
316 |
--------------------------------------------------------------------------------