├── 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 | [![Latest Stable Version](https://img.shields.io/packagist/v/yii2tech/ar-softdelete.svg)](https://packagist.org/packages/yii2tech/ar-softdelete) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/yii2tech/ar-softdelete.svg)](https://packagist.org/packages/yii2tech/ar-softdelete) 15 | [![Build Status](https://travis-ci.org/yii2tech/ar-softdelete.svg?branch=master)](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 | $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 | --------------------------------------------------------------------------------