RevisionBehavior.php 34 KB

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