RevisionBehavior.php 34 KB

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