├── .editorconfig ├── .upgrade.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── _config └── extension.yml ├── composer.json ├── docs ├── code-of-conduct.md └── install.md └── src ├── Tasks └── TruncateVersionsTask.php └── VersionTruncator.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in this file, 2 | # please see the EditorConfig documentation: 3 | # http://editorconfig.org 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [{*.yml,package.json}] 14 | indent_size = 2 15 | 16 | # The indent size used in the package.json file cannot be changed: 17 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 18 | -------------------------------------------------------------------------------- /.upgrade.yml: -------------------------------------------------------------------------------- 1 | mappings: 2 | VersionTruncator: Axllent\VersionTruncator\SiteTreeVersionTruncator 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Notable changes to this project will be documented in this file. 4 | 5 | ## [4.0.0] 6 | 7 | - Support for Silverstripe 6 8 | 9 | 10 | ## [3.1.1] 11 | 12 | - Replace deprecated DataExtension with Extension 13 | 14 | 15 | ## [3.1.0] 16 | 17 | - Add task option to delete all archived DataObjects 18 | 19 | 20 | ## [3.0.1] 21 | 22 | - Support for Silverstripe 5 23 | - Ensure versioned object hasStages() 24 | - Move set_reading_mode() to onAfterPublish() 25 | 26 | 27 | ## [3.0.0] 28 | 29 | - Major rewrite, breaking changes - support for all versioned DataObjects 30 | - Deletion policy per class type (and extending classes) 31 | - Prune only on `onPublish()` to simplify and reduce overheads 32 | - Modify tasks 33 | 34 | 35 | ## [2.0.3] 36 | 37 | - Switch to silverstripe-vendormodule 38 | 39 | 40 | ## [2.0.2] 41 | 42 | - Replace default config with static variables 43 | 44 | 45 | ## [2.0.1] 46 | 47 | - Fix potential dependency loop 48 | 49 | 50 | ## [2.0.0] 51 | 52 | - Add support for SilverStripe 4 (new SilverStripe 3 branch) 53 | - Rewrite of the internals 54 | - Add task to manually run cleanup 55 | - Update docs 56 | 57 | 58 | ## [1.0.0] 59 | 60 | - Adopt semantic versioning releases 61 | - Release versions 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2013-Now() Techno Joy www.technojoy.co.nz 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Version truncator for Silverstripe 2 | 3 | An extension for Silverstripe to automatically delete old versioned DataObject records from your database when a record is published, following predefined retention policies (see [configuration](#configuration)). 4 | 5 | When a record is being edited (such as a Page), no changes are made until it is published, so it could have 50 draft versions while you work on the copy. When you publish the page, the module prunes the (by default) all draft copies, leaving just the 10 latest published versions (configurable). 6 | 7 | 8 | ## Features 9 | 10 | * Delete all but the last XX **published** versions of a DataObject on publish 11 | * Delete all but the last YY **draft** versions of a DataObject on publish 12 | * Optionally keep old SiteTree objects where the URLSegment has changed (to preserve redirects) 13 | 14 | 15 | ## Tasks 16 | 17 | The module adds four manual tasks to: 18 | 19 | 1. Force a run over the entire database - this task is generally not needed unless you either just install the module and wish to tidy up, or change your DataObject configurations. 20 | 2. Silverstripe does not currently delete any File records once the file had been physically deleted (probably due to the immediate post-delete functionality relating to internal file linking). I cannot see any purpose of keeping these records after this, so this task will remove all records pertaining to deleted files/folders. 21 | 3. Force a "reset", keeping only the latest published version of each currently published DataObject (regardless of policy). Unpublished / modified DataObjects are not touched. 22 | 4. Delete all archived DataObjects. 23 | 24 | The tasks can be run in your browser via `/dev/tasks/TruncateVersionsTask`, 25 | or see `sake tasks:TruncateVersionsTask --help` for CLI options. 26 | 27 | 28 | ## Requirements 29 | 30 | * Silverstripe ^6.0 31 | 32 | Please see the `3` branch for Silverstripe 4 & 5 support. 33 | 34 | 35 | ## Installation 36 | 37 | `composer require axllent/silverstripe-version-truncator` 38 | 39 | 40 | ## Configuration 41 | 42 | Configuration is optional (see [Default config](#default-config)), however you can create a YML file (eg: `app/_config/version-truncator.yml`): 43 | 44 | ```yaml 45 | MyCustomObject: 46 | keep_versions: 5 47 | keep_drafts: 5 48 | ``` 49 | 50 | To skip pruning altogether for a particular DataObject, set `keep_versions: 0` for that object class. 51 | 52 | To overwrite the global defaults, see [`_config/extension.yml`](_config/extension.yml), eg: 53 | 54 | ```yaml 55 | SilverStripe\CMS\Model\SiteTree: 56 | keep_versions: 20 57 | keep_drafts: 10 58 | ``` 59 | 60 | 61 | ## Default config 62 | 63 | ### SiteTree (and extending classes eg: Page etc) 64 | 65 | On publish, the last 10 published versions are kept, and all draft copied are removed. The only exception is if the `URLSegment` and/or `ParentID` is has changed, in which case the module will keep a single record for each differing URLSegment to allow auto-redirection. 66 | 67 | 68 | ### All other DataObjects 69 | 70 | For all other versioned DataObjects, only the latest published version is kept, and all drafts deleted. This can be adjusted per DataObject, or globally (see above). 71 | -------------------------------------------------------------------------------- /_config/extension.yml: -------------------------------------------------------------------------------- 1 | SilverStripe\ORM\DataObject: 2 | extensions: 3 | - Axllent\VersionTruncator\VersionTruncator 4 | keep_versions: 1 5 | keep_drafts: 0 6 | 7 | SilverStripe\CMS\Model\SiteTree: 8 | keep_versions: 10 9 | keep_drafts: 0 10 | keep_redirects: true 11 | 12 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "axllent/silverstripe-version-truncator", 3 | "description": "Automatically delete old versioned Silverstripe records from the database", 4 | "type": "silverstripe-vendormodule", 5 | "homepage": "https://github.com/axllent/silverstripe-version-truncator", 6 | "keywords": [ 7 | "silverstripe", 8 | "framework", 9 | "sitetree", 10 | "dataobject", 11 | "database", 12 | "performance", 13 | "versioned" 14 | ], 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Ralph Slooten", 19 | "homepage": "https://www.axllent.org/" 20 | } 21 | ], 22 | "support": { 23 | "issues": "https://github.com/axllent/silverstripe-version-truncator/issues" 24 | }, 25 | "require": { 26 | "silverstripe/framework": "^6.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Axllent\\VersionTruncator\\": "src/" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /docs/code-of-conduct.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | Any discussions about this module, issues or pull requests should be done through the Github project page. 4 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Please refer to the [README.md](../README.md) for installation & configuration options. 4 | -------------------------------------------------------------------------------- /src/Tasks/TruncateVersionsTask.php: -------------------------------------------------------------------------------- 1 | getOption('prune')) { 42 | $this->prune($output); 43 | } 44 | if ($input->getOption('files')) { 45 | $this->pruneDeletedFileVersions($output); 46 | } 47 | if ($input->getOption('reset')) { 48 | $this->reset($output); 49 | } 50 | if ($input->getOption('archived')) { 51 | $this->deleteArchivedDataObjects($output); 52 | } 53 | 54 | $output->writeForHtml('

