RevisionBehavior.php 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978
  1. <?php
  2. App::uses('ModelBehavior', 'Model');
  3. /**
  4. * Revision Behavior
  5. *
  6. * Revision is a solution for adding undo and other versioning functionality
  7. * to your database models. It is set up to be easy to apply to your project,
  8. * to be easy to use and not get in the way of your other model activity.
  9. * It is also intended to work well with it's sibling, LogableBehavior.
  10. *
  11. * Feature list :
  12. *
  13. * - Easy to install
  14. * - Automagically save revision on model save
  15. * - Able to ignore model saves which only contain certain fields
  16. * - Limit number of revisions to keep, will delete oldest
  17. * - Undo functionality (or update to any revision directly)
  18. * - Revert to a datetime (and even do so cascading)
  19. * - Get a diff model array to compare two or more revisions
  20. * - Inspect any or all revisions of a model
  21. * - Work with Tree Behavior
  22. * - Includes beforeUndelete and afterUndelete callbacks
  23. * - NEW As of 1.2 behavior will revision HABTM relationships (from one way)
  24. *
  25. * Install instructions :
  26. *
  27. * - Place the newest version of RevisionBehavior in your app/models/behaviors folder
  28. * - Add the behavior to AppModel (or single models if you prefer)
  29. * - Create a shadow table for each model that you want revision for.
  30. * - Behavior will gracefully do nothing for models that has behavior, without table
  31. * - If adding to an existing project, run the initializeRevisions() method once for each model.
  32. *
  33. * About shadow tables :
  34. *
  35. * You should make these AFTER you have baked your ordinary tables as they may interfer. By default
  36. * the tables should be named "[prefix][model_table_name]_revs" If you wish to change the suffix you may
  37. * do so in the property called $revision_suffix found bellow. Also by default the behavior expects
  38. * the revision tables to be in the same dbconfig as the model, but you may change this on a per
  39. * model basis with the useDbConfig config option.
  40. *
  41. * Add the same fields as in the live table, with 3 important differences.
  42. * - The 'id' field should NOT be the primary key, nor auto increment
  43. * - Add the fields 'version_id' (int, primary key, autoincrement) and
  44. * 'version_created' (datetime)
  45. * - Skipp fields that should not be saved in shadowtable (lft,right,weight for instance)
  46. *
  47. * Configuration :
  48. *
  49. * - 'limit' : number of revisions to keep, must be at least 2
  50. * - 'ignore' : array containing the name of fields to ignore
  51. * - 'auto' : boolean when false the behavior will NOT generate revisions in afterSave
  52. * - 'useDbConfig' : string/null Name of dbConfig to use. Null to use Model's
  53. *
  54. * Limit functionality :
  55. * The shadow table will save a revision copy when it saves live data, so the newest
  56. * row in the shadow table will (in most cases) be the same as the current live data.
  57. * The exception is when the ignore field functionality is used and the live data is
  58. * updated only in those fields.
  59. *
  60. * Ignore field(s) functionality :
  61. * If you wish to be able to update certain fields without generating new revisions,
  62. * you can add those fields to the configuration ignore array. Any time the behavior's
  63. * afterSave is called with just primary key and these fields, it will NOT generate
  64. * a new revision. It WILL however save these fields together with other fields when it
  65. * does save a revision. You will probably want to set up cron or otherwise call
  66. * createRevision() to update these fields at some points.
  67. *
  68. * Auto functionality :
  69. * By default the behavior will insert itself into the Model's save process by implementing
  70. * beforeSave and afterSave. In afterSave, the behavior will save a new revision of the dataset
  71. * that is now the live data. If you do NOT want this automatic behavior, you may set the config
  72. * option 'auto' to false. Then the shadow table will remain empty unless you call createRevisions
  73. * manually.
  74. *
  75. * HABTM revision feature :
  76. * In order to do revision on HABTM relationship, add a text field to the main model's shadow table
  77. * with the same name as the association, ie if Article habtm ArticleTag as Tag, add a field 'Tag'
  78. * to articles_revs.
  79. * NB! In version 1.2 and up to current, Using HABTM revision requires that both models uses this
  80. * behavior (even if secondary model does not have a shadow table).
  81. *
  82. * 1.1.1 => 1.1.2 changelog
  83. * - revisions() got new paramter: $include_current
  84. * This now defaults to false, resulting in a change from 1.1.1. See tests
  85. *
  86. * 1.1.6 => 1.2
  87. * - includes HABTM revision control (one way)
  88. *
  89. * 1.2 => 1.2.1
  90. * - api change in revertToDate, added paramter for force delete if reverting to before earliest
  91. *
  92. * 1.2.6 => 1.2.7
  93. * - api change: removed shadow(), changed revertToDate() to only recurse into related models that
  94. * are dependent when cascade is true
  95. *
  96. * 2.0.5 => CakePHP 2.x
  97. *
  98. * @author Ronny Vindenes
  99. * @author Alexander 'alkemann' Morland
  100. * @license MIT
  101. * @modifed 27. march 2009
  102. * @version 2.0.5
  103. * @modified 2012-07-28 Mark Scherer (2.x ready)
  104. * @cakephp 2.x
  105. */
  106. class RevisionBehavior extends ModelBehavior {
  107. /**
  108. * Shadow table prefix
  109. * Only change this value if it causes table name crashes
  110. *
  111. * @var string
  112. */
  113. public $revision_suffix = '_revs';
  114. /**
  115. * Defaul setting values
  116. *
  117. * @var array
  118. */
  119. protected $_defaults = array(
  120. 'limit' => false,
  121. 'auto' => true,
  122. 'ignore' => array(),
  123. 'useDbConfig' => null,
  124. 'model' => null);
  125. /**
  126. * Old data, used to detect changes
  127. *
  128. * @var array
  129. */
  130. protected $oldData = array();
  131. /**
  132. * Configure the behavior through the Model::actsAs property
  133. *
  134. * @param object $Model
  135. * @param array $config
  136. */
  137. public function setup(Model $Model, $config = array()) {
  138. if (!empty($config)) {
  139. $this->settings[$Model->alias] = array_merge($this->_defaults, $config);
  140. } else {
  141. $this->settings[$Model->alias] = $this->_defaults;
  142. }
  143. $this->createShadowModel($Model);
  144. $Model->Behaviors->load('Containable');
  145. }
  146. /**
  147. * Manually create a revision of the current record of Model->id
  148. *
  149. * @example $this->Post->id = 5; $this->Post->createRevision();
  150. * @param object $Model
  151. * @return boolean success
  152. */
  153. public function createRevision(Model $Model) {
  154. if (!$Model->id) {
  155. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  156. return null;
  157. }
  158. if (!$Model->ShadowModel) {
  159. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  160. return false;
  161. }
  162. $habtm = array();
  163. $all_habtm = $Model->getAssociated('hasAndBelongsToMany');
  164. foreach ($all_habtm as $assocAlias) {
  165. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  166. $habtm[] = $assocAlias;
  167. }
  168. }
  169. $data = $Model->find('first', array('conditions' => array($Model->alias . '.' . $Model->primaryKey => $Model->id),
  170. 'contain' => $habtm));
  171. $Model->ShadowModel->create($data);
  172. $Model->ShadowModel->set('version_created', date('Y-m-d H:i:s'));
  173. foreach ($habtm as $assocAlias) {
  174. $foreign_keys = Set::extract($data, '/' . $assocAlias . '/' . $Model->{$assocAlias}->primaryKey);
  175. $Model->ShadowModel->set($assocAlias, implode(',', $foreign_keys));
  176. }
  177. return $Model->ShadowModel->save();
  178. }
  179. /**
  180. * Returns an array that maps to the Model, only with multiple values for fields that has been changed
  181. *
  182. * @example $this->Post->id = 4; $changes = $this->Post->diff();
  183. * @example $this->Post->id = 4; $my_changes = $this->Post->diff(null,nul, array('conditions'=>array('user_id'=>4)));
  184. * @example $this->Post->id = 4; $difference = $this->Post->diff(45,192);
  185. * @param Object $Model
  186. * @param int $from_version_id
  187. * @param int $to_version_id
  188. * @param array $options
  189. * @return array
  190. */
  191. public function diff(Model $Model, $from_version_id = null, $to_version_id = null, $options = array()) {
  192. if (!$Model->id) {
  193. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  194. return null;
  195. }
  196. if (!$Model->ShadowModel) {
  197. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  198. return null;
  199. }
  200. if (isset($options['conditions'])) {
  201. $conditions = array_merge($options['conditions'], array($Model->primaryKey => $Model->id));
  202. } else {
  203. $conditions = array($Model->primaryKey => $Model->id);
  204. }
  205. if (is_numeric($from_version_id) || is_numeric($to_version_id)) {
  206. if (is_numeric($from_version_id) && is_numeric($to_version_id)) {
  207. $conditions['version_id'] = array($from_version_id, $to_version_id);
  208. if ($Model->ShadowModel->find('count', array('conditions' => $conditions)) < 2) {
  209. return false;
  210. }
  211. } else {
  212. if (is_numeric($from_version_id)) {
  213. $conditions['version_id'] = $from_version_id;
  214. } else {
  215. $conditions['version_id'] = $to_version_id;
  216. }
  217. if ($Model->ShadowModel->find('count', array('conditions' => $conditions)) < 1) {
  218. return false;
  219. }
  220. }
  221. }
  222. $conditions = array($Model->primaryKey => $Model->id);
  223. if (is_numeric($from_version_id)) {
  224. $conditions['version_id >='] = $from_version_id;
  225. }
  226. if (is_numeric($to_version_id)) {
  227. $conditions['version_id <='] = $to_version_id;
  228. }
  229. $options['conditions'] = $conditions;
  230. $all = $this->revisions($Model, $options, true);
  231. if (sizeof($all) == 0) {
  232. return null;
  233. }
  234. $unified = array();
  235. $keys = array_keys($all[0][$Model->alias]);
  236. foreach ($keys as $field) {
  237. $all_values = Set::extract($all, '/' . $Model->alias . '/' . $field);
  238. $all_values = array_reverse(array_unique(array_reverse($all_values, true)), true);
  239. if (sizeof($all_values) == 1) {
  240. $unified[$field] = reset($all_values);
  241. } else {
  242. $unified[$field] = $all_values;
  243. }
  244. }
  245. return array($Model->alias => $unified);
  246. }
  247. /**
  248. * Will create a current revision of all rows in Model, if none exist.
  249. * Use this if you add the revision to a model that allready has data in
  250. * the DB.
  251. * If you have large tables or big/many fields, use $limit to reduce the
  252. * number of rows that is run at once.
  253. *
  254. * @example $this->Post->initializeRevisions();
  255. * @param object $Model
  256. * @param int $limit number of rows to initialize in one go
  257. * @return boolean
  258. */
  259. public function initializeRevisions(Model $Model, $limit = 100) {
  260. if (!$Model->ShadowModel) {
  261. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  262. return false;
  263. }
  264. if ($Model->ShadowModel->useTable == false) {
  265. trigger_error('RevisionBehavior: Missing shadowtable : ' . $Model->table . $this->suffix, E_USER_WARNING);
  266. return null;
  267. }
  268. if ($Model->ShadowModel->find('count') != 0) {
  269. return false;
  270. }
  271. $count = $Model->find('count');
  272. if ($limit < $count) {
  273. $remaining = $count;
  274. for ($p = 1; true; $p++) {
  275. $this->init($Model, $p, $limit);
  276. $remaining = $remaining - $limit;
  277. if ($remaining <= 0) {
  278. break;
  279. }
  280. }
  281. } else {
  282. $this->init($Model, 1, $count);
  283. }
  284. return true;
  285. }
  286. /**
  287. * saves revisions for rows matching page and limit given
  288. *
  289. * @param object $Model
  290. * @param int $page
  291. * @param int $limit
  292. */
  293. protected function init(Model $Model, $page, $limit) {
  294. $habtm = array();
  295. $all_habtm = $Model->getAssociated('hasAndBelongsToMany');
  296. foreach ($all_habtm as $assocAlias) {
  297. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  298. $habtm[] = $assocAlias;
  299. }
  300. }
  301. $all = $Model->find('all', array(
  302. 'limit' => $limit,
  303. 'page' => $page,
  304. 'contain' => $habtm));
  305. $version_created = date('Y-m-d H:i:s');
  306. foreach ($all as $data) {
  307. $Model->ShadowModel->create($data);
  308. $Model->ShadowModel->set('version_created', $version_created);
  309. $Model->ShadowModel->save();
  310. }
  311. }
  312. /**
  313. * Finds the newest revision, including the current one.
  314. * Use with caution, the live model may be different depending on the usage
  315. * of ignore fields.
  316. *
  317. * @example $this->Post->id = 6; $newest_revision = $this->Post->newest();
  318. * @param object $Model
  319. * @param array $options
  320. * @return array
  321. */
  322. public function newest(Model $Model, $options = array()) {
  323. if (!$Model->id) {
  324. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  325. return null;
  326. }
  327. if (!$Model->ShadowModel) {
  328. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  329. return null;
  330. }
  331. if (isset($options['conditions'])) {
  332. $options['conditions'] = array_merge($options['conditions'], array($Model->alias . '.' . $Model->primaryKey => $Model->id));
  333. } else {
  334. $options['conditions'] = array($Model->alias . '.' . $Model->primaryKey => $Model->id);
  335. }
  336. return $Model->ShadowModel->find('first', $options);
  337. }
  338. /**
  339. * Find the oldest revision for the current Model->id
  340. * If no limit is used on revision and revision has been enabled for the model
  341. * since start, this call will return the original first record.
  342. *
  343. * @example $this->Post->id = 2; $original = $this->Post->oldest();
  344. * @param object $Model
  345. * @param array $options
  346. * @return array
  347. */
  348. public function oldest(Model $Model, $options = array()) {
  349. if (!$Model->id) {
  350. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  351. return null;
  352. }
  353. if (!$Model->ShadowModel) {
  354. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  355. return null;
  356. }
  357. if (isset($options['conditions'])) {
  358. $options['conditions'] = array_merge($options['conditions'], array($Model->primaryKey => $Model->id));
  359. } else {
  360. $options['conditions'] = array($Model->primaryKey => $Model->id);
  361. }
  362. $options['order'] = 'version_created ASC, version_id ASC';
  363. return $Model->ShadowModel->find('first', $options);
  364. }
  365. /**
  366. * Find the second newest revisions, including the current one.
  367. *
  368. * @example $this->Post->id = 6; $undo_revision = $this->Post->previous();
  369. * @param object $Model
  370. * @param array $options
  371. * @return array
  372. */
  373. public function previous(Model $Model, $options = array()) {
  374. if (!$Model->id) {
  375. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  376. return null;
  377. }
  378. if (!$Model->ShadowModel) {
  379. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  380. return false;
  381. }
  382. $options['limit'] = 1;
  383. $options['page'] = 2;
  384. if (isset($options['conditions'])) {
  385. $options['conditions'] = array_merge($options['conditions'], array($Model->primaryKey => $Model->id));
  386. } else {
  387. $options['conditions'] = array($Model->primaryKey => $Model->id);
  388. }
  389. $revisions = $Model->ShadowModel->find('all', $options);
  390. if (!$revisions) {
  391. return null;
  392. }
  393. return $revisions[0];
  394. }
  395. /**
  396. * Revert all rows matching conditions to given date.
  397. * Model rows outside condition or not edited will not be affected. Edits since date
  398. * will be reverted and rows created since date deleted.
  399. *
  400. * @param object $Model
  401. * @param array $options 'conditions','date'
  402. * @return boolean success
  403. */
  404. public function revertAll(Model $Model, $options = array()) {
  405. if (!$Model->ShadowModel) {
  406. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  407. return false;
  408. }
  409. if (empty($options) || !isset($options['date'])) {
  410. return false;
  411. }
  412. if (!isset($options['conditions'])) {
  413. $options['conditions'] = array();
  414. }
  415. // leave model rows out side of condtions alone
  416. // leave model rows not edited since date alone
  417. $all = $Model->find('all', array('conditions' => $options['conditions'], 'fields' => $Model->primaryKey));
  418. $allIds = Set::extract($all, '/' . $Model->alias . '/' . $Model->primaryKey);
  419. $cond = $options['conditions'];
  420. $cond['version_created <'] = $options['date'];
  421. $created_before_date = $Model->ShadowModel->find('all', array(
  422. 'order' => $Model->primaryKey,
  423. 'conditions' => $cond,
  424. 'fields' => array('version_id', $Model->primaryKey)));
  425. $created_before_dateIds = Set::extract($created_before_date, '/' . $Model->alias . '/' . $Model->primaryKey);
  426. $deleteIds = array_diff($allIds, $created_before_dateIds);
  427. // delete all Model rows where there are only version_created later than date
  428. $Model->deleteAll(array($Model->alias . '.' . $Model->primaryKey => $deleteIds), false, true);
  429. unset($cond['version_created <']);
  430. $cond['version_created >='] = $options['date'];
  431. $created_after_date = $Model->ShadowModel->find('all', array(
  432. 'order' => $Model->primaryKey,
  433. 'conditions' => $cond,
  434. 'fields' => array('version_id', $Model->primaryKey)));
  435. $created_after_dateIds = Set::extract($created_after_date, '/' . $Model->alias . '/' . $Model->primaryKey);
  436. $updateIds = array_diff($created_after_dateIds, $deleteIds);
  437. $revertSuccess = true;
  438. // update model rows that have version_created earlier than date to latest before date
  439. foreach ($updateIds as $mid) {
  440. $Model->id = $mid;
  441. if (!$Model->revertToDate($options['date'])) {
  442. $revertSuccess = false;
  443. }
  444. }
  445. return $revertSuccess;
  446. }
  447. /**
  448. * Revert current Model->id to the given revision id
  449. * Will return false if version id is invalid or save fails
  450. *
  451. * @example $this->Post->id = 3; $this->Post->revertTo(12);
  452. * @param object $Model
  453. * @param int $version_id
  454. * @return boolean
  455. */
  456. public function revertTo(Model $Model, $version_id) {
  457. if (!$Model->id) {
  458. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  459. return null;
  460. }
  461. if (!$Model->ShadowModel) {
  462. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  463. return false;
  464. }
  465. $data = $Model->ShadowModel->find('first', array('conditions' => array('version_id' => $version_id)));
  466. if ($data == false) {
  467. return false;
  468. }
  469. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  470. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  471. $data[$assocAlias][$assocAlias] = explode(',', $data[$Model->alias][$assocAlias]);
  472. }
  473. }
  474. return $Model->save($data);
  475. }
  476. /**
  477. * Revert to the oldest revision after the given datedate.
  478. * Will cascade to hasOne and hasMany associeted models if $cascade is true.
  479. * Will return false if no change is made on the main model
  480. *
  481. * @example $this->Post->id = 3; $this->Post->revertToDate(date('Y-m-d H:i:s',strtotime('Yesterday')));
  482. * @example $this->Post->id = 4; $this->Post->revertToDate('2008-09-01',true);
  483. * @param object $Model
  484. * @param string $datetime
  485. * @param boolean $cascade
  486. * @param boolean $force_delete
  487. * @return boolean
  488. */
  489. public function revertToDate(Model $Model, $datetime, $cascade = false, $force_delete = false) {
  490. if (!$Model->id) {
  491. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  492. return null;
  493. }
  494. if ($cascade) {
  495. $associated = array_merge($Model->hasMany, $Model->hasOne);
  496. foreach ($associated as $assoc => $data) {
  497. // Continue with next association if no shadow model
  498. if (empty($Model->$assoc->ShadowModel)) {
  499. continue;
  500. }
  501. $ids = array();
  502. $cascade = false;
  503. /* Check if association has dependent children */
  504. $depassoc = array_merge($Model->$assoc->hasMany, $Model->$assoc->hasOne);
  505. foreach ($depassoc as $dep) {
  506. if ($dep['dependent']) {
  507. $cascade = true;
  508. }
  509. }
  510. /* Query live data for children */
  511. $children = $Model->$assoc->find('list', array('conditions' => array($data['foreignKey'] => $Model->id), 'recursive' =>
  512. -1));
  513. if (!empty($children)) {
  514. $ids = array_keys($children);
  515. }
  516. /* Query shadow table for deleted children */
  517. $revision_children = $Model->$assoc->ShadowModel->find('all', array(
  518. 'fields' => array('DISTINCT ' . $Model->primaryKey),
  519. 'conditions' => array($data['foreignKey'] => $Model->id, 'NOT' => array($Model->primaryKey => $ids)),
  520. ));
  521. if (!empty($revision_children)) {
  522. $ids = array_merge($ids, Set::extract($revision_children, '/' . $assoc . '/' . $Model->$assoc->primaryKey));
  523. }
  524. /* Revert all children */
  525. foreach ($ids as $id) {
  526. $Model->$assoc->id = $id;
  527. $Model->$assoc->revertToDate($datetime, $cascade, $force_delete);
  528. }
  529. }
  530. }
  531. if (empty($Model->ShadowModel)) {
  532. return true;
  533. }
  534. $data = $Model->ShadowModel->find('first', array('conditions' => array($Model->primaryKey => $Model->id,
  535. 'version_created <=' => $datetime), 'order' => 'version_created ASC, version_id ASC'));
  536. /* If no previous version was found and revertToDate() was called with force_delete, then delete the live data, else leave it alone */
  537. if ($data == false) {
  538. if ($force_delete) {
  539. $Model->logableAction['Revision'] = 'revertToDate(' . $datetime . ') delete';
  540. return $Model->delete($Model->id);
  541. }
  542. return true;
  543. }
  544. $habtm = array();
  545. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  546. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  547. $habtm[] = $assocAlias;
  548. }
  549. }
  550. $liveData = $Model->find('first', array('contain' => $habtm, 'conditions' => array($Model->alias . '.' . $Model->
  551. primaryKey => $Model->id)));
  552. $Model->logableAction['Revision'] = 'revertToDate(' . $datetime . ') add';
  553. if ($liveData) {
  554. $Model->logableAction['Revision'] = 'revertToDate(' . $datetime . ') edit';
  555. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  556. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  557. $ids = Set::extract($liveData, '/' . $assocAlias . '/' . $Model->$assocAlias->primaryKey);
  558. if (empty($ids) || is_string($ids)) {
  559. $liveData[$Model->alias][$assocAlias] = '';
  560. } else {
  561. $liveData[$Model->alias][$assocAlias] = implode(',', $ids);
  562. }
  563. $data[$assocAlias][$assocAlias] = explode(',', $data[$Model->alias][$assocAlias]);
  564. }
  565. unset($liveData[$assocAlias]);
  566. }
  567. $changeDetected = false;
  568. foreach ($liveData[$Model->alias] as $key => $value) {
  569. if (isset($data[$Model->alias][$key])) {
  570. $old_value = $data[$Model->alias][$key];
  571. } else {
  572. $old_value = '';
  573. }
  574. if ($value != $old_value) {
  575. $changeDetected = true;
  576. }
  577. }
  578. if (!$changeDetected) {
  579. return true;
  580. }
  581. }
  582. $auto = $this->settings[$Model->alias]['auto'];
  583. $this->settings[$Model->alias]['auto'] = false;
  584. $Model->ShadowModel->create($data, true);
  585. $Model->ShadowModel->set('version_created', date('Y-m-d H:i:s'));
  586. $Model->ShadowModel->save();
  587. $Model->version_id = $Model->ShadowModel->id;
  588. $success = $Model->save($data);
  589. $this->settings[$Model->alias]['auto'] = $auto;
  590. return $success;
  591. }
  592. /**
  593. * Returns a comeplete list of revisions for the current Model->id.
  594. * The options array may include Model::find parameters to narrow down result
  595. * Alias for shadow('all', array('conditions'=>array($Model->primaryKey => $Model->id)));
  596. *
  597. * @example $this->Post->id = 4; $history = $this->Post->revisions();
  598. * @example $this->Post->id = 4; $today = $this->Post->revisions(array('conditions'=>array('version_create >'=>'2008-12-10')));
  599. * @param object $Model
  600. * @param array $options
  601. * @param boolean $include_current If true will include last saved (live) data
  602. * @return array
  603. */
  604. public function revisions(Model $Model, $options = array(), $include_current = false) {
  605. if (!$Model->id) {
  606. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  607. return null;
  608. }
  609. if (!$Model->ShadowModel) {
  610. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  611. return null;
  612. }
  613. if (isset($options['conditions'])) {
  614. $options['conditions'] = array_merge($options['conditions'], array($Model->alias . '.' . $Model->primaryKey => $Model->id));
  615. } else {
  616. $options['conditions'] = array($Model->alias . '.' . $Model->primaryKey => $Model->id);
  617. }
  618. if ($include_current == false) {
  619. $current = $this->newest($Model, array('fields' => array($Model->alias . '.version_id', $Model->primaryKey)));
  620. $options['conditions'][$Model->alias . '.version_id !='] = $current[$Model->alias]['version_id'];
  621. }
  622. return $Model->ShadowModel->find('all', $options);
  623. }
  624. /**
  625. * Undoes an delete by saving the last revision to the Model
  626. * Will return false if this Model->id exist in the live table.
  627. * Calls Model::beforeUndelete and Model::afterUndelete
  628. *
  629. * @example $this->Post->id = 7; $this->Post->undelete();
  630. * @param object $Model
  631. * @return boolean
  632. */
  633. public function undelete(Model $Model) {
  634. if (!$Model->id) {
  635. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  636. return null;
  637. }
  638. if (!$Model->ShadowModel) {
  639. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  640. return false;
  641. }
  642. if ($Model->find('count', array('conditions' => array($Model->primaryKey => $Model->id), 'recursive' => -1)) > 0) {
  643. return false;
  644. }
  645. $data = $this->newest($Model);
  646. if (!$data) {
  647. return false;
  648. }
  649. $beforeUndeleteSuccess = true;
  650. if (method_exists($Model, 'beforeUndelete')) {
  651. $beforeUndeleteSuccess = $Model->beforeUndelete();
  652. }
  653. if (!$beforeUndeleteSuccess) {
  654. return false;
  655. }
  656. $model_id = $data[$Model->alias][$Model->primaryKey];
  657. unset($data[$Model->alias][$Model->ShadowModel->primaryKey]);
  658. $Model->create($data, true);
  659. $auto_setting = $this->settings[$Model->alias]['auto'];
  660. $this->settings[$Model->alias]['auto'] = false;
  661. $save_success = $Model->save();
  662. $this->settings[$Model->alias]['auto'] = $auto_setting;
  663. if (!$save_success) {
  664. return false;
  665. }
  666. $Model->updateAll(
  667. array($Model->primaryKey => $model_id),
  668. array($Model->primaryKey => $Model->id)
  669. );
  670. $Model->id = $model_id;
  671. $Model->createRevision();
  672. $afterUndeleteSuccess = true;
  673. if (method_exists($Model, 'afterUndelete')) {
  674. $afterUndeleteSuccess = $Model->afterUndelete();
  675. }
  676. return $afterUndeleteSuccess;
  677. }
  678. /**
  679. * Update to previous revision
  680. *
  681. * @example $this->Post->id = 2; $this->Post->undo();
  682. * @param object $Model
  683. * @return boolean
  684. */
  685. public function undo(Model $Model) {
  686. if (!$Model->id) {
  687. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  688. return null;
  689. }
  690. if (!$Model->ShadowModel) {
  691. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  692. return false;
  693. }
  694. $data = $this->previous($Model);
  695. if ($data == false) {
  696. $Model->logableAction['Revision'] = 'undo add';
  697. $Model->delete($Model->id);
  698. return false;
  699. }
  700. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  701. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  702. $data[$assocAlias][$assocAlias] = explode(',', $data[$Model->alias][$assocAlias]);
  703. }
  704. }
  705. $Model->logableAction['Revision'] = 'undo changes';
  706. return $Model->save($data);
  707. }
  708. /**
  709. * Calls create revision for all rows matching primary key list of $idlist
  710. *
  711. * @example $this->Model->updateRevisions(array(1,2,3));
  712. * @param object $Model
  713. * @param array $idlist
  714. */
  715. public function updateRevisions(Model $Model, $idlist = array()) {
  716. if (!$Model->ShadowModel) {
  717. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  718. return null;
  719. }
  720. foreach ($idlist as $id) {
  721. $Model->id = $id;
  722. $Model->createRevision();
  723. }
  724. }
  725. /**
  726. * Causes revision for habtm associated models if that model does version control
  727. * on their relationship. BeforeDelete identifies the related models that will need
  728. * to do the revision update in afterDelete. Uses
  729. *
  730. * @param unknown_type $Model
  731. */
  732. public function afterDelete(Model $Model) {
  733. if ($this->settings[$Model->alias]['auto'] === false) {
  734. return true;
  735. }
  736. if (!$Model->ShadowModel) {
  737. return true;
  738. }
  739. if (isset($this->deleteUpdates[$Model->alias]) && !empty($this->deleteUpdates[$Model->alias])) {
  740. foreach ($this->deleteUpdates[$Model->alias] as $assocAlias => $assocIds) {
  741. $Model->{$assocAlias}->updateRevisions($assocIds);
  742. }
  743. unset($this->deleteUpdates[$Model->alias]);
  744. }
  745. }
  746. /**
  747. * Will create a new revision if changes have been made in the models non-ignore fields.
  748. * Also deletes oldest revision if limit is (active and) reached.
  749. *
  750. * @param object $Model
  751. * @param boolean $created
  752. * @return boolean
  753. */
  754. public function afterSave(Model $Model, $created) {
  755. if ($this->settings[$Model->alias]['auto'] === false) {
  756. return true;
  757. }
  758. if (!$Model->ShadowModel) {
  759. return true;
  760. }
  761. if ($created) {
  762. $Model->ShadowModel->create($Model->data, true);
  763. $Model->ShadowModel->set($Model->primaryKey, $Model->id);
  764. $Model->ShadowModel->set('version_created', date('Y-m-d H:i:s'));
  765. foreach ($Model->data as $alias => $alias_data) {
  766. if (isset($Model->ShadowModel->_schema[$alias])) {
  767. if (isset($alias_data[$alias]) && !empty($alias_data[$alias])) {
  768. $Model->ShadowModel->set($alias, implode(',', $alias_data[$alias]));
  769. }
  770. }
  771. }
  772. $success = $Model->ShadowModel->save();
  773. $Model->version_id = $Model->ShadowModel->id;
  774. return $success;
  775. }
  776. $habtm = array();
  777. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  778. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  779. $habtm[] = $assocAlias;
  780. }
  781. }
  782. $data = $Model->find('first', array('contain' => $habtm, 'conditions' => array($Model->alias . '.' . $Model->primaryKey =>
  783. $Model->id)));
  784. $changeDetected = false;
  785. foreach ($data[$Model->alias] as $key => $value) {
  786. if (isset($data[$Model->alias][$Model->primaryKey]) && !empty($this->oldData[$Model->alias]) && isset($this->oldData[$Model->
  787. alias][$Model->alias][$key])) {
  788. $old_value = $this->oldData[$Model->alias][$Model->alias][$key];
  789. } else {
  790. $old_value = '';
  791. }
  792. if ($value != $old_value && !in_array($key, $this->settings[$Model->alias]['ignore'])) {
  793. $changeDetected = true;
  794. }
  795. }
  796. $Model->ShadowModel->create($data);
  797. if (!empty($habtm)) {
  798. foreach ($habtm as $assocAlias) {
  799. if (in_array($assocAlias, $this->settings[$Model->alias]['ignore'])) {
  800. continue;
  801. }
  802. $oldIds = Set::extract($this->oldData[$Model->alias], $assocAlias . '.{n}.id');
  803. if (!isset($Model->data[$assocAlias])) {
  804. $Model->ShadowModel->set($assocAlias, implode(',', $oldIds));
  805. continue;
  806. }
  807. $currentIds = Set::extract($data, $assocAlias . '.{n}.id');
  808. $id_changes = array_diff($currentIds, $oldIds);
  809. if (!empty($id_changes)) {
  810. $Model->ShadowModel->set($assocAlias, implode(',', $currentIds));
  811. $changeDetected = true;
  812. } else {
  813. $Model->ShadowModel->set($assocAlias, implode(',', $oldIds));
  814. }
  815. }
  816. }
  817. unset($this->oldData[$Model->alias]);
  818. if (!$changeDetected) {
  819. return true;
  820. }
  821. $Model->ShadowModel->set('version_created', date('Y-m-d H:i:s'));
  822. $Model->ShadowModel->save();
  823. $Model->version_id = $Model->ShadowModel->id;
  824. if (is_numeric($this->settings[$Model->alias]['limit'])) {
  825. $conditions = array('conditions' => array($Model->alias . '.' . $Model->primaryKey => $Model->id));
  826. $count = $Model->ShadowModel->find('count', $conditions);
  827. if ($count > $this->settings[$Model->alias]['limit']) {
  828. $conditions['order'] = $Model->alias . '.version_created ASC, ' . $Model->alias . '.version_id ASC';
  829. $oldest = $Model->ShadowModel->find('first', $conditions);
  830. $Model->ShadowModel->id = null;
  831. $Model->ShadowModel->delete($oldest[$Model->alias][$Model->ShadowModel->primaryKey]);
  832. }
  833. }
  834. return true;
  835. }
  836. /**
  837. * Causes revision for habtm associated models if that model does version control
  838. * on their relationship. BeforeDelete identifies the related models that will need
  839. * to do the revision update in afterDelete.
  840. *
  841. * @param object $Model
  842. * @return boolean
  843. */
  844. public function beforeDelete(Model $Model, $cascade = true) {
  845. if ($this->settings[$Model->alias]['auto'] === false) {
  846. return true;
  847. }
  848. if (!$Model->ShadowModel) {
  849. return true;
  850. }
  851. foreach ($Model->hasAndBelongsToMany as $assocAlias => $a) {
  852. if (isset($Model->{$assocAlias}->ShadowModel->_schema[$Model->alias])) {
  853. $joins = $Model->{$a['with']}->find('all', array('recursive' => -1, 'conditions' => array($a['foreignKey'] => $Model->
  854. id)));
  855. $this->deleteUpdates[$Model->alias][$assocAlias] = Set::extract($joins, '/' . $a['with'] . '/' . $a['associationForeignKey']);
  856. }
  857. }
  858. return true;
  859. }
  860. /**
  861. * Revision uses the beforeSave callback to remember the old data for comparison in afterSave
  862. *
  863. * @param object $Model
  864. * @return boolean
  865. */
  866. public function beforeSave(Model $Model) {
  867. if ($this->settings[$Model->alias]['auto'] === false) {
  868. return true;
  869. }
  870. if (!$Model->ShadowModel) {
  871. return true;
  872. }
  873. $Model->ShadowModel->create();
  874. if (!isset($Model->data[$Model->alias][$Model->primaryKey]) && !$Model->id) {
  875. return true;
  876. }
  877. $habtm = array();
  878. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  879. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  880. $habtm[] = $assocAlias;
  881. }
  882. }
  883. $this->oldData[$Model->alias] = $Model->find('first', array('contain' => $habtm, 'conditions' => array($Model->alias .
  884. '.' . $Model->primaryKey => $Model->id)));
  885. return true;
  886. }
  887. /**
  888. * Returns a generic model that maps to the current $Model's shadow table.
  889. *
  890. * @param object $Model
  891. * @return boolean
  892. */
  893. protected function createShadowModel(Model $Model) {
  894. if (is_null($this->settings[$Model->alias]['useDbConfig'])) {
  895. $dbConfig = $Model->useDbConfig;
  896. } else {
  897. $dbConfig = $this->settings[$Model->alias]['useDbConfig'];
  898. }
  899. $db = ConnectionManager::getDataSource($dbConfig);
  900. if ($Model->useTable) {
  901. $shadow_table = $Model->useTable;
  902. } else {
  903. $shadow_table = Inflector::tableize($Model->name);
  904. }
  905. $shadow_table = $shadow_table . $this->revision_suffix;
  906. $prefix = $Model->tablePrefix ? $Model->tablePrefix : $db->config['prefix'];
  907. $full_table_name = $prefix . $shadow_table;
  908. $existing_tables = $db->listSources();
  909. if (!in_array($full_table_name, $existing_tables)) {
  910. $Model->ShadowModel = false;
  911. return false;
  912. }
  913. $useShadowModel = $this->settings[$Model->alias]['model'];
  914. if (is_string($useShadowModel) && App::import('model', $useShadowModel)) {
  915. $Model->ShadowModel = new $useShadowModel(false, $shadow_table, $dbConfig);
  916. } else {
  917. $Model->ShadowModel = new Model(false, $shadow_table, $dbConfig);
  918. }
  919. if ($Model->tablePrefix) {
  920. $Model->ShadowModel->tablePrefix = $Model->tablePrefix;
  921. }
  922. $Model->ShadowModel->alias = $Model->alias;
  923. $Model->ShadowModel->primaryKey = 'version_id';
  924. $Model->ShadowModel->order = 'version_created DESC, version_id DESC';
  925. return true;
  926. }
  927. }