RevisionBehavior.php 33 KB

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