SoftDeleteBehavior.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. <?php
  2. /**
  3. * Copyright 2007-2010, Cake Development Corporation (http://cakedc.com)
  4. *
  5. * Licensed under The MIT License
  6. * Redistributions of files must retain the above copyright notice.
  7. *
  8. * @copyright Copyright 2007-2010, Cake Development Corporation (http://cakedc.com)
  9. * @license MIT License (http://www.opensource.org/licenses/mit-license.php)
  10. */
  11. App::uses('ModelBehavior', 'Model');
  12. /**
  13. * Soft Delete Behavior
  14. *
  15. * Note: To make delete() return true with SoftDelete attached, you need to modify your AppModel and overwrite
  16. * delete() there:
  17. *
  18. * public function delete($id = null, $cascade = true) {
  19. * $result = parent::delete($id, $cascade);
  20. * if (!$result && $this->Behaviors->loaded('SoftDelete')) {
  21. * return $this->softDeleted;
  22. * }
  23. * return $result;
  24. * }
  25. *
  26. */
  27. class SoftDeleteBehavior extends ModelBehavior {
  28. /**
  29. * Default settings
  30. *
  31. * @var array
  32. */
  33. protected $_defaults = array(
  34. 'attribute' => 'softDeleted',
  35. 'fields' => array(
  36. 'deleted' => 'deleted_date'
  37. )
  38. );
  39. /**
  40. * Holds activity flags for models
  41. *
  42. * @var array
  43. */
  44. public $runtime = array();
  45. /**
  46. * Setup callback
  47. *
  48. * @param Model $model
  49. * @param array $settings
  50. * @return void
  51. */
  52. public function setup(Model $model, $settings = array()) {
  53. $settings = array_merge($this->_defaults, $settings);
  54. $error = 'SoftDeleteBehavior::setup(): model ' . $model->alias . ' has no field ';
  55. $fields = $this->_normalizeFields($model, $settings['fields']);
  56. foreach ($fields as $flag => $date) {
  57. if ($model->hasField($flag)) {
  58. if ($date && !$model->hasField($date)) {
  59. trigger_error($error . $date, E_USER_NOTICE);
  60. return;
  61. }
  62. continue;
  63. }
  64. trigger_error($error . $flag, E_USER_NOTICE);
  65. return;
  66. }
  67. $this->settings[$model->alias] = array_merge($settings, array('fields' => $fields));
  68. $this->softDelete($model, true);
  69. $attribute = $this->settings[$model->alias]['attribute'];
  70. $model->$attribute = false;
  71. }
  72. /**
  73. * Before find callback
  74. *
  75. * @param Model $model
  76. * @param array $query
  77. * @return array
  78. */
  79. public function beforeFind(Model $model, $query) {
  80. $runtime = $this->runtime[$model->alias];
  81. if ($runtime) {
  82. if (!is_array($query['conditions'])) {
  83. $query['conditions'] = array();
  84. }
  85. $conditions = array_filter(array_keys($query['conditions']));
  86. $fields = $this->_normalizeFields($model);
  87. foreach ($fields as $flag => $date) {
  88. if ($runtime === true || $flag === $runtime) {
  89. if (!in_array($flag, $conditions) && !in_array($model->name . '.' . $flag, $conditions)) {
  90. $query['conditions'][$model->alias . '.' . $flag] = false;
  91. }
  92. if ($flag === $runtime) {
  93. break;
  94. }
  95. }
  96. }
  97. return $query;
  98. }
  99. }
  100. /**
  101. * Before delete callback
  102. *
  103. * @param Model $model
  104. * @param array $query
  105. * @return boolean Success
  106. */
  107. public function beforeDelete(Model $model, $cascade = true) {
  108. $runtime = $this->runtime[$model->alias];
  109. if ($runtime) {
  110. if ($this->delete($model, $model->id)) {
  111. $attribute = $this->settings[$model->alias]['attribute'];
  112. $model->$attribute = true;
  113. }
  114. return false;
  115. }
  116. return true;
  117. }
  118. /**
  119. * Mark record as deleted
  120. *
  121. * @param Model $model
  122. * @param integer $id
  123. * @return boolean Success
  124. */
  125. public function delete(Model $model, $id) {
  126. $runtime = $this->runtime[$model->alias];
  127. $data = array();
  128. $fields = $this->_normalizeFields($model);
  129. foreach ($fields as $flag => $date) {
  130. if ($runtime === true || $flag === $runtime) {
  131. $data[$flag] = true;
  132. if ($date) {
  133. $data[$date] = date('Y-m-d H:i:s');
  134. }
  135. if ($flag === $runtime) {
  136. break;
  137. }
  138. }
  139. }
  140. $keys = $this->_getCounterCacheKeys($model, $id);
  141. $model->create();
  142. $model->set($model->primaryKey, $id);
  143. $options = array(
  144. 'validate' => false,
  145. 'fieldList' => array_keys($data),
  146. 'counterCache' => false
  147. );
  148. $result = (bool)$model->save(array($model->alias => $data), $options);
  149. if ($result && !empty($keys[$model->alias])) {
  150. $model->updateCounterCache($keys[$model->alias]);
  151. }
  152. return $result;
  153. }
  154. /**
  155. * Mark record as not deleted
  156. *
  157. * @param Model $model
  158. * @param integer $id
  159. * @return boolean Success
  160. */
  161. public function undelete(Model $model, $id) {
  162. $runtime = $this->runtime[$model->alias];
  163. $this->softDelete($model, false);
  164. $data = array();
  165. $fields = $this->_normalizeFields($model);
  166. foreach ($fields as $flag => $date) {
  167. if ($runtime === true || $flag === $runtime) {
  168. $data[$flag] = false;
  169. if ($date) {
  170. $data[$date] = null;
  171. }
  172. if ($flag === $runtime) {
  173. break;
  174. }
  175. }
  176. }
  177. $model->create();
  178. $model->set($model->primaryKey, $id);
  179. $options = array(
  180. 'validate' => false,
  181. 'fieldList' => array_keys($data)
  182. );
  183. $result = $model->save(array($model->alias => $data), $options);
  184. $this->softDelete($model, $runtime);
  185. if ($result) {
  186. $keys = $this->_getCounterCacheKeys($model, $id);
  187. if (!empty($keys[$model->alias])) {
  188. $model->updateCounterCache($keys[$model->alias]);
  189. }
  190. }
  191. return $result;
  192. }
  193. /**
  194. * Enable/disable SoftDelete functionality
  195. *
  196. * Usage from model:
  197. * $this->softDelete(false); deactivate this behavior for model
  198. * $this->softDelete('field_two'); enabled only for this flag field
  199. * $this->softDelete(true); enable again for all flag fields
  200. * $config = $this->softDelete(null); for obtaining current setting
  201. *
  202. * @param Model $model
  203. * @param mixed $active
  204. * @return mixed If $active is null, then current setting/null, or boolean if runtime setting for model was changed
  205. */
  206. public function softDelete(Model $model, $active) {
  207. if ($active === null) {
  208. return isset($this->runtime[$model->alias]) ? $this->runtime[$model->alias] : null;
  209. }
  210. $result = !isset($this->runtime[$model->alias]) || $this->runtime[$model->alias] !== $active;
  211. $this->runtime[$model->alias] = $active;
  212. $this->_softDeleteAssociations($model, $active);
  213. return $result;
  214. }
  215. /**
  216. * Returns number of outdated softdeleted records prepared for purge
  217. *
  218. * @param Model $model
  219. * @param mixed $expiration anything parseable by strtotime(), by default '-90 days'
  220. * @return integer
  221. */
  222. public function purgeDeletedCount(Model $model, $expiration = '-90 days') {
  223. $this->softDelete($model, false);
  224. return $model->find('count', array('conditions' => $this->_purgeDeletedConditions($model, $expiration), 'recursive' => -1));
  225. }
  226. /**
  227. * Purge table
  228. *
  229. * @param Model $model
  230. * @param mixed $expiration anything parseable by strtotime(), by default '-90 days'
  231. * @return boolean If there were some outdated records
  232. */
  233. public function purgeDeleted(Model $model, $expiration = '-90 days') {
  234. $this->softDelete($model, false);
  235. $records = $model->find('all', array(
  236. 'conditions' => $this->_purgeDeletedConditions($model, $expiration),
  237. 'fields' => array($model->primaryKey),
  238. 'recursive' => -1));
  239. if ($records) {
  240. foreach ($records as $record) {
  241. $model->delete($record[$model->alias][$model->primaryKey]);
  242. }
  243. return true;
  244. }
  245. return false;
  246. }
  247. /**
  248. * Returns conditions for finding outdated records
  249. *
  250. * @param Model $model
  251. * @param mixed $expiration anything parseable by strtotime(), by default '-90 days'
  252. * @return array
  253. */
  254. protected function _purgeDeletedConditions(Model $model, $expiration = '-90 days') {
  255. $purgeDate = date('Y-m-d H:i:s', strtotime($expiration));
  256. $conditions = array();
  257. foreach ($this->settings[$model->alias]['fields'] as $flag => $date) {
  258. $conditions[$model->alias . '.' . $flag] = true;
  259. if ($date) {
  260. $conditions[$model->alias . '.' . $date . ' <'] = $purgeDate;
  261. }
  262. }
  263. return $conditions;
  264. }
  265. /**
  266. * Return normalized field array
  267. *
  268. * @param Model $model
  269. * @param array $settings
  270. * @return array
  271. */
  272. protected function _normalizeFields(Model $model, $settings = array()) {
  273. if (empty($settings)) {
  274. $settings = $this->settings[$model->alias]['fields'];
  275. }
  276. $result = array();
  277. foreach ($settings as $flag => $date) {
  278. if (is_numeric($flag)) {
  279. $flag = $date;
  280. $date = false;
  281. }
  282. $result[$flag] = $date;
  283. }
  284. return $result;
  285. }
  286. /**
  287. * Modifies conditions of hasOne and hasMany associations.
  288. *
  289. * If multiple delete flags are configured for model, then $active=true doesn't
  290. * do anything - you have to alter conditions in association definition
  291. *
  292. * @param Model $model
  293. * @param mixed $active
  294. * @return void
  295. */
  296. protected function _softDeleteAssociations(Model $model, $active) {
  297. if (empty($model->belongsTo)) {
  298. return;
  299. }
  300. $fields = array_keys($this->_normalizeFields($model));
  301. $parentModels = array_keys($model->belongsTo);
  302. foreach ($parentModels as $parentModel) {
  303. foreach (array('hasOne', 'hasMany') as $assocType) {
  304. if (empty($model->{$parentModel}->{$assocType})) {
  305. continue;
  306. }
  307. foreach ($model->{$parentModel}->{$assocType} as $assoc => $assocConfig) {
  308. $modelName = !empty($assocConfig['className']) ? $assocConfig['className'] : $assoc;
  309. if ($model->alias !== $modelName) {
  310. continue;
  311. }
  312. $conditions = $model->{$parentModel}->{$assocType}[$assoc]['conditions'];
  313. if (!is_array($conditions)) {
  314. $model->{$parentModel}->{$assocType}[$assoc]['conditions'] = array();
  315. }
  316. $multiFields = 1 < count($fields);
  317. foreach ($fields as $field) {
  318. if ($active) {
  319. if (!isset($conditions[$field]) && !isset($conditions[$assoc . '.' . $field])) {
  320. if (is_string($active)) {
  321. if ($field === $active) {
  322. $conditions[$assoc . '.' . $field] = false;
  323. } elseif (isset($conditions[$assoc . '.' . $field])) {
  324. unset($conditions[$assoc . '.' . $field]);
  325. }
  326. } elseif (!$multiFields) {
  327. $conditions[$assoc . '.' . $field] = false;
  328. }
  329. }
  330. } elseif (isset($conditions[$assoc . '.' . $field])) {
  331. unset($conditions[$assoc . '.' . $field]);
  332. }
  333. }
  334. }
  335. }
  336. }
  337. }
  338. /**
  339. * Retrieves the foreign key values for the `belongsTo` associations
  340. * with enabled counter caching.
  341. *
  342. * The returned array has the following format:
  343. *
  344. * {{{
  345. * array(
  346. * 'ModelAlias' => array(
  347. * 'foreign_key_name' => foreign key value
  348. * )
  349. * )
  350. * }}}
  351. *
  352. * @param Model $model
  353. * @param integer $id The ID of the current record
  354. * @return array|null
  355. */
  356. protected function _getCounterCacheKeys(Model $model, $id) {
  357. $keys = null;
  358. if (!empty($model->belongsTo)) {
  359. foreach ($model->belongsTo as $assoc) {
  360. if (empty($assoc['counterCache'])) {
  361. continue;
  362. }
  363. $keys = $model->find('first', array(
  364. 'fields' => $this->_collectForeignKeys($model),
  365. 'conditions' => array($model->alias . '.' . $model->primaryKey => $id),
  366. 'recursive' => -1,
  367. 'callbacks' => false
  368. ));
  369. break;
  370. }
  371. }
  372. return $keys;
  373. }
  374. /**
  375. * Collects foreign keys from `belongsTo` associations.
  376. *
  377. * @param Model $model
  378. * @return array
  379. */
  380. protected function _collectForeignKeys(Model $model) {
  381. $result = array();
  382. foreach ($model->belongsTo as $assoc => $data) {
  383. if (isset($data['foreignKey']) && is_string($data['foreignKey'])) {
  384. $result[$assoc] = $data['foreignKey'];
  385. }
  386. }
  387. return $result;
  388. }
  389. }