RevisionBehavior.php 34 KB

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