Select a task:

55 |

You do not normally need to run these tasks, as pruning is run automatically 56 | whenever a versioned DataObject is published.

57 | 95 | ', false); 96 | 97 | return Command::SUCCESS; 98 | } 99 | 100 | public function getOptions(): array 101 | { 102 | return [ 103 | new InputOption( 104 | 'prune', 105 | null, 106 | InputOption::VALUE_NONE, 107 | 'Prune all published versioned DataObjects according to your policies' 108 | ), 109 | new InputOption( 110 | 'files', 111 | null, 112 | InputOption::VALUE_NONE, 113 | 'Delete all versions belonging to deleted files' 114 | ), 115 | new InputOption( 116 | 'reset', 117 | null, 118 | InputOption::VALUE_NONE, 119 | 'Delete ALL historical versions for all versioned DataObjects' 120 | ), 121 | new InputOption( 122 | 'archived', 123 | null, 124 | InputOption::VALUE_NONE, 125 | 'Delete archived DataObjects' 126 | ), 127 | ]; 128 | } 129 | 130 | /** 131 | * Prune all published DataObjects which are published according to config 132 | * 133 | * @return void 134 | */ 135 | private function prune(PolyOutput $output) 136 | { 137 | $classes = $this->getAllVersionedDataClasses(); 138 | 139 | $output->writeln('Pruning all DataObjects'); 140 | 141 | $total = 0; 142 | 143 | foreach ($classes as $class) { 144 | $records = Versioned::get_by_stage($class, Versioned::DRAFT); 145 | $deleted = 0; 146 | 147 | foreach ($records as $r) { 148 | // check if stages are present 149 | if (!$r->hasStages()) { 150 | continue; 151 | } 152 | 153 | if ($r->isLiveVersion()) { 154 | $deleted += $r->doVersionCleanup(); 155 | } 156 | } 157 | 158 | if ($deleted > 0) { 159 | $output->writeln("Deleted {$deleted} versioned {$class} records"); 160 | 161 | $total += $deleted; 162 | } 163 | } 164 | 165 | $output->writeln("Completed, pruned {$total} records"); 166 | } 167 | 168 | /** 169 | * Prune versions of deleted files/folders 170 | * 171 | * @return void 172 | */ 173 | private function pruneDeletedFileVersions(PolyOutput $output) 174 | { 175 | $output->writeln('Pruning all deleted File DataObjects'); 176 | 177 | $query = new SQLSelect(); 178 | $query->setSelect(['RecordID']); 179 | $query->setFrom('File_Versions'); 180 | $query->addWhere( 181 | [ 182 | '"WasDeleted" = ?' => 1, 183 | ] 184 | ); 185 | 186 | $to_delete = []; 187 | 188 | $results = $query->execute(); 189 | 190 | foreach ($results as $result) { 191 | array_push($to_delete, $result['RecordID']); 192 | } 193 | 194 | if (!count($to_delete)) { 195 | $output->writeln('Completed, pruned 0 File records'); 196 | 197 | return; 198 | } 199 | 200 | $deleteSQL = sprintf( 201 | 'DELETE FROM File_Versions WHERE "RecordID" IN (%s)', 202 | implode(',', $to_delete) 203 | ); 204 | 205 | DB::query($deleteSQL); 206 | 207 | $deleted = DB::affected_rows(); 208 | 209 | $output->writeln("Completed, pruned {$deleted} File records"); 210 | } 211 | 212 | /** 213 | * Delete all previous records of published records 214 | * 215 | * @return void 216 | */ 217 | private function reset(PolyOutput $output) 218 | { 219 | $output->writeln('Pruning all published records'); 220 | 221 | $classes = $this->getAllVersionedDataClasses(); 222 | 223 | $total = 0; 224 | 225 | foreach ($classes as $class) { 226 | $records = Versioned::get_by_stage($class, Versioned::DRAFT); 227 | $deleted = 0; 228 | 229 | // set to minimum 230 | $class::config()->set('keep_versions', 1); 231 | $class::config()->set('keep_drafts', 0); 232 | $class::config()->set('keep_redirects', false); 233 | 234 | foreach ($records as $r) { 235 | if ($r->isLiveVersion()) { 236 | $deleted += $r->doVersionCleanup(); 237 | } 238 | } 239 | 240 | if ($deleted > 0) { 241 | $output->writeln("Deleted {$deleted} versioned {$class} records"); 242 | $total += $deleted; 243 | } 244 | } 245 | 246 | $output->writeln("Completed, pruned {$total} records"); 247 | 248 | $this->pruneDeletedFileVersions($output); 249 | } 250 | 251 | /** 252 | * Delete All Archived DataObjects 253 | * 254 | * @return void 255 | */ 256 | private function deleteArchivedDataObjects(PolyOutput $output) 257 | { 258 | $total = 0; 259 | 260 | $output->writeln('Deleting all archived DataObjects'); 261 | 262 | $classes = $this->getAllVersionedDataClasses(); 263 | 264 | foreach ($classes as $class) { 265 | $singleton = singleton($class); 266 | $list = $singleton->get(); 267 | $baseTable = $singleton->baseTable(); 268 | 269 | $list = $list->setDataQueryParam('Versioned.mode', 'latest_versions'); 270 | 271 | $draftTable = $baseTable . '_Draft'; 272 | $list = $list 273 | ->leftJoin( 274 | $draftTable, 275 | "\"{$baseTable}\".\"ID\" = \"{$draftTable}\".\"ID\"" 276 | ); 277 | 278 | if ($singleton->hasStages()) { 279 | $liveTable = $baseTable . '_Live'; 280 | $list = $list->leftJoin( 281 | $liveTable, 282 | "\"{$baseTable}\".\"ID\" = \"{$liveTable}\".\"ID\"" 283 | ); 284 | } 285 | 286 | $list = $list->where("\"{$draftTable}\".\"ID\" IS NULL"); 287 | 288 | $deleted = 0; 289 | 290 | foreach ($list as $rec) { 291 | $deleteSQL = sprintf( 292 | 'DELETE FROM "%s_Versions" 293 | WHERE "RecordID" = %s', 294 | $baseTable, 295 | $rec->ID 296 | ); 297 | DB::query($deleteSQL); 298 | 299 | ++$deleted; 300 | } 301 | 302 | if ($deleted > 0) { 303 | $output->writeln("Deleted {$deleted} archived {$class} records"); 304 | $total += $deleted; 305 | } 306 | } 307 | 308 | $output->writeln("Completed, deleted {$total} archived DataObjects"); 309 | } 310 | 311 | /** 312 | * Get all versioned database classes 313 | * 314 | * @return array 315 | */ 316 | private function getAllVersionedDataClasses() 317 | { 318 | $all_classes = ClassInfo::subclassesFor(DataObject::class); 319 | $versioned_classes = []; 320 | foreach ($all_classes as $c) { 321 | if (DataObject::has_extension($c, Versioned::class)) { 322 | array_push($versioned_classes, $c); 323 | } 324 | } 325 | 326 | return array_reverse($versioned_classes); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/VersionTruncator.php: -------------------------------------------------------------------------------- 1 | config('keep_versions')) { 30 | // skip this DataObject 31 | return; 32 | } 33 | 34 | $oldMode = Versioned::get_reading_mode(); 35 | if ('Stage.Stage' != $oldMode) { 36 | Versioned::set_reading_mode('Stage.Stage'); 37 | } 38 | 39 | $has_stages = $this->owner->hasStages(); 40 | if ($has_stages) { 41 | $this->doVersionCleanup(); 42 | } 43 | 44 | if ('Stage.Stage' != $oldMode) { 45 | Versioned::set_reading_mode($oldMode); 46 | } 47 | } 48 | 49 | /** 50 | * Version cleanup 51 | * 52 | * @return int 53 | */ 54 | public function doVersionCleanup() 55 | { 56 | // array of version IDs to delete 57 | $to_delete = []; 58 | 59 | // Base table has Versioned data 60 | $baseTable = $this->owner->baseTable(); 61 | 62 | $total_deleted = 0; 63 | 64 | $keep_versions = $this->config('keep_versions'); 65 | if (is_int($keep_versions) && $keep_versions > 0) { 66 | $query = new SQLSelect(); 67 | $query->setSelect(['ID', 'Version', 'LastEdited']); 68 | $query->setFrom($baseTable . '_Versions'); 69 | $query->addWhere( 70 | [ 71 | '"RecordID" = ?' => $this->owner->ID, 72 | '"WasPublished" = ?' => 1, 73 | ] 74 | ); 75 | if ('SiteTree' == $baseTable && $this->config('keep_redirects')) { 76 | $query->addWhere( 77 | [ 78 | '"URLSegment" = ?' => $this->owner->URLSegment, 79 | '"ParentID" = ?' => $this->owner->ParentID, 80 | ] 81 | ); 82 | } 83 | $query->setOrderBy('LastEdited DESC, ID DESC'); 84 | $query->setLimit(100, $keep_versions); 85 | 86 | $results = $query->execute(); 87 | 88 | foreach ($results as $result) { 89 | array_push($to_delete, $result['Version']); 90 | } 91 | 92 | if ('SiteTree' == $baseTable 93 | && $this->config('keep_redirects') 94 | ) { 95 | // Get the most recent Version IDs of all published pages to ensure 96 | // we leave at least X versions even if a URLSegment or ParentID 97 | // has changed. 98 | $query = new SQLSelect(); 99 | $query->setSelect( 100 | ['Version', 'LastEdited'] 101 | ); 102 | $query->setFrom($baseTable . '_Versions'); 103 | $query->addWhere( 104 | [ 105 | '"RecordID" = ?' => $this->owner->ID, 106 | '"WasPublished" = ?' => 1, 107 | ] 108 | ); 109 | $query->setOrderBy('LastEdited DESC'); 110 | $query->setLimit($keep_versions, 0); 111 | 112 | $results = $query->execute(); 113 | 114 | $to_keep = []; 115 | foreach ($results as $result) { 116 | array_push($to_keep, $result['Version']); 117 | } 118 | 119 | // only keep a single historical record of moved/renamed 120 | // unless they within the `keep_versions` range 121 | $query = new SQLSelect(); 122 | $query->setSelect( 123 | ['Version', 'LastEdited', 'URLSegment', 'ParentID'] 124 | ); 125 | $query->setFrom($baseTable . '_Versions'); 126 | $query->addWhere( 127 | [ 128 | '"RecordID" = ?' => $this->owner->ID, 129 | '"WasPublished" = ?' => 1, 130 | '"Version" NOT IN (' . implode(',', $to_keep) . ')', 131 | '"URLSegment" != ? OR "ParentID" != ?' => [ 132 | $this->owner->URLSegment, 133 | $this->owner->ParentID, 134 | ], 135 | ] 136 | ); 137 | $query->setOrderBy('LastEdited DESC'); 138 | 139 | $results = $query->execute(); 140 | 141 | $moved_pages = []; 142 | 143 | // create a `ParentID - $URLSegment` array to keep only a single 144 | // version of each for URL redirection 145 | foreach ($results as $result) { 146 | $key = $result['ParentID'] . ' - ' . $result['URLSegment']; 147 | 148 | if (in_array($key, $moved_pages)) { 149 | array_push($to_delete, $result['Version']); 150 | } else { 151 | array_push($moved_pages, $key); 152 | } 153 | } 154 | } 155 | } 156 | 157 | $keep_drafts = $this->config('keep_drafts'); 158 | 159 | // remove drafts keeping `keep_drafts` 160 | if (is_int($keep_drafts)) { 161 | $query = new SQLSelect(); 162 | $query->setSelect(['ID', 'Version', 'LastEdited']); 163 | $query->setFrom($baseTable . '_Versions'); 164 | $query->addWhere( 165 | 'RecordID = ' . $this->owner->ID, 166 | 'WasPublished = 0' 167 | ); 168 | $query->setOrderBy('LastEdited DESC, ID DESC'); 169 | $query->setLimit(100, $this->config('keep_drafts')); 170 | 171 | $results = $query->execute(); 172 | 173 | foreach ($results as $result) { 174 | array_push($to_delete, $result['Version']); 175 | } 176 | } 177 | 178 | if (!count($to_delete)) { 179 | return; 180 | } 181 | 182 | // Ugly (borrowed from DataObject::class), but returns all 183 | // database tables relating to DataObject 184 | $srcQuery = DataList::create($this->owner->ClassName) 185 | ->filter('ID', $this->owner->ID) 186 | ->dataQuery() 187 | ->query(); 188 | $queriedTables = $srcQuery->queriedTables(); 189 | 190 | foreach ($queriedTables as $table) { 191 | $delSQL = sprintf( 192 | 'DELETE FROM "%s_Versions" 193 | WHERE "Version" IN (%s) 194 | AND "RecordID" = %d', 195 | $table, 196 | implode(',', $to_delete), 197 | $this->owner->ID 198 | ); 199 | 200 | DB::query($delSQL); 201 | 202 | $total_deleted += DB::affected_rows(); 203 | } 204 | 205 | return $total_deleted; 206 | } 207 | 208 | /** 209 | * Return a config variable 210 | * 211 | * @param string $key Config key 212 | * 213 | * @return mixed 214 | */ 215 | private function config(string $key) 216 | { 217 | if (!$this->conf) { 218 | $this->conf = Config::inst(); 219 | } 220 | 221 | return $this->conf->get( 222 | $this->owner->ClassName, 223 | $key 224 | ); 225 | } 226 | } 227 | --------------------------------------------------------------------------------