RevisionBehavior.php 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985
  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. * Behavior settings
  109. *
  110. * @access public
  111. * @var array
  112. */
  113. public $settings = array();
  114. /**
  115. * Shadow table prefix
  116. * Only change this value if it causes table name crashes
  117. *
  118. * @access private
  119. * @var string
  120. */
  121. protected $revision_suffix = '_revs';
  122. /**
  123. * Defaul setting values
  124. *
  125. * @access private
  126. * @var array
  127. */
  128. protected $defaults = array(
  129. 'limit' => false,
  130. 'auto' => true,
  131. 'ignore' => array(),
  132. 'useDbConfig' => null,
  133. 'model' => null);
  134. /**
  135. * Old data, used to detect changes
  136. *
  137. * @var array
  138. */
  139. protected $oldData = array();
  140. /**
  141. * Configure the behavior through the Model::actsAs property
  142. *
  143. * @param object $Model
  144. * @param array $config
  145. */
  146. public function setup(Model $Model, $config = array()) {
  147. if (!empty($config)) {
  148. $this->settings[$Model->alias] = array_merge($this->defaults, $config);
  149. } else {
  150. $this->settings[$Model->alias] = $this->defaults;
  151. }
  152. $this->createShadowModel($Model);
  153. $Model->Behaviors->attach('Containable');
  154. }
  155. /**
  156. * Manually create a revision of the current record of Model->id
  157. *
  158. * @example $this->Post->id = 5; $this->Post->createRevision();
  159. * @param object $Model
  160. * @return boolean success
  161. */
  162. public function createRevision(Model $Model) {
  163. if (!$Model->id) {
  164. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  165. return null;
  166. }
  167. if (!$Model->ShadowModel) {
  168. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  169. return false;
  170. }
  171. $habtm = array();
  172. $all_habtm = $Model->getAssociated('hasAndBelongsToMany');
  173. foreach ($all_habtm as $assocAlias) {
  174. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  175. $habtm[] = $assocAlias;
  176. }
  177. }
  178. $data = $Model->find('first', array('conditions' => array($Model->alias . '.' . $Model->primaryKey => $Model->id),
  179. 'contain' => $habtm));
  180. $Model->ShadowModel->create($data);
  181. $Model->ShadowModel->set('version_created', date('Y-m-d H:i:s'));
  182. foreach ($habtm as $assocAlias) {
  183. $foreign_keys = Set::extract($data, '/' . $assocAlias . '/' . $Model->{$assocAlias}->primaryKey);
  184. $Model->ShadowModel->set($assocAlias, implode(',', $foreign_keys));
  185. }
  186. return $Model->ShadowModel->save();
  187. }
  188. /**
  189. * Returns an array that maps to the Model, only with multiple values for fields that has been changed
  190. *
  191. * @example $this->Post->id = 4; $changes = $this->Post->diff();
  192. * @example $this->Post->id = 4; $my_changes = $this->Post->diff(null,nul,array('conditions'=>array('user_id'=>4)));
  193. * @example $this->Post->id = 4; $difference = $this->Post->diff(45,192);
  194. * @param Object $Model
  195. * @param int $from_version_id
  196. * @param int $to_version_id
  197. * @param array $options
  198. * @return array
  199. */
  200. public function diff(Model $Model, $from_version_id = null, $to_version_id = null, $options = array()) {
  201. if (!$Model->id) {
  202. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  203. return null;
  204. }
  205. if (!$Model->ShadowModel) {
  206. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  207. return null;
  208. }
  209. if (isset($options['conditions'])) {
  210. $conditions = am($options['conditions'], array($Model->primaryKey => $Model->id));
  211. } else {
  212. $conditions = array($Model->primaryKey => $Model->id);
  213. }
  214. if (is_numeric($from_version_id) || is_numeric($to_version_id)) {
  215. if (is_numeric($from_version_id) && is_numeric($to_version_id)) {
  216. $conditions['version_id'] = array($from_version_id, $to_version_id);
  217. if ($Model->ShadowModel->find('count', array('conditions' => $conditions)) < 2) {
  218. return false;
  219. }
  220. } else {
  221. if (is_numeric($from_version_id)) {
  222. $conditions['version_id'] = $from_version_id;
  223. } else {
  224. $conditions['version_id'] = $to_version_id;
  225. }
  226. if ($Model->ShadowModel->find('count', array('conditions' => $conditions)) < 1) {
  227. return false;
  228. }
  229. }
  230. }
  231. $conditions = array($Model->primaryKey => $Model->id);
  232. if (is_numeric($from_version_id)) {
  233. $conditions['version_id >='] = $from_version_id;
  234. }
  235. if (is_numeric($to_version_id)) {
  236. $conditions['version_id <='] = $to_version_id;
  237. }
  238. $options['conditions'] = $conditions;
  239. $all = $this->revisions($Model, $options, true);
  240. if (sizeof($all) == 0) {
  241. return null;
  242. }
  243. $unified = array();
  244. $keys = array_keys($all[0][$Model->alias]);
  245. foreach ($keys as $field) {
  246. $all_values = Set::extract($all, '/' . $Model->alias . '/' . $field);
  247. $all_values = array_reverse(array_unique(array_reverse($all_values, true)), true);
  248. if (sizeof($all_values) == 1) {
  249. $unified[$field] = reset($all_values);
  250. } else {
  251. $unified[$field] = $all_values;
  252. }
  253. }
  254. return array($Model->alias => $unified);
  255. }
  256. /**
  257. * Will create a current revision of all rows in Model, if none exist.
  258. * Use this if you add the revision to a model that allready has data in
  259. * the DB.
  260. * If you have large tables or big/many fields, use $limit to reduce the
  261. * number of rows that is run at once.
  262. *
  263. * @example $this->Post->initializeRevisions();
  264. * @param object $Model
  265. * @param int $limit number of rows to initialize in one go
  266. * @return boolean
  267. */
  268. public function initializeRevisions(Model $Model, $limit = 100) {
  269. if (!$Model->ShadowModel) {
  270. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  271. return false;
  272. }
  273. if ($Model->ShadowModel->useTable == false) {
  274. trigger_error('RevisionBehavior: Missing shadowtable : ' . $Model->table . $this->suffix, E_USER_WARNING);
  275. return null;
  276. }
  277. if ($Model->ShadowModel->find('count') != 0) {
  278. return false;
  279. }
  280. $count = $Model->find('count');
  281. if ($limit < $count) {
  282. $remaining = $count;
  283. for ($p = 1; true; $p++) {
  284. $this->init($Model, $p, $limit);
  285. $remaining = $remaining - $limit;
  286. if ($remaining <= 0) {
  287. break;
  288. }
  289. }
  290. } else {
  291. $this->init($Model, 1, $count);
  292. }
  293. return true;
  294. }
  295. /**
  296. * saves revisions for rows matching page and limit given
  297. *
  298. * @param object $Model
  299. * @param int $page
  300. * @param int $limit
  301. */
  302. protected function init(Model $Model, $page, $limit) {
  303. $habtm = array();
  304. $all_habtm = $Model->getAssociated('hasAndBelongsToMany');
  305. foreach ($all_habtm as $assocAlias) {
  306. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  307. $habtm[] = $assocAlias;
  308. }
  309. }
  310. $all = $Model->find('all', array(
  311. 'limit' => $limit,
  312. 'page' => $page,
  313. 'contain' => $habtm));
  314. $version_created = date('Y-m-d H:i:s');
  315. foreach ($all as $data) {
  316. $Model->ShadowModel->create($data);
  317. $Model->ShadowModel->set('version_created', $version_created);
  318. $Model->ShadowModel->save();
  319. }
  320. }
  321. /**
  322. * Finds the newest revision, including the current one.
  323. * Use with caution, the live model may be different depending on the usage
  324. * of ignore fields.
  325. *
  326. * @example $this->Post->id = 6; $newest_revision = $this->Post->newest();
  327. * @param object $Model
  328. * @param array $options
  329. * @return array
  330. */
  331. public function newest(Model $Model, $options = array()) {
  332. if (!$Model->id) {
  333. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  334. return null;
  335. }
  336. if (!$Model->ShadowModel) {
  337. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  338. return null;
  339. }
  340. if (isset($options['conditions'])) {
  341. $options['conditions'] = am($options['conditions'], array($Model->alias . '.' . $Model->primaryKey => $Model->id));
  342. } else {
  343. $options['conditions'] = array($Model->alias . '.' . $Model->primaryKey => $Model->id);
  344. }
  345. return $Model->ShadowModel->find('first', $options);
  346. }
  347. /**
  348. * Find the oldest revision for the current Model->id
  349. * If no limit is used on revision and revision has been enabled for the model
  350. * since start, this call will return the original first record.
  351. *
  352. * @example $this->Post->id = 2; $original = $this->Post->oldest();
  353. * @param object $Model
  354. * @param array $options
  355. * @return array
  356. */
  357. public function oldest(Model $Model, $options = array()) {
  358. if (!$Model->id) {
  359. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  360. return null;
  361. }
  362. if (!$Model->ShadowModel) {
  363. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  364. return null;
  365. }
  366. if (isset($options['conditions'])) {
  367. $options['conditions'] = am($options['conditions'], array($Model->primaryKey => $Model->id));
  368. } else {
  369. $options['conditions'] = array($Model->primaryKey => $Model->id);
  370. }
  371. $options['order'] = 'version_created ASC, version_id ASC';
  372. return $Model->ShadowModel->find('first', $options);
  373. }
  374. /**
  375. * Find the second newest revisions, including the current one.
  376. *
  377. * @example $this->Post->id = 6; $undo_revision = $this->Post->previous();
  378. * @param object $Model
  379. * @param array $options
  380. * @return array
  381. */
  382. public function previous(Model $Model, $options = array()) {
  383. if (!$Model->id) {
  384. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  385. return null;
  386. }
  387. if (!$Model->ShadowModel) {
  388. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  389. return false;
  390. }
  391. $options['limit'] = 1;
  392. $options['page'] = 2;
  393. if (isset($options['conditions'])) {
  394. $options['conditions'] = am($options['conditions'], array($Model->primaryKey => $Model->id));
  395. } else {
  396. $options['conditions'] = array($Model->primaryKey => $Model->id);
  397. }
  398. $revisions = $Model->ShadowModel->find('all', $options);
  399. if (!$revisions) {
  400. return null;
  401. }
  402. return $revisions[0];
  403. }
  404. /**
  405. * Revert all rows matching conditions to given date.
  406. * Model rows outside condition or not edited will not be affected. Edits since date
  407. * will be reverted and rows created since date deleted.
  408. *
  409. * @param object $Model
  410. * @param array $options 'conditions','date'
  411. * @return boolean success
  412. */
  413. public function revertAll(Model $Model, $options = array()) {
  414. if (!$Model->ShadowModel) {
  415. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  416. return false;
  417. }
  418. if (empty($options) || !isset($options['date'])) {
  419. return false;
  420. }
  421. if (!isset($options['conditions'])) {
  422. $options['conditions'] = array();
  423. }
  424. // leave model rows out side of condtions alone
  425. // leave model rows not edited since date alone
  426. $all = $Model->find('all', array('conditions' => $options['conditions'], 'fields' => $Model->primaryKey));
  427. $allIds = Set::extract($all, '/' . $Model->alias . '/' . $Model->primaryKey);
  428. $cond = $options['conditions'];
  429. $cond['version_created <'] = $options['date'];
  430. $created_before_date = $Model->ShadowModel->find('all', array(
  431. 'order' => $Model->primaryKey,
  432. 'conditions' => $cond,
  433. 'fields' => array('version_id', $Model->primaryKey)));
  434. $created_before_dateIds = Set::extract($created_before_date, '/' . $Model->alias . '/' . $Model->primaryKey);
  435. $deleteIds = array_diff($allIds, $created_before_dateIds);
  436. // delete all Model rows where there are only version_created later than date
  437. $Model->deleteAll(array($Model->alias . '.' . $Model->primaryKey => $deleteIds), false, true);
  438. unset($cond['version_created <']);
  439. $cond['version_created >='] = $options['date'];
  440. $created_after_date = $Model->ShadowModel->find('all', array(
  441. 'order' => $Model->primaryKey,
  442. 'conditions' => $cond,
  443. 'fields' => array('version_id', $Model->primaryKey)));
  444. $created_after_dateIds = Set::extract($created_after_date, '/' . $Model->alias . '/' . $Model->primaryKey);
  445. $updateIds = array_diff($created_after_dateIds, $deleteIds);
  446. $revertSuccess = true;
  447. // update model rows that have version_created earlier than date to latest before date
  448. foreach ($updateIds as $mid) {
  449. $Model->id = $mid;
  450. if (!$Model->revertToDate($options['date'])) {
  451. $revertSuccess = false;
  452. }
  453. }
  454. return $revertSuccess;
  455. }
  456. /**
  457. * Revert current Model->id to the given revision id
  458. * Will return false if version id is invalid or save fails
  459. *
  460. * @example $this->Post->id = 3; $this->Post->revertTo(12);
  461. * @param object $Model
  462. * @param int $version_id
  463. * @return boolean
  464. */
  465. public function revertTo(Model $Model, $version_id) {
  466. if (!$Model->id) {
  467. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  468. return null;
  469. }
  470. if (!$Model->ShadowModel) {
  471. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  472. return false;
  473. }
  474. $data = $Model->ShadowModel->find('first', array('conditions' => array('version_id' => $version_id)));
  475. if ($data == false) {
  476. return false;
  477. }
  478. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  479. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  480. $data[$assocAlias][$assocAlias] = explode(',', $data[$Model->alias][$assocAlias]);
  481. }
  482. }
  483. return $Model->save($data);
  484. }
  485. /**
  486. * Revert to the oldest revision after the given datedate.
  487. * Will cascade to hasOne and hasMany associeted models if $cascade is true.
  488. * Will return false if no change is made on the main model
  489. *
  490. * @example $this->Post->id = 3; $this->Post->revertToDate(date('Y-m-d H:i:s',strtotime('Yesterday')));
  491. * @example $this->Post->id = 4; $this->Post->revertToDate('2008-09-01',true);
  492. * @param object $Model
  493. * @param string $datetime
  494. * @param boolean $cascade
  495. * @param boolean $force_delete
  496. * @return boolean
  497. */
  498. public function revertToDate(Model $Model, $datetime, $cascade = false, $force_delete = false) {
  499. if (!$Model->id) {
  500. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  501. return null;
  502. }
  503. if ($cascade) {
  504. $associated = array_merge($Model->hasMany, $Model->hasOne);
  505. foreach ($associated as $assoc => $data) {
  506. // Continue with next association if no shadow model
  507. if (empty($Model->$assoc->ShadowModel)) {
  508. continue;
  509. }
  510. $ids = array();
  511. $cascade = false;
  512. /* Check if association has dependent children */
  513. $depassoc = array_merge($Model->$assoc->hasMany, $Model->$assoc->hasOne);
  514. foreach ($depassoc as $dep) {
  515. if ($dep['dependent']) {
  516. $cascade = true;
  517. }
  518. }
  519. /* Query live data for children */
  520. $children = $Model->$assoc->find('list', array('conditions' => array($data['foreignKey'] => $Model->id), 'recursive' =>
  521. -1));
  522. if (!empty($children)) {
  523. $ids = array_keys($children);
  524. }
  525. /* Query shadow table for deleted children */
  526. $revision_children = $Model->$assoc->ShadowModel->find('all', array(
  527. 'fields' => array('DISTINCT ' . $Model->primaryKey),
  528. 'conditions' => array($data['foreignKey'] => $Model->id, 'NOT' => array($Model->primaryKey => $ids)),
  529. ));
  530. if (!empty($revision_children)) {
  531. $ids = am($ids, Set::extract($revision_children, '/' . $assoc . '/' . $Model->$assoc->primaryKey));
  532. }
  533. /* Revert all children */
  534. foreach ($ids as $id) {
  535. $Model->$assoc->id = $id;
  536. $Model->$assoc->revertToDate($datetime, $cascade, $force_delete);
  537. }
  538. }
  539. }
  540. if (empty($Model->ShadowModel)) {
  541. return true;
  542. }
  543. $data = $Model->ShadowModel->find('first', array('conditions' => array($Model->primaryKey => $Model->id,
  544. 'version_created <=' => $datetime), 'order' => 'version_created ASC, version_id ASC'));
  545. /* If no previous version was found and revertToDate() was called with force_delete, then delete the live data, else leave it alone */
  546. if ($data == false) {
  547. if ($force_delete) {
  548. $Model->logableAction['Revision'] = 'revertToDate(' . $datetime . ') delete';
  549. return $Model->delete($Model->id);
  550. }
  551. return true;
  552. }
  553. $habtm = array();
  554. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  555. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  556. $habtm[] = $assocAlias;
  557. }
  558. }
  559. $liveData = $Model->find('first', array('contain' => $habtm, 'conditions' => array($Model->alias . '.' . $Model->
  560. primaryKey => $Model->id)));
  561. $Model->logableAction['Revision'] = 'revertToDate(' . $datetime . ') add';
  562. if ($liveData) {
  563. $Model->logableAction['Revision'] = 'revertToDate(' . $datetime . ') edit';
  564. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  565. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  566. $ids = Set::extract($liveData, '/' . $assocAlias . '/' . $Model->$assocAlias->primaryKey);
  567. if (empty($ids) || is_string($ids)) {
  568. $liveData[$Model->alias][$assocAlias] = '';
  569. } else {
  570. $liveData[$Model->alias][$assocAlias] = implode(',', $ids);
  571. }
  572. $data[$assocAlias][$assocAlias] = explode(',', $data[$Model->alias][$assocAlias]);
  573. }
  574. unset($liveData[$assocAlias]);
  575. }
  576. $changeDetected = false;
  577. foreach ($liveData[$Model->alias] as $key => $value) {
  578. if (isset($data[$Model->alias][$key])) {
  579. $old_value = $data[$Model->alias][$key];
  580. } else {
  581. $old_value = '';
  582. }
  583. if ($value != $old_value) {
  584. $changeDetected = true;
  585. }
  586. }
  587. if (!$changeDetected) {
  588. return true;
  589. }
  590. }
  591. $auto = $this->settings[$Model->alias]['auto'];
  592. $this->settings[$Model->alias]['auto'] = false;
  593. $Model->ShadowModel->create($data, true);
  594. $Model->ShadowModel->set('version_created', date('Y-m-d H:i:s'));
  595. $Model->ShadowModel->save();
  596. $Model->version_id = $Model->ShadowModel->id;
  597. $success = $Model->save($data);
  598. $this->settings[$Model->alias]['auto'] = $auto;
  599. return $success;
  600. }
  601. /**
  602. * Returns a comeplete list of revisions for the current Model->id.
  603. * The options array may include Model::find parameters to narrow down result
  604. * Alias for shadow('all',array('conditions'=>array($Model->primaryKey => $Model->id)));
  605. *
  606. * @example $this->Post->id = 4; $history = $this->Post->revisions();
  607. * @example $this->Post->id = 4; $today = $this->Post->revisions(array('conditions'=>array('version_create >'=>'2008-12-10')));
  608. * @param object $Model
  609. * @param array $options
  610. * @param boolean $include_current If true will include last saved (live) data
  611. * @return array
  612. */
  613. public function revisions(Model $Model, $options = array(), $include_current = false) {
  614. if (!$Model->id) {
  615. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  616. return null;
  617. }
  618. if (!$Model->ShadowModel) {
  619. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  620. return null;
  621. }
  622. if (isset($options['conditions'])) {
  623. $options['conditions'] = am($options['conditions'], array($Model->alias . '.' . $Model->primaryKey => $Model->id));
  624. } else {
  625. $options['conditions'] = array($Model->alias . '.' . $Model->primaryKey => $Model->id);
  626. }
  627. if ($include_current == false) {
  628. $current = $this->newest($Model, array('fields' => array($Model->alias . '.version_id', $Model->primaryKey)));
  629. $options['conditions'][$Model->alias . '.version_id !='] = $current[$Model->alias]['version_id'];
  630. }
  631. return $Model->ShadowModel->find('all', $options);
  632. }
  633. /**
  634. * Undoes an delete by saving the last revision to the Model
  635. * Will return false if this Model->id exist in the live table.
  636. * Calls Model::beforeUndelete and Model::afterUndelete
  637. *
  638. * @example $this->Post->id = 7; $this->Post->undelete();
  639. * @param object $Model
  640. * @return boolean
  641. */
  642. public function undelete(Model $Model) {
  643. if (!$Model->id) {
  644. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  645. return null;
  646. }
  647. if (!$Model->ShadowModel) {
  648. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  649. return false;
  650. }
  651. if ($Model->find('count', array('conditions' => array($Model->primaryKey => $Model->id), 'recursive' => -1)) > 0) {
  652. return false;
  653. }
  654. $data = $this->newest($Model);
  655. if (!$data) {
  656. return false;
  657. }
  658. $beforeUndeleteSuccess = true;
  659. if (method_exists($Model, 'beforeUndelete')) {
  660. $beforeUndeleteSuccess = $Model->beforeUndelete();
  661. }
  662. if (!$beforeUndeleteSuccess) {
  663. return false;
  664. }
  665. $model_id = $data[$Model->alias][$Model->primaryKey];
  666. unset($data[$Model->alias][$Model->ShadowModel->primaryKey]);
  667. $Model->create($data, true);
  668. $auto_setting = $this->settings[$Model->alias]['auto'];
  669. $this->settings[$Model->alias]['auto'] = false;
  670. $save_success = $Model->save();
  671. $this->settings[$Model->alias]['auto'] = $auto_setting;
  672. if (!$save_success) {
  673. return false;
  674. }
  675. $Model->updateAll(
  676. array($Model->primaryKey => $model_id),
  677. array($Model->primaryKey => $Model->id)
  678. );
  679. $Model->id = $model_id;
  680. $Model->createRevision();
  681. $afterUndeleteSuccess = true;
  682. if (method_exists($Model, 'afterUndelete')) {
  683. $afterUndeleteSuccess = $Model->afterUndelete();
  684. }
  685. return $afterUndeleteSuccess;
  686. }
  687. /**
  688. * Update to previous revision
  689. *
  690. * @example $this->Post->id = 2; $this->Post->undo();
  691. * @param object $Model
  692. * @return boolean
  693. */
  694. public function undo(Model $Model) {
  695. if (!$Model->id) {
  696. trigger_error('RevisionBehavior: Model::id must be set', E_USER_WARNING);
  697. return null;
  698. }
  699. if (!$Model->ShadowModel) {
  700. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  701. return false;
  702. }
  703. $data = $this->previous($Model);
  704. if ($data == false) {
  705. $Model->logableAction['Revision'] = 'undo add';
  706. $Model->delete($Model->id);
  707. return false;
  708. }
  709. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  710. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  711. $data[$assocAlias][$assocAlias] = explode(',', $data[$Model->alias][$assocAlias]);
  712. }
  713. }
  714. $Model->logableAction['Revision'] = 'undo changes';
  715. return $Model->save($data);
  716. }
  717. /**
  718. * Calls create revision for all rows matching primary key list of $idlist
  719. *
  720. * @example $this->Model->updateRevisions(array(1,2,3));
  721. * @param object $Model
  722. * @param array $idlist
  723. */
  724. public function updateRevisions(Model $Model, $idlist = array()) {
  725. if (!$Model->ShadowModel) {
  726. trigger_error('RevisionBehavior: ShadowModel doesnt exist.', E_USER_WARNING);
  727. return null;
  728. }
  729. foreach ($idlist as $id) {
  730. $Model->id = $id;
  731. $Model->createRevision();
  732. }
  733. }
  734. /**
  735. * Causes revision for habtm associated models if that model does version control
  736. * on their relationship. BeforeDelete identifies the related models that will need
  737. * to do the revision update in afterDelete. Uses
  738. *
  739. * @param unknown_type $Model
  740. */
  741. public function afterDelete(Model $Model) {
  742. if ($this->settings[$Model->alias]['auto'] === false) {
  743. return true;
  744. }
  745. if (!$Model->ShadowModel) {
  746. return true;
  747. }
  748. if (isset($this->deleteUpdates[$Model->alias]) && !empty($this->deleteUpdates[$Model->alias])) {
  749. foreach ($this->deleteUpdates[$Model->alias] as $assocAlias => $assocIds) {
  750. $Model->{$assocAlias}->updateRevisions($assocIds);
  751. }
  752. unset($this->deleteUpdates[$Model->alias]);
  753. }
  754. }
  755. /**
  756. * Will create a new revision if changes have been made in the models non-ignore fields.
  757. * Also deletes oldest revision if limit is (active and) reached.
  758. *
  759. * @param object $Model
  760. * @param boolean $created
  761. * @return boolean
  762. */
  763. public function afterSave(Model $Model, $created) {
  764. if ($this->settings[$Model->alias]['auto'] === false) {
  765. return true;
  766. }
  767. if (!$Model->ShadowModel) {
  768. return true;
  769. }
  770. if ($created) {
  771. $Model->ShadowModel->create($Model->data, true);
  772. $Model->ShadowModel->set($Model->primaryKey, $Model->id);
  773. $Model->ShadowModel->set('version_created', date('Y-m-d H:i:s'));
  774. foreach ($Model->data as $alias => $alias_data) {
  775. if (isset($Model->ShadowModel->_schema[$alias])) {
  776. if (isset($alias_data[$alias]) && !empty($alias_data[$alias])) {
  777. $Model->ShadowModel->set($alias, implode(',', $alias_data[$alias]));
  778. }
  779. }
  780. }
  781. $success = $Model->ShadowModel->save();
  782. $Model->version_id = $Model->ShadowModel->id;
  783. return $success;
  784. }
  785. $habtm = array();
  786. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  787. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  788. $habtm[] = $assocAlias;
  789. }
  790. }
  791. $data = $Model->find('first', array('contain' => $habtm, 'conditions' => array($Model->alias . '.' . $Model->primaryKey =>
  792. $Model->id)));
  793. $changeDetected = false;
  794. foreach ($data[$Model->alias] as $key => $value) {
  795. if (isset($data[$Model->alias][$Model->primaryKey]) && !empty($this->oldData[$Model->alias]) && isset($this->oldData[$Model->
  796. alias][$Model->alias][$key])) {
  797. $old_value = $this->oldData[$Model->alias][$Model->alias][$key];
  798. } else {
  799. $old_value = '';
  800. }
  801. if ($value != $old_value && !in_array($key, $this->settings[$Model->alias]['ignore'])) {
  802. $changeDetected = true;
  803. }
  804. }
  805. $Model->ShadowModel->create($data);
  806. if (!empty($habtm)) {
  807. foreach ($habtm as $assocAlias) {
  808. if (in_array($assocAlias, $this->settings[$Model->alias]['ignore'])) {
  809. continue;
  810. }
  811. $oldIds = Set::extract($this->oldData[$Model->alias], $assocAlias . '.{n}.id');
  812. if (!isset($Model->data[$assocAlias])) {
  813. $Model->ShadowModel->set($assocAlias, implode(',', $oldIds));
  814. continue;
  815. }
  816. $currentIds = Set::extract($data, $assocAlias . '.{n}.id');
  817. $id_changes = array_diff($currentIds, $oldIds);
  818. if (!empty($id_changes)) {
  819. $Model->ShadowModel->set($assocAlias, implode(',', $currentIds));
  820. $changeDetected = true;
  821. } else {
  822. $Model->ShadowModel->set($assocAlias, implode(',', $oldIds));
  823. }
  824. }
  825. }
  826. unset($this->oldData[$Model->alias]);
  827. if (!$changeDetected) {
  828. return true;
  829. }
  830. $Model->ShadowModel->set('version_created', date('Y-m-d H:i:s'));
  831. $Model->ShadowModel->save();
  832. $Model->version_id = $Model->ShadowModel->id;
  833. if (is_numeric($this->settings[$Model->alias]['limit'])) {
  834. $conditions = array('conditions' => array($Model->alias . '.' . $Model->primaryKey => $Model->id));
  835. $count = $Model->ShadowModel->find('count', $conditions);
  836. if ($count > $this->settings[$Model->alias]['limit']) {
  837. $conditions['order'] = $Model->alias . '.version_created ASC, ' . $Model->alias . '.version_id ASC';
  838. $oldest = $Model->ShadowModel->find('first', $conditions);
  839. $Model->ShadowModel->id = null;
  840. $Model->ShadowModel->delete($oldest[$Model->alias][$Model->ShadowModel->primaryKey]);
  841. }
  842. }
  843. return true;
  844. }
  845. /**
  846. * Causes revision for habtm associated models if that model does version control
  847. * on their relationship. BeforeDelete identifies the related models that will need
  848. * to do the revision update in afterDelete.
  849. *
  850. * @param object $Model
  851. * @return boolean
  852. */
  853. public function beforeDelete(Model $Model) {
  854. if ($this->settings[$Model->alias]['auto'] === false) {
  855. return true;
  856. }
  857. if (!$Model->ShadowModel) {
  858. return true;
  859. }
  860. foreach ($Model->hasAndBelongsToMany as $assocAlias => $a) {
  861. if (isset($Model->{$assocAlias}->ShadowModel->_schema[$Model->alias])) {
  862. $joins = $Model->{$a['with']}->find('all', array('recursive' => -1, 'conditions' => array($a['foreignKey'] => $Model->
  863. id)));
  864. $this->deleteUpdates[$Model->alias][$assocAlias] = Set::extract($joins, '/' . $a['with'] . '/' . $a['associationForeignKey']);
  865. }
  866. }
  867. return true;
  868. }
  869. /**
  870. * Revision uses the beforeSave callback to remember the old data for comparison in afterSave
  871. *
  872. * @param object $Model
  873. * @return boolean
  874. */
  875. public function beforeSave(Model $Model) {
  876. if ($this->settings[$Model->alias]['auto'] === false) {
  877. return true;
  878. }
  879. if (!$Model->ShadowModel) {
  880. return true;
  881. }
  882. $Model->ShadowModel->create();
  883. if (!isset($Model->data[$Model->alias][$Model->primaryKey]) && !$Model->id) {
  884. return true;
  885. }
  886. $habtm = array();
  887. foreach ($Model->getAssociated('hasAndBelongsToMany') as $assocAlias) {
  888. if (isset($Model->ShadowModel->_schema[$assocAlias])) {
  889. $habtm[] = $assocAlias;
  890. }
  891. }
  892. $this->oldData[$Model->alias] = $Model->find('first', array('contain' => $habtm, 'conditions' => array($Model->alias .
  893. '.' . $Model->primaryKey => $Model->id)));
  894. return true;
  895. }
  896. /**
  897. * Returns a generic model that maps to the current $Model's shadow table.
  898. *
  899. * @param object $Model
  900. * @return boolean
  901. */
  902. protected function createShadowModel(Model $Model) {
  903. if (is_null($this->settings[$Model->alias]['useDbConfig'])) {
  904. $dbConfig = $Model->useDbConfig;
  905. } else {
  906. $dbConfig = $this->settings[$Model->alias]['useDbConfig'];
  907. }
  908. $db = ConnectionManager::getDataSource($dbConfig);
  909. if ($Model->useTable) {
  910. $shadow_table = $Model->useTable;
  911. } else {
  912. $shadow_table = Inflector::tableize($Model->name);
  913. }
  914. $shadow_table = $shadow_table . $this->revision_suffix;
  915. $prefix = $Model->tablePrefix ? $Model->tablePrefix : $db->config['prefix'];
  916. $full_table_name = $prefix . $shadow_table;
  917. $existing_tables = $db->listSources();
  918. if (!in_array($full_table_name, $existing_tables)) {
  919. $Model->ShadowModel = false;
  920. return false;
  921. }
  922. $useShadowModel = $this->settings[$Model->alias]['model'];
  923. if (is_string($useShadowModel) && App::import('model', $useShadowModel)) {
  924. $Model->ShadowModel = new $useShadowModel(false, $shadow_table, $dbConfig);
  925. } else {
  926. $Model->ShadowModel = new Model(false, $shadow_table, $dbConfig);
  927. }
  928. if ($Model->tablePrefix) {
  929. $Model->ShadowModel->tablePrefix = $Model->tablePrefix;
  930. }
  931. $Model->ShadowModel->alias = $Model->alias;
  932. $Model->ShadowModel->primaryKey = 'version_id';
  933. $Model->ShadowModel->order = 'version_created DESC, version_id DESC';
  934. return true;
  935. }
  936. }