├── .gitignore ├── src ├── Exceptions │ └── TemporalException.php ├── Scopes │ └── TemporalScope.php ├── Migration.php └── Temporal.php ├── circle.yml ├── composer.json ├── phpunit.xml.dist ├── LICENSE.md ├── reference.md ├── README.md └── tests └── TemporalTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | vendor 4 | docs 5 | docsmd 6 | .idea 7 | ._DS_STORE -------------------------------------------------------------------------------- /src/Exceptions/TemporalException.php: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | > Permission is hereby granted, free of charge, to any person obtaining a copy 4 | > of this software and associated documentation files (the "Software"), to deal 5 | > in the Software without restriction, including without limitation the rights 6 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | > copies of the Software, and to permit persons to whom the Software is 8 | > furnished to do so, subject to the following conditions: 9 | > 10 | > The above copyright notice and this permission notice shall be included in 11 | > all copies or substantial portions of the Software. 12 | > 13 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | > THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/Scopes/TemporalScope.php: -------------------------------------------------------------------------------- 1 | where($model->getTemporalEndColumn(), $model->getTemporalMax()); 28 | } 29 | 30 | public function extend(Builder $builder) 31 | { 32 | foreach ($this->extensions as $extension) { 33 | $this->{"add{$extension}"}($builder); 34 | } 35 | 36 | $builder->onDelete(function (Builder $builder) { 37 | $model = $builder->getModel(); 38 | $column = $model->getTemporalEndColumn(); 39 | return $builder->update([ 40 | $column => $model->freshTimestampString(), 41 | ]); 42 | }); 43 | } 44 | 45 | /** 46 | * The currentVersions builder method will constrain the query to revisions that are currently active. This is normally applied automatically as a global scope, so this method is not usually necessary. 47 | */ 48 | protected function addCurrentVersions(Builder $builder) 49 | { 50 | $builder->macro('currentVersions', function (Builder $builder) 51 | { 52 | $model = $builder->getModel(); 53 | 54 | $builder->withoutGlobalScope($this) 55 | ->where($model->getTemporalEndColumn(), $model->getTemporalMax()); 56 | 57 | return $builder; 58 | }); 59 | } 60 | 61 | /** 62 | * The allVersions builder method will remove the constraint that normally causes just the currently active versions to be returned. 63 | */ 64 | protected function addAllVersions(Builder $builder) 65 | { 66 | $builder->macro('allVersions', function (Builder $builder) 67 | { 68 | return $builder->withoutGlobalScope($this); 69 | }); 70 | } 71 | 72 | /** 73 | * The firstVersions builder method will constrain the query to original revisions. 74 | */ 75 | protected function addFirstVersions(Builder $builder) 76 | { 77 | $builder->macro('firstVersions', function (Builder $builder) 78 | { 79 | $model = $builder->getModel(); 80 | 81 | $builder->withoutGlobalScope($this) 82 | ->where($model->getVersionColumn(), 1); 83 | 84 | return $builder; 85 | }); 86 | } 87 | 88 | /** 89 | * The versionsAt builder method will constrain the query to revisions with the specified version number. 90 | */ 91 | protected function addVersionsAt(Builder $builder) 92 | { 93 | $builder->macro('versionsAt', function (Builder $builder, $version = 1) 94 | { 95 | $model = $builder->getModel(); 96 | 97 | $builder->withoutGlobalScope($this) 98 | ->where($model->getVersionColumn(), $version); 99 | 100 | return $builder; 101 | }); 102 | } 103 | 104 | /** 105 | * The versionsAtDate builder method will constrain the query to revisions that were active during the specified date. 106 | */ 107 | protected function addVersionsAtDate(Builder $builder) 108 | { 109 | $builder->macro('versionsAtDate', function (Builder $builder, $datetime = null) 110 | { 111 | $datetime = new Carbon($datetime); 112 | 113 | $model = $builder->getModel(); 114 | 115 | $builder->withoutGlobalScope($this) 116 | ->where($model->getTemporalStartColumn(), '<=', $datetime) 117 | ->where($model->getTemporalEndColumn(), '>', $datetime); 118 | 119 | return $builder; 120 | }); 121 | } 122 | 123 | /** 124 | * The versionsInRange builder method will constrain the query to revisions that were active at some point in the specified date range. 125 | * You can specify null as the $from or $to date to get all revisions in that direction of time. 126 | */ 127 | protected function addVersionsInRange(Builder $builder) 128 | { 129 | $builder->macro('versionsInRange', function (Builder $builder, $from = null, $to = null) 130 | { 131 | if ($from !== null) $from = new Carbon($from); 132 | if ($to !== null) $to = new Carbon($to); 133 | 134 | $model = $builder->getModel(); 135 | 136 | $builder->withoutGlobalScope($this); 137 | if ($from) $builder->where($model->getTemporalEndColumn(), '>=', $from); 138 | if ($to) $builder->where($model->getTemporalStartColumn(), '<', $to); 139 | 140 | return $builder; 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Migration.php: -------------------------------------------------------------------------------- 1 | increments(\'id\').'); 26 | 27 | //remove the primary key(s) and auto-increment in one statement... 28 | $alter_commands = array(); 29 | $alter_commands[] = "DROP PRIMARY KEY"; 30 | foreach($primary_keys as $primary_key=>$info) 31 | { 32 | if ($info['auto_increment']) 33 | $alter_commands[] = "MODIFY `$primary_key` {$info['type']} NOT NULL"; 34 | } 35 | DB::statement("ALTER TABLE `$tablename` " . implode(', ', $alter_commands) . ';'); 36 | 37 | //add the temporal columns and specify new primary keys... 38 | Schema::table($tablename, function ($table) use($primary_keys, $version, $temporal_start, $temporal_end, $temporal_max) { 39 | $keys = array_keys($primary_keys); 40 | $table->unsignedMediumInteger($version)->comment('The version number')->default(0)->after(end($keys)); 41 | $table->dateTime($temporal_start)->comment('When the revision begins')->default(DB::raw('NOW()'))->after($version); 42 | $table->dateTime($temporal_end)->comment('When the revision ends')->default($temporal_max)->after($temporal_start); 43 | $table->primary(array_merge($keys, array($version))); 44 | $table->index(array_merge(array($temporal_end), $keys), 'current_version'); 45 | $table->index(array_merge($keys, array($temporal_start, $temporal_end)), 'version_at_time'); 46 | }); 47 | 48 | //add auto-increments back... 49 | $alter_commands = array(); 50 | foreach($primary_keys as $primary_key=>$info) 51 | { 52 | if ($info['auto_increment']) 53 | $alter_commands[] = "MODIFY `$primary_key` {$info['type']} NOT NULL AUTO_INCREMENT"; 54 | } 55 | DB::statement("ALTER TABLE `$tablename` " . implode(', ', $alter_commands) . ';'); 56 | } 57 | 58 | /** 59 | * Figures out which columns in the specified table are primary keys, and returns information about them. 60 | * 61 | * @param string $tablename The table to retrieve the primary keys from 62 | * @throws TemporalException If the primary key(s) cannot be retrieved (possibly due to a non-MySQL database driver) 63 | * @return array An associative array of the table's primary keys, with the key being the name of the column, and the value being an array of information about the column. 64 | */ 65 | private static function get_primary_keys($tablename) 66 | { 67 | //determine if we have an auto-incrementing field... 68 | $primary_keys = array(); 69 | $columns = DB::select("SHOW COLUMNS FROM `$tablename`;"); 70 | if (count($columns)) 71 | { 72 | foreach($columns as $column=>$info) 73 | { 74 | $data = array(); 75 | foreach($info as $key=>$val) 76 | $data[strtolower($key)] = $val; 77 | 78 | if (strpos(strtolower($data['key']), 'pri') !== false) 79 | { 80 | $primary_keys[$data['field']] = array( 81 | 'type'=>$data['type'], 82 | 'auto_increment'=>(strpos(strtolower($data['extra']), 'auto_increment') !== false) 83 | ); 84 | } 85 | } 86 | 87 | return $primary_keys; 88 | } 89 | else 90 | throw new TemporalException('Could not get info about the table to base modifications on. Maybe this isn\'t a MySQL database?'); 91 | } 92 | } -------------------------------------------------------------------------------- /reference.md: -------------------------------------------------------------------------------- 1 | # Temporal Trait 2 | 3 | Properties 4 | ---------- 5 | 6 | 7 | ### $temporal_max 8 | 9 | protected string $temporal_max = '2999-01-01 00:00:00' 10 | 11 | The datetime to use for the end of the currently active version 12 | 13 | 14 | 15 | ### $version_column 16 | 17 | protected string $version_column = 'version' 18 | 19 | The integer column used to represent the version number of a revision 20 | 21 | 22 | 23 | ### $temporal_start_column 24 | 25 | protected string $temporal_start_column = 'temporal_start' 26 | 27 | The datetime column used to represent the beginning of a version 28 | 29 | 30 | 31 | ### $temporal_end_column 32 | 33 | protected string $temporal_end_column = 'temporal_end' 34 | 35 | The datetime column used to represent the end of a version 36 | 37 | 38 | 39 | Methods 40 | ------- 41 | 42 | ### delete 43 | 44 | Soft Deletes the record by updating the temporal_end to the current datetime--making it invalid in the present 45 | 46 | 47 | 48 | 49 | 50 | ### save 51 | 52 | boolean save(array $options) 53 | 54 | Save the model to the database. 55 | 56 | If the record already exists, instead of updating it, we will update the temporal_end column and insert a new revision. 57 | New revisions automatically have their temporal_start set to the current time, and temporal_end set to the max timestamp. 58 | 59 | 60 | 61 | ### overwrite 62 | 63 | boolean overwrite() 64 | 65 | Updates the record without inserting a new revision--overwriting the current revision. 66 | 67 | Fires overwriting and overwritten events. Does not fire update or save events. 68 | 69 | 70 | 71 | 72 | 73 | ### restore 74 | 75 | boolean restore() 76 | 77 | Restores the deleted revision. If this revision wasn't deleted (there is presently an active revision), nothing happens. 78 | 79 | Fires restoring and restored events. Does not fire update or save events. 80 | 81 | 82 | 83 | 84 | 85 | ### purge 86 | 87 | boolean purge() 88 | 89 | Permanently removes all versions of this record from the database--destroying all of the data. THIS CANNOT BE UNDONE! 90 | Fires purging and purged events. Does not fire update, save, or delete events. 91 | 92 | 93 | 94 | 95 | 96 | ### previousVersion 97 | 98 | previousVersion() 99 | 100 | Returns the previous version of the record, or null if there is no previous version. 101 | 102 | 103 | 104 | 105 | 106 | ### nextVersion 107 | 108 | nextVersion() 109 | 110 | Returns the next version of the record, or null if there is no next version. 111 | 112 | 113 | 114 | 115 | 116 | ### firstVersion 117 | 118 | firstVersion() 119 | 120 | Returns the first version of the record, or null if there is no first version. 121 | 122 | 123 | 124 | 125 | 126 | ### atVersion 127 | 128 | atVersion($version) 129 | 130 | Returns the specified version of the record, or null if the specified version does not exist. 131 | 132 | 133 | 134 | #### Arguments 135 | * $version **int** The version number to retrieve 136 | 137 | 138 | 139 | ### currentVersion 140 | 141 | currentVersion() 142 | 143 | Returns the current version of the record, or null if the record has been deleted. 144 | 145 | 146 | 147 | 148 | 149 | ### latestVersion 150 | 151 | latestVersion() 152 | 153 | Returns the latest version of the record, or null if there is no latest version. Note that this may NOT be the currently active version, if the record was deleted. 154 | 155 | 156 | 157 | 158 | 159 | ### atDate 160 | 161 | atDate(Carbon|string $datetime) 162 | 163 | Returns the record as it existed on the specified date, or null if the record did not exist then. 164 | 165 | If multiple versions of the record happen to exist at the same second, only the newest one will be returned. 166 | 167 | 168 | 169 | #### Arguments 170 | * $datetime **Carbon|string** - The date/time you want to search for a revision at. Can be a Carbon instance, or a datetime string that your database recognizes. 171 | 172 | 173 | 174 | ### inRange 175 | 176 | inRange(Carbon|string $from = null, Carbon|string $to = null) 177 | 178 | Returns an array of any versions of the record that existed at some point during the specified date range, or an empty array if the record did not exist at all during the date range. 179 | 180 | The array is sorted from oldest to newest. 181 | 182 | 183 | 184 | #### Arguments 185 | * $from **Carbon|string** - If specified, only versions that existed on or after this date/time will be returned 186 | * $to **Carbon|string** - If specified, only versions that existed before this date/time will be returned 187 | 188 | 189 | 190 | ### currentVersions 191 | 192 | \Illuminate\Database\Eloquent\Builder currentVersions() 193 | 194 | Starts a query constrained to only the current versions. This is accomplished in normal queries automatically, as the global temporal scope applies the same constraint. Feel free to use this method instead if you want, though. 195 | 196 | * This method is **static**. 197 | 198 | 199 | 200 | # Query Builder Extensions 201 | 202 | 203 | 204 | 205 | ### currentVersions 206 | 207 | currentVersions() 208 | 209 | 210 | The currentVersions builder method will constrain the query to revisions that are currently active. This is normally applied automatically as a global scope, so this method is not usually necessary. 211 | ##### 212 | 213 | 214 | 215 | ### allVersions 216 | 217 | allVersions() 218 | 219 | The allVersions builder method will remove the constraint that normally causes just the currently active versions to be returned. 220 | 221 | 222 | 223 | ### firstVersions 224 | 225 | firstVersions() 226 | 227 | The firstVersions builder method will constrain the query to original revisions. 228 | 229 | 230 | 231 | ### versionsAt 232 | 233 | versionsAt($version = 1) 234 | 235 | The versionsAt builder method will constrain the query to revisions with the specified version number. 236 | 237 | #### Arguments 238 | * $version **int** The version number to retrieve 239 | 240 | 241 | 242 | ### versionsAtDate 243 | 244 | versionsAtDate($datetime = null) 245 | 246 | The versionsAtDate builder method will constrain the query to revisions that were active during the specified date. 247 | ##### 248 | 249 | #### Arguments 250 | * $datetime **Carbon|string** - The date/time you want to search for revisions at. Can be a Carbon instance, or a datetime string that your database recognizes. 251 | 252 | 253 | 254 | ### versionsInRange 255 | 256 | versionsInRange(Carbon|string $from = null, Carbon|string $to = null) 257 | 258 | The versionsInRange builder method will constrain the query to revisions that were active at some point in the specified date range. 259 | 260 | You can specify null as the $from or $to date to get all revisions in that direction of time. 261 | 262 | 263 | #### Arguments 264 | * $from **Carbon|string** - If specified, only versions that existed on or after this date/time will be returned 265 | * $to **Carbon|string** - If specified, only versions that existed before this date/time will be returned -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Temporal Models 2 | ### Temporal models and versioning for Laravel 3 | 4 | You know what's crazy? Database updates. You update a record and BAM! The previous version of the record is overwritten. What was it like before you updated it? No one knows. The data has been lost forever. More like database overwrites, amirite? 5 | 6 | If you're not in the business of losing data forever, you should give temporal models a try! It's like version control for your database records. Now when you update something, the previous version of it is kept intact, and a new revision is inserted instead. 7 | 8 | Normally you're only going to care about the current versions of things, so by default that's all you'll get when querying. But the old versions are always there too if you want to get at them. Now when someone asks "what was this thing like before the latest change?" or "what did this thing dress as last Halloween?" or "did this thing always have a tail?", you'll have all the answers. 9 | 10 | ## Requirements 11 | 12 | - This has been unit tested, but only on Laravel 5.4 with PHP 7.1, Laravel 6.0 with PHP 7.2 and Laravel 8.0 with PHP 8.0.3. Let me know if you find it works on older versions! 13 | - Also only tested with MySQL/MariaDB. Likely will not work with SQLite, but let me know if you find it works with other databases! 14 | 15 | ## Installation 16 | 17 | Install via Composer... 18 | 19 | For Laravel 5 use the version 1.1 20 | ```bash 21 | composer require gazugafan/laravel-temporal:1.1 22 | ``` 23 | 24 | For Laravel 6.0 use the version 2.0 25 | ```bash 26 | composer require gazugafan/laravel-temporal:2.0 27 | ``` 28 | 29 | For Laravel 7.0 use the version 3.0 30 | ```bash 31 | composer require gazugafan/laravel-temporal:3.0 32 | ``` 33 | 34 | For Laravel 8.0 use the version 4.0 35 | ```bash 36 | composer require gazugafan/laravel-temporal:4.0 37 | ``` 38 | 39 | For Laravel 9.0 use the version 5.0.1 40 | ```bash 41 | composer require gazugafan/laravel-temporal:5.0.1 42 | ``` 43 | 44 | ## Overview 45 | 46 | Temporal models get three new fields... 47 | - ```version``` represents the version number of a record (1 would be the original version, 2 would be the second version, etc.). Versions always start at 1, and will never have a gap. 48 | - ```temporal_start``` and ```temporal_end``` represent the range of time a version is/was active. If a revision is currently active, temporal_end will automatically be set VERY far into the future. 49 | 50 | Whenever you save or update a model, the previous version's ```temporal_end``` is updated to mark the end of its lifespan, and a new version is inserted with an incremented ```version```. 51 | 52 | When querying for temporal models, we automatically constrain the query so that only current versions are returned. Getting at old revisions is also possible using added methods. 53 | 54 | When paired with [laravel-changelog](https://github.com/gazugafan/laravel-changelog), this will give you the history of every change made to a record, including who made each change and exactly what was changed. 55 | 56 | 57 | ## Schema Migration 58 | 59 | You'll need to modify your table's schema a bit. The bad news is that Laravel doesn't really "support" the modifications we need to make, so we have to resort to some ugly workarounds. The good news is that I made a helper class that handles all the dirty work for you! 60 | ```php 61 | //create your table like normal... 62 | Schema::create('widgets', function ($table) { 63 | $table->increments('id'); 64 | $table->timestamps(); 65 | $table->string('name'); 66 | }); 67 | 68 | //add the columns and keys needed for the temporal features... 69 | Gazugafan\Temporal\Migration::make_temporal('widgets'); 70 | ``` 71 | What's actually going on here... 72 | > The actual changes necessary are: 73 | > - Add an unsigned integer ```version``` column as an additional primary key. 74 | > - Add datetime ```temporal_start``` and ```temporal_end``` columns. 75 | > - Add indexes to make sure queries stay just as fast. I'd recommend two additional indexes... 76 | > - ```(temporal_end, id)``` for getting current revisions (which we do all the time) 77 | > - ```(id, temporal_start, temporal_end)``` for getting revisions at a certain date/time 78 | > 79 | > Laravel doesn't have an easy mechanism for specifying multiple primary keys along with an auto-increment key. To work around this, ```Migration::make_temporal``` does some raw MySQL commands. This most likely will NOT work on non-MySQL databases like SQLite. 80 | 81 | ## Model Setup 82 | 83 | To make your model temporal, just add the ```Gazugafan\Temporal\Temporal``` trait to the model's class... 84 | ```php 85 | class Widget extends Model 86 | { 87 | use Temporal; //add all the temporal features 88 | 89 | protected $dates = ['temporal_start', 'temporal_end']; //if you want these auto-cast to Carbon, go for it! 90 | } 91 | ``` 92 | 93 | You can also customize the column names and maximum temporal timestamp, if you'd like to change them from the defaults... 94 | ```php 95 | class Widget extends Model 96 | { 97 | use Temporal; //add all the temporal features 98 | 99 | protected $version_column = 'version'; 100 | protected $temporal_start_column = 'temporal_start'; 101 | protected $temporal_end_column = 'temporal_end'; 102 | protected $temporal_max = '2999-01-01 00:00:00'; 103 | protected $overwritable = ['worthless_column']; //columns that can simply be overwritten if only they are being updated 104 | } 105 | ``` 106 | 107 | ## Usage 108 | 109 | ###### Saving temporal models 110 | When you first save a new record, it is inserted into the database with a ```version``` of 1, a ```temporal_start``` of the current date/time, and a ```temporal_end``` of WAY in the future ('2999-01-01' by default). Thus, this first revision is currently active from now to forever. 111 | 112 | The next time you save this record, the previous revision's ```temporal_end``` is automatically updated to the current date/time--marking the end of that version's lifespan. The new revision is then inserted into the database with its ```version``` incremented by 1, and its ```temporal_start``` and ```temporal_end``` updated as before. Thus, the previous version is now inactive, and the new version is currently active. 113 | ```php 114 | $widget = new Widget(); 115 | $widget->name = 'Cawg'; 116 | $widget->save(); //inserts a new widget at version 1 with temporal_start=now and temporal_end='2999-01-01' 117 | 118 | $widget->name = 'Cog'; 119 | $widget->save(); //ends the lifespan of version 1 by updating its temporal_end=now, and inserts a new revision with version=2 120 | 121 | $anotherWidget = Widget::create(['name'=>'Other Cog']); //another way to insert a new widget at version 1 122 | ``` 123 | 124 | You can only save the newest version of a record. If you find an old version of something and try to modify it, an error will be thrown... you can't change the past. 125 | 126 | If you want to overwrite the latest revision, use ```overwrite()``` instead of ```save()```. This will simply update the revision instead of inserting a new one. It totally defeats the purpose of using temporal models, but it can be useful for making frequent tiny changes. Like incrementing/decrementing something on a set schedule, or updating a cached calculation. 127 | 128 | You can also automatically overwrite whenever you perform a ```save()``` if only columns you've defined as ```overwritable``` have been changed. Just add an ```$overwritable``` array property to your model like this: ```protected $overwritable = ['worthless_column', 'cached_value'];``` and we'll automatically perform an overwrite (instead of inserting a new version) when only those columns are changing. If you notice lots of unnecessary versions in your table just because of one or two columns changing that you don't care about the history of, add those columns here! 129 | 130 | 131 | ###### Retrieving temporal models 132 | By default, all temporal model queries will be constrained so that only current versions are returned. 133 | ```php 134 | $widget123 = Widget::find(123); //gets the current version of widget #123. 135 | $blueWidgets = Widget::where('color', 'blue')->get(); //gets the current version of all the blue widgets 136 | ``` 137 | 138 | If you want to get all versions for some reason, you can use the ```allVersions()``` method to remove the global scope... 139 | ```php 140 | $widget123s = Widget::where('id', 123)->allVersions()->get(); //gets all versions of widget #123 141 | $blueWidgets = Widget::allVersions()->where('color', 'blue')->get(); //gets all versions of all blue widgets 142 | ``` 143 | 144 | You can also get specific versions, and traverse through versions of a certain record... 145 | ```php 146 | $widget = Widget::find(123); //gets the current version of widget #123, like normal 147 | $firstWidget = $widget->firstVersion(); //gets the first version of widget #123 148 | $secondWidget = $firstWidget->nextVersion(); //gets version 2 of widget #123 149 | $fifthWidget = $widget->atVersion(5); //gets version 5 of widget #123 150 | $fourthWidget = $fifthWidget->previousVersion(); //gets version 4 of widget #123 151 | $latestWidget = $widget->latestVersion(); //gets the latest version of widget #123 (not necessarily the current version if it was deleted) 152 | $yesterdaysWidget = $widget->atDate(Carbon::now()->subDays(1)); //get widget #123 as it existed at this time yesterday 153 | $januaryWidgets = $widget->inRange('2017-01-01', '2017-02-01'); //get all versions of widget #123 that were active at some point in Jan. 2017 154 | ``` 155 | 156 | And there are similar query builder methods... 157 | ```php 158 | $firstWidgets = Widget::where('id', 123)->firstVersions()->get(); //gets the first version of widget #123 (in a collection with 1 element) 159 | $firstWidget = $firstWidgets->first(); //since this is a collection, you can use first() to get the first (and in this case only) element 160 | $secondBlueWidgets = Widget::versionsAt(2)->where('color', 'blue')->get(); //gets the second version of all blue widgets 161 | $noonWidgets = Widget::versionsAtDate('2017-01-01 12:00:00')->get(); //gets all widgets as they were at noon on Jan. 1st 2017 162 | 163 | //get all versions of widgets that were red and active at some point last week... 164 | $lastWeeksRedWidgets = Widget::where('color', 'red')->versionsInRange(Carbon::now()->subWeeks(2), Carbon::now()->subWeeks(1))->get(); 165 | ``` 166 | 167 | ###### Deleting temporal models 168 | When you call ```delete()``` on a temporal model, we don't actually DELETE anything. We just set its ```temporal_end``` to now--thereby marking the end of that revision's lifespan. And, without a current revision inserted to follow it, the record is effectively non-existant in the present. So, querying for it like normal won't get you anything. 169 | ```php 170 | $widget = Widget::create(['name'=>'cog']); //create a new widget like normal 171 | $widgetID = $widget->id; //get the widget's ID 172 | $widget->delete(); //nothing is really DELETEd from the database. We just update temporal_end to now 173 | $deletedWidget = Widget::find($widgetID); //returns null because the record no longer has a current version 174 | ``` 175 | 176 | It's like you get SoftDelete functionality for free! You can even restore deleted records... 177 | ```php 178 | $widget->restore(); //would restore the deleted record from the example above 179 | ``` 180 | 181 | Keep in mind that you can only delete/restore the current/latest version of a record. If you really want to permanently remove a record from the database, you can use ```purge()```. This DELETEs every version of the record, and cannot be undone. 182 | ```php 183 | $widget->purge(); //that widget's gone for good... like it never even existed in the first place. 184 | ``` 185 | 186 | ## Reference 187 | 188 | [Check out the full documentation here](reference.md) 189 | 190 | ## Pitfalls 191 | - You cannot change the past. Attempting to save or delete anything but the current version of a record will result in an error. Attempting to restore anything but the latest version of a deleted record will result in an error. 192 | - Mass-updating will not respect the temporal model features, and unfortunately I don't know how to throw an error if you try. If you attempt something like the following, don't expect it to insert new revisions... 193 | ```php 194 | //don't even try this unless you want to totally screw 195 | //things up (or you really know what you're doing)... 196 | App\Widget::where('active', 1)->update(['description'=>'An active widget']); 197 | ``` 198 | - Saving changes to two copies of the same record will NOT cause the second save to update the first copy's record. However, because of our composite primary key (which includes ```version```, attempting this will simply throw an error... 199 | ```php 200 | $copyA = Widget::find(1); 201 | $copyB = Widget::find(1); 202 | 203 | $copyA->name = 'new name A'; 204 | $copyA->save(); //updates the original revision and inserts a new revision 205 | 206 | $copyB->name = 'new name B'; 207 | $copyB->save(); //attempts to update the original revision again and throws an error 208 | ``` 209 | - When a revision is overwritten, its ```updated_at``` field is automatically updated. This means it's possible to have an ```updated_at``` field that does not match the revision's ```temporal_start```. The ```version``` field, however, is NOT updated (which ensures version numbers always start at 1 and never have gaps). 210 | - We can't automatically add the temporal restriction to queries outside of the query builder specific to the temporal Eloquent model. If you need to do some manual (non-ORM) queries, remember to add a WHERE clause to only get the latest versions (```WHERE temporal_end = '2999-01-01'```). 211 | - The ```unique``` validation method does NOT respect temporal models. So, it will consider ALL versions and fail even if old versions of a record have the same value. This should quickly become apparant if you make your User model temporal. If you want a tweaked ```unique``` validation method that works for temporal models, try something like this... 212 | ```php 213 | public function validateTemporalUnique($attribute, $value, $parameters) 214 | { 215 | $this->requireParameterCount(1, $parameters, 'unique'); 216 | 217 | list($connection, $table) = $this->parseTable($parameters[0]); 218 | 219 | // The second parameter position holds the name of the column that needs to 220 | // be verified as unique. If this parameter isn't specified we will just 221 | // assume that this column to be verified shares the attribute's name. 222 | $column = $this->getQueryColumn($parameters, $attribute); 223 | 224 | list($idColumn, $id) = [null, null]; 225 | 226 | if (isset($parameters[2])) { 227 | list($idColumn, $id) = $this->getUniqueIds($parameters); 228 | } 229 | 230 | // The presence verifier is responsible for counting rows within this store 231 | // mechanism which might be a relational database or any other permanent 232 | // data store like Redis, etc. We will use it to determine uniqueness. 233 | $verifier = $this->getPresenceVerifierFor($connection); 234 | 235 | $extra = $this->getUniqueExtra($parameters); 236 | 237 | if ($this->currentRule instanceof Unique) { 238 | $extra = array_merge($extra, $this->currentRule->queryCallbacks()); 239 | } 240 | 241 | //add the default temporal property... 242 | if (!array_key_exists('temporal_end', $extra)) 243 | $extra['temporal_end'] = '2999-01-01'; 244 | 245 | return $verifier->getCount( 246 | $table, $column, $value, $id, $idColumn, $extra 247 | ) == 0; 248 | } 249 | ``` 250 | 251 | ## Credits 252 | Inspired by [navjobs/temporal-models](https://github.com/navjobs/temporal-models) and [FuelPHP's temporal models](https://fuelphp.com/dev-docs/packages/orm/model/temporal.html) 253 | -------------------------------------------------------------------------------- /src/Temporal.php: -------------------------------------------------------------------------------- 1 | version_column)?$this->version_column:static::$_temporal_version_column; } 27 | public function getTemporalStartColumn() { return isset($this->temporal_start_column)?$this->temporal_start_column:static::$_temporal_temporal_start_column; } 28 | public function getTemporalEndColumn() { return isset($this->temporal_end_column)?$this->temporal_end_column:static::$_temporal_temporal_end_column; } 29 | public function getTemporalMax() { return isset($this->temporal_max)?$this->temporal_max:static::$_temporal_temporal_max; } 30 | public function getOverwritable() { return isset($this->overwritable)?$this->overwritable:static::$_overwritable; } 31 | 32 | 33 | /******************************************************************************** 34 | * Method Overrides 35 | ********************************************************************************/ 36 | 37 | /** 38 | * Add the global scope 39 | */ 40 | public static function bootTemporal() 41 | { 42 | static::addGlobalScope(new Scopes\TemporalScope); 43 | } 44 | 45 | /** 46 | * Soft Deletes the record by updating the temporal_end to the current datetime--making it invalid in the present 47 | */ 48 | public function performDeleteOnModel() 49 | { 50 | //we can only delete the current version... 51 | if ($this->{$this->getTemporalEndColumn()} != $this->getTemporalMax()) 52 | { 53 | //double-check that we don't just have to parse the dates... 54 | if (Carbon::parse($this->{$this->getTemporalEndColumn()})->notEqualTo(Carbon::parse($this->getTemporalMax()))) 55 | throw new TemporalException('You cannot delete past revisions--only the current version.'); 56 | } 57 | 58 | //get a solid cutoff timestamp... 59 | $cutoffTimestamp = $this->freshTimestamp(); 60 | 61 | $query = $this->newQueryWithoutScopes(); 62 | $this->setKeysForSaveQuery($query)->toBase()->update(array( 63 | $this->getTemporalEndColumn() => $cutoffTimestamp 64 | )); 65 | 66 | $this->{$this->getTemporalEndColumn()} = $cutoffTimestamp; 67 | $this->syncOriginal(); 68 | } 69 | 70 | /** 71 | * Save the model to the database. 72 | * If the record already exists, instead of updating it, we will update the temporal_end column and insert a new revision. 73 | * New revisions automatically have their temporal_start set to the current time, and temporal_end set to the max timestamp. 74 | * 75 | * @param array $options 76 | * @return bool 77 | */ 78 | public function save(array $options = []) 79 | { 80 | //we can only save the current version... 81 | if (isset($this->{$this->getTemporalEndColumn()}) && $this->{$this->getTemporalEndColumn()} != $this->getTemporalMax()) 82 | { 83 | //double-check that we don't just have to parse the dates... 84 | if (Carbon::parse($this->{$this->getTemporalEndColumn()})->notEqualTo(Carbon::parse($this->getTemporalMax()))) 85 | throw new TemporalException('You cannot save past revisions--only the current version.'); 86 | } 87 | 88 | $query = $this->newQueryWithoutScopes(); 89 | 90 | // If the "saving" event returns false we'll bail out of the save and return 91 | // false, indicating that the save failed. This provides a chance for any 92 | // listeners to cancel save operations if validations fail or whatever. 93 | if ($this->fireModelEvent('saving') === false) { 94 | return false; 95 | } 96 | 97 | // If the model already exists in the database we can just update our record 98 | // that is already in this database using the current IDs in this "where" 99 | // clause to only update this model. Otherwise, we'll just insert them. 100 | if ($this->exists) 101 | { 102 | //we only need to perform an "update" if the record has actually been changed... 103 | if ($this->isDirty()) 104 | { 105 | // If the updating event returns false, we will cancel the update operation so 106 | // developers can hook Validation systems into their models and cancel this 107 | // operation if the model does not pass validation. Otherwise, we update. 108 | if ($this->fireModelEvent('updating') === false) 109 | return false; 110 | 111 | //if only overwritable columns have changed, then just do an overwrite... 112 | $overwritableColumns = $this->getOverwritable(); 113 | $overwrite = true; 114 | $dirty = array_keys($this->getDirty()); 115 | foreach($dirty as $key) 116 | { 117 | if (!in_array($key, $overwritableColumns)) 118 | { 119 | $overwrite = false; 120 | break; 121 | } 122 | } 123 | 124 | if ($overwrite) 125 | { 126 | $this->overwrite(); 127 | } 128 | else 129 | { 130 | //get a solid cutoff timestamp... 131 | $cutoffTimestamp = $this->freshTimestamp(); 132 | 133 | //just update the temporal_end, without triggering update events or timestamp updates... 134 | $this->setKeysForSaveQuery($query)->toBase()->update(array( 135 | $this->getTemporalEndColumn()=>$cutoffTimestamp 136 | )); 137 | 138 | //set new temporal properties... 139 | $query = $this->newQueryWithoutScopes(); 140 | $this->{$this->getVersionColumn()}++; 141 | $this->{$this->getTemporalStartColumn()} = $cutoffTimestamp; 142 | $this->{$this->getTemporalEndColumn()} = $this->getTemporalMax(); 143 | 144 | //update the updated_at timestamp, if necessary... 145 | if ($this->usesTimestamps()) 146 | $this->updateTimestamps(); 147 | 148 | //insert the new revision... 149 | $attributes = $this->attributes; 150 | $query->insert($attributes); 151 | 152 | // Once we have run the update operation, we will fire the "updated" event for 153 | // this model instance. This will allow developers to hook into these after 154 | // models are updated, giving them a chance to do any special processing. 155 | $this->fireModelEvent('updated', false); 156 | } 157 | 158 | $saved = true; 159 | } 160 | else 161 | $saved = true; 162 | } 163 | 164 | // If the model is brand new, we'll insert it into our database and set the 165 | // ID attribute on the model to the value of the newly inserted row's ID 166 | // which is typically an auto-increment value managed by the database. 167 | else 168 | { 169 | $this->{$this->getVersionColumn()} = 1; 170 | $this->{$this->getTemporalStartColumn()} = $this->freshTimestamp(); 171 | $this->{$this->getTemporalEndColumn()} = $this->getTemporalMax(); 172 | $saved = $this->performInsert($query); 173 | } 174 | 175 | // If the model is successfully saved, we need to do a few more things once 176 | // that is done. We will call the "saved" method here to run any actions 177 | // we need to happen after a model gets successfully saved right here. 178 | if ($saved) { 179 | $this->finishSave($options); 180 | } 181 | 182 | return $saved; 183 | } 184 | 185 | /** 186 | * Set the keys for a save update query. 187 | * Includes the version column 188 | * 189 | * @param \Illuminate\Database\Eloquent\Builder $query 190 | * @return \Illuminate\Database\Eloquent\Builder 191 | */ 192 | protected function setKeysForSaveQuery($query) 193 | { 194 | $query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery()); 195 | $query->where($this->getVersionColumn(), '=', $this->{$this->getVersionColumn()}); 196 | 197 | return $query; 198 | } 199 | 200 | /** 201 | * Reload a fresh model instance from the database. 202 | * 203 | * @param array|string $with 204 | * @return static|null 205 | */ 206 | public function fresh($with = []) 207 | { 208 | if (! $this->exists) { 209 | return; 210 | } 211 | 212 | return static::newQueryWithoutScopes() 213 | ->with(is_string($with) ? func_get_args() : $with) 214 | ->where($this->getKeyName(), $this->getKey()) 215 | ->where($this->getTemporalEndColumn(), $this->getTemporalMax()) 216 | ->first(); 217 | } 218 | 219 | 220 | /******************************************************************************** 221 | * New Methods 222 | ********************************************************************************/ 223 | 224 | /** 225 | * Updates the record without inserting a new revision--overwriting the current revision. 226 | * Fires overwriting and overwritten events. Does not fire update or save events. 227 | * 228 | * @return bool True if successful. False if interrupted. 229 | */ 230 | public function overwrite() 231 | { 232 | $query = $this->newQueryWithoutScopes(); 233 | 234 | if ($this->fireModelEvent('overwriting') === false) return false; 235 | 236 | $this->setKeysForSaveQuery($query); 237 | 238 | if ($this->usesTimestamps()) $this->updateTimestamps(); 239 | 240 | $dirty = $this->getDirty(); 241 | if (count($dirty) > 0) 242 | { 243 | $query->update($dirty); 244 | $this->fireModelEvent('overwritten', false); 245 | $this->syncOriginal(); 246 | } 247 | 248 | return true; 249 | } 250 | 251 | /** 252 | * Restores the deleted revision. If this revision wasn't deleted (there is presently an active revision), nothing happens. 253 | * Fires restoring and restored events. Does not fire update or save events. 254 | * 255 | * @return bool True if the deleted revision was restored. False if there was already a currently active version (and we therefore didn't do anything). 256 | */ 257 | public function restore() 258 | { 259 | $query = $this->newQueryWithoutScopes(); 260 | 261 | if ($this->fireModelEvent('restoring') === false) return false; 262 | 263 | //make sure there is no currently active revision... 264 | if (!$this->find($this->{$this->getKeyName()})) 265 | { 266 | $this->setKeysForSaveQuery($query); 267 | 268 | if ($this->usesTimestamps()) $this->updateTimestamps(); 269 | 270 | $this->{$this->getTemporalEndColumn()} = $this->getTemporalMax(); 271 | 272 | $dirty = $this->getDirty(); 273 | if (count($dirty) > 0) 274 | { 275 | $query->update($dirty); 276 | $this->fireModelEvent('restored', false); 277 | $this->syncOriginal(); 278 | } 279 | 280 | return true; 281 | } 282 | 283 | return false; 284 | } 285 | 286 | /** 287 | * Permanently removes all versions of this record from the database--destroying all of the data. THIS CANNOT BE UNDONE! 288 | * Fires purging and purged events. Does not fire update, save, or delete events. 289 | * 290 | * @return bool 291 | */ 292 | public function purge() 293 | { 294 | $query = $this->newQueryWithoutScopes(); 295 | 296 | if ($this->fireModelEvent('purging') === false) return false; 297 | 298 | $query->where($this->getKeyName(), $this->{$this->getKeyName()}); 299 | $query->toBase()->delete(); 300 | 301 | $this->exists = false; 302 | 303 | $this->fireModelEvent('purged', false); 304 | 305 | return true; 306 | } 307 | 308 | /** 309 | * Returns the previous version of the record, or null if there is no previous version. 310 | * 311 | * @return Model 312 | */ 313 | public function previousVersion() 314 | { 315 | $query = $this->newQueryWithoutScopes(); 316 | $query->where($this->getKeyName(), $this->getKeyForSaveQuery()); 317 | $query->where($this->getVersionColumn(), ($this->{$this->getVersionColumn()} - 1)); 318 | 319 | return $query->first(); 320 | } 321 | 322 | /** 323 | * Returns the next version of the record, or null if there is no next version. 324 | * 325 | * @return Model 326 | */ 327 | public function nextVersion() 328 | { 329 | $query = $this->newQueryWithoutScopes(); 330 | $query->where($this->getKeyName(), $this->getKeyForSaveQuery()); 331 | $query->where($this->getVersionColumn(), ($this->{$this->getVersionColumn()} + 1)); 332 | 333 | return $query->first(); 334 | } 335 | 336 | /** 337 | * Returns the first version of the record, or null if there is no first version. 338 | * 339 | * @return Model 340 | */ 341 | public function firstVersion() 342 | { 343 | $query = $this->newQueryWithoutScopes(); 344 | $query->where($this->getKeyName(), $this->getKeyForSaveQuery()); 345 | $query->where($this->getVersionColumn(), 1); 346 | 347 | return $query->first(); 348 | } 349 | 350 | /** 351 | * Returns the specified version of the record, or null if the specified version does not exist. 352 | * 353 | * @param int $version The version number to retrieve 354 | * @return Model 355 | */ 356 | public function atVersion($version) 357 | { 358 | $query = $this->newQueryWithoutScopes(); 359 | $query->where($this->getKeyName(), $this->getKeyForSaveQuery()); 360 | $query->where($this->getVersionColumn(), $version); 361 | 362 | return $query->first(); 363 | } 364 | 365 | /** 366 | * Returns the current version of the record, or null if the record has been deleted. 367 | * 368 | * @return Model 369 | */ 370 | public function currentVersion() 371 | { 372 | $query = $this->newQueryWithoutScopes(); 373 | $query->where($this->getKeyName(), $this->getKeyForSaveQuery()); 374 | $query->where($this->getTemporalEndColumn(), $this->getTemporalMax()); 375 | 376 | return $query->first(); 377 | } 378 | 379 | /** 380 | * Returns the latest version of the record, or null if there is no latest version. Note that this may NOT be the currently active version, if the record was deleted. 381 | * 382 | * @return Model 383 | */ 384 | public function latestVersion() 385 | { 386 | $query = $this->newQueryWithoutScopes(); 387 | $query->where($this->getKeyName(), $this->getKeyForSaveQuery()); 388 | $query->orderBy($this->getVersionColumn(), 'desc'); 389 | 390 | return $query->first(); 391 | } 392 | 393 | /** 394 | * Returns the record as it existed on the specified date, or null if the record did not exist then. 395 | * If multiple versions of the record happen to exist at the same second, only the newest one will be returned. 396 | * 397 | * @param Carbon|string $datetime The date/time you want to search for a revision at. Can be a Carbon instance, or a datetime string that your database recognizes. 398 | * @return Model 399 | */ 400 | public function atDate($datetime) 401 | { 402 | $query = $this->newQueryWithoutScopes(); 403 | $query->where($this->getKeyName(), $this->getKeyForSaveQuery()); 404 | $query->where($this->getTemporalStartColumn(), '<=', $datetime); 405 | $query->where($this->getTemporalEndColumn(), '>', $datetime); 406 | $query->orderBy('version', 'desc'); 407 | 408 | return $query->first(); 409 | } 410 | 411 | /** 412 | * Returns an array of any versions of the record that existed at some point during the specified date range, or an empty array if the record did not exist at all during the date range. 413 | * The array is sorted from oldest to newest. 414 | * 415 | * @param Carbon|string $from If specified, only versions that existed on or after this date/time will be returned 416 | * @param Carbon|string $to If specified, only versions that existed before this date/time will be returned 417 | * @return Model[] 418 | */ 419 | public function inRange($from = null, $to = null) 420 | { 421 | if ($from !== null) $from = new Carbon($from); 422 | if ($to !== null) $to = new Carbon($to); 423 | 424 | $query = $this->newQueryWithoutScopes(); 425 | $query->where($this->getKeyName(), $this->getKeyForSaveQuery()); 426 | if ($from) $query->where($this->getTemporalEndColumn(), '>=', $from); 427 | if ($to) $query->where($this->getTemporalStartColumn(), '<', $to); 428 | $query->orderBy('version', 'asc'); 429 | 430 | return $query->get(); 431 | } 432 | 433 | /** 434 | * Get a new query to restore one or more models by their queueable IDs. 435 | * 436 | * @param array|int $ids 437 | * @return \Illuminate\Database\Eloquent\Builder 438 | */ 439 | public function newQueryForRestoration($ids) 440 | { 441 | $query = $this->newQueryWithoutScopes(); 442 | $query->where($this->getTemporalEndColumn(), $this->getTemporalMax()); 443 | 444 | return is_array($ids) 445 | ? $query->whereIn($this->getQualifiedKeyName(), $ids) 446 | : $query->whereKey($ids); 447 | } 448 | 449 | 450 | /******************************************************************************** 451 | * Static Methods 452 | ********************************************************************************/ 453 | 454 | /** 455 | * Starts a query constrained to only the current versions. This is accomplished in normal queries automatically, as the global temporal scope applies the same constraint. Feel free to use this method instead if you want, though. 456 | * 457 | * @return \Illuminate\Database\Eloquent\Builder 458 | */ 459 | public static function currentVersions() 460 | { 461 | return new static(); 462 | } 463 | } 464 | -------------------------------------------------------------------------------- /tests/TemporalTest.php: -------------------------------------------------------------------------------- 1 | addConnection([ 20 | 'driver' => 'mysql', 21 | 'database' => 'testbed', 22 | 'host' => '127.0.0.1', 23 | 'port' => '3306', 24 | 'username' => 'homestead', 25 | 'password' => 'secret', 26 | ]); 27 | 28 | $db->setEventDispatcher(new Dispatcher(new Container())); 29 | $db->setAsGlobal(); 30 | $db->bootEloquent(); 31 | 32 | $this->createSchema(); 33 | $this->seed(); 34 | $this->resetListeners(); 35 | } 36 | 37 | /** 38 | * Setup the database schema. 39 | * 40 | * @return void 41 | */ 42 | public function createSchema() 43 | { 44 | /** 45 | * DON'T DO THIS!!! THIS IS JUST A QUICK AND DIRTY WAY TO CREATE THE TESTING TABLE WITHOUT THE DB FACADE. 46 | * INSTEAD, YOU CAN EASILY CREATE TEMPORAL TABLES LIKE THIS... 47 | * 48 | * $this->schema()->create('widgets', function ($table) { 49 | * $table->increments('id'); 50 | * $table->timestamps(); 51 | * $table->string('name'); 52 | * }); 53 | * Migration::make_temporal('widgets'); 54 | * 55 | * NO NEED TO DO THE FOLLOWING... 56 | */ 57 | $this->schema()->create('widgets', function ($table) { 58 | $table->unsignedInteger('id'); 59 | $table->unsignedMediumInteger('version'); 60 | $table->dateTime('temporal_start'); 61 | $table->dateTime('temporal_end'); 62 | $table->timestamps(); 63 | $table->string('name'); 64 | $table->string('worthless')->nullable(); 65 | $table->primary(['id', 'version']); 66 | $table->index(['temporal_end', 'id']); 67 | $table->index(['id', 'temporal_start', 'temporal_end']); 68 | }); 69 | $this->connection()->statement('ALTER TABLE widgets MODIFY id INTEGER NOT NULL AUTO_INCREMENT;'); 70 | 71 | } 72 | 73 | /** 74 | * Seeds the database 75 | */ 76 | public function seed() 77 | { 78 | $records = rand(10, 50); 79 | for($x = 0; $x < $records; $x++) 80 | { 81 | $widget = new Widget(); 82 | $widget->name = str_random(); 83 | $widget->worthless = str_random(); 84 | $widget->save(); 85 | 86 | $versions = rand(0, 5); 87 | for($y = 0; $y < $versions; $y++) 88 | { 89 | $widget->name = str_random(); 90 | $widget->worthless = str_random(); 91 | $widget->save(); 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * Address a testing issue where model listeners are not reset. 98 | */ 99 | public function resetListeners() 100 | { 101 | Widget::flushEventListeners(); 102 | } 103 | 104 | /** 105 | * Tear down the database schema. 106 | * 107 | * @return void 108 | */ 109 | public function tearDown(): void 110 | { 111 | $this->schema()->drop('widgets'); 112 | } 113 | 114 | public function testSaveNewInsertsWithTemporalColumnsFilledIn() 115 | { 116 | $widget = new Widget(['name' => 'test1']); 117 | $widget->save(); 118 | 119 | $this->assertEquals(Carbon::parse($widget->temporal_end), Carbon::parse($widget->getTemporalMax())); 120 | $this->assertLessThan(5, Carbon::parse($widget->temporal_start)->diffInSeconds()); 121 | $this->assertEquals(1, $widget->version); 122 | 123 | $widget = Widget::create(['name'=>'test1']); 124 | 125 | $this->assertEquals(Carbon::parse($widget->temporal_end), Carbon::parse($widget->getTemporalMax())); 126 | $this->assertLessThan(5, Carbon::parse($widget->temporal_start)->diffInSeconds()); 127 | $this->assertEquals(1, $widget->version); 128 | } 129 | 130 | public function testSaveExistingInsertsNewVersion() 131 | { 132 | $widget = new Widget(['name' => 'test1']); 133 | $widget->save(); 134 | $this->assertEquals(1, $widget->version); 135 | $original_date = $widget->temporal_start; 136 | 137 | sleep(2); 138 | 139 | $widget->name = 'test2'; 140 | $widget->save(); 141 | $this->assertEquals(2, $widget->version); 142 | $this->assertEquals(Carbon::parse($widget->temporal_end), Carbon::parse($widget->getTemporalMax())); 143 | $this->assertTrue(Carbon::parse($widget->temporal_start)->greaterThan($original_date)); 144 | } 145 | 146 | public function testUpdateInsertsNewVersion() 147 | { 148 | $widget = new Widget(['name' => 'test1']); 149 | $widget->save(); 150 | $this->assertEquals(1, $widget->version); 151 | $original_date = $widget->temporal_start; 152 | 153 | sleep(2); 154 | 155 | $widget->update(['name'=>'test2']); 156 | $this->assertEquals('test2', $widget->name); 157 | $this->assertEquals(2, $widget->version); 158 | $this->assertEquals(Carbon::parse($widget->temporal_end), Carbon::parse($widget->getTemporalMax())); 159 | $this->assertTrue(Carbon::parse($widget->temporal_start)->greaterThan($original_date)); 160 | } 161 | 162 | public function testOverwriteRemovesPreviousVersion() 163 | { 164 | $widget = new Widget(['name' => 'test1']); 165 | $widget->save(); 166 | $widget->name = 'test2'; 167 | $widget->save(); 168 | 169 | sleep(2); 170 | 171 | $widget->name = 'test3'; 172 | $widget->overwrite(); 173 | 174 | $this->assertEquals(2, $widget->version); 175 | $this->assertGreaterThan(1, Carbon::parse($widget->temporal_start)->diffInSeconds()); 176 | $this->assertLessThan(2, Carbon::parse($widget->updated_at)->diffInSeconds()); 177 | 178 | $latestWidget = Widget::find($widget->id); 179 | $this->assertEquals('test3', $latestWidget->name); 180 | $this->assertEquals(2, $latestWidget->version); 181 | $this->assertGreaterThan(1, Carbon::parse($latestWidget->temporal_start)->diffInSeconds()); 182 | $this->assertLessThan(2, Carbon::parse($latestWidget->updated_at)->diffInSeconds()); 183 | 184 | $allVersions = Widget::withoutGlobalScopes()->where('id', $widget->id)->get(); 185 | $this->assertCount(2, $allVersions); 186 | } 187 | 188 | public function testChangeOverwritableOverwrites() 189 | { 190 | $widget = new Widget(['name' => 'test1', 'worthless'=>'a']); 191 | $widget->save(); 192 | $this->assertEquals(1, $widget->version); 193 | $original_date = $widget->temporal_start; 194 | 195 | sleep(2); 196 | 197 | $widget->worthless = 'b'; 198 | $widget->save(); 199 | 200 | $this->assertEquals(1, $widget->version); 201 | $this->assertGreaterThan(1, Carbon::parse($widget->temporal_start)->diffInSeconds()); 202 | $this->assertLessThan(2, Carbon::parse($widget->updated_at)->diffInSeconds()); 203 | 204 | $latestWidget = Widget::find($widget->id); 205 | $this->assertEquals('b', $latestWidget->worthless); 206 | $this->assertEquals(1, $latestWidget->version); 207 | $this->assertGreaterThan(1, Carbon::parse($latestWidget->temporal_start)->diffInSeconds()); 208 | $this->assertLessThan(2, Carbon::parse($latestWidget->updated_at)->diffInSeconds()); 209 | 210 | $allVersions = Widget::withoutGlobalScopes()->where('id', $widget->id)->get(); 211 | $this->assertCount(1, $allVersions); 212 | } 213 | 214 | public function testQuickSavesCauseNoVersionConflicts() 215 | { 216 | $widget = new Widget(['name' => 'test0']); 217 | $widget->save(); 218 | 219 | for($x = 1; $x < 10; $x++) 220 | { 221 | $widget->name = 'test' . $x; 222 | $widget->save(); 223 | } 224 | 225 | $this->assertTrue(true); 226 | } 227 | 228 | public function testGlobalScopeFindsLatestVersion() 229 | { 230 | $widget = new Widget(['name' => 'test1']); 231 | $widget->save(); 232 | $widget->name = 'test2'; 233 | $widget->save(); 234 | 235 | $latestWidget = Widget::find($widget->id); 236 | $this->assertEquals($latestWidget->id, $widget->id); 237 | $this->assertEquals(2, $latestWidget->version); 238 | $this->assertEquals(Carbon::parse($latestWidget->temporal_end), Carbon::parse($widget->getTemporalMax())); 239 | 240 | $latestWidgets = Widget::where('id', $widget->id)->get(); 241 | $this->assertCount(1, $latestWidgets); 242 | $latestWidget = $latestWidgets->first(); 243 | $this->assertEquals($latestWidget->id, $widget->id); 244 | $this->assertEquals(2, $latestWidget->version); 245 | $this->assertEquals(Carbon::parse($latestWidget->temporal_end), Carbon::parse($widget->getTemporalMax())); 246 | } 247 | 248 | public function testDeleteUpdatesTemporalEnd() 249 | { 250 | $widget = new Widget(['name' => 'test1']); 251 | $widget->save(); 252 | $widget->name = 'test2'; 253 | $widget->save(); 254 | 255 | sleep(3); 256 | 257 | $widget->delete(); 258 | $this->assertLessThan(2, Carbon::parse($widget->temporal_end)->diffInSeconds()); 259 | 260 | $deletedWidget = Widget::find($widget->id); 261 | $this->assertNull($deletedWidget); 262 | 263 | $allVersions = Widget::withoutGlobalScopes()->where('id', $widget->id)->get(); 264 | $this->assertCount(2, $allVersions); 265 | 266 | $allVersions = Widget::where('id', $widget->id)->get(); 267 | $this->assertCount(0, $allVersions); 268 | } 269 | 270 | public function testDeleteQueryUpdatesTemporalEnd() 271 | { 272 | $widget = new Widget(['name' => 'test1']); 273 | $widget->save(); 274 | $widget->name = 'test2'; 275 | $widget->save(); 276 | 277 | sleep(3); 278 | 279 | Widget::where('id', $widget->id)->delete(); 280 | 281 | $deletedWidget = Widget::find($widget->id); 282 | $this->assertNull($deletedWidget); 283 | 284 | $allVersions = Widget::withoutGlobalScopes()->where('id', $widget->id)->get(); 285 | $this->assertCount(2, $allVersions); 286 | $this->assertLessThan(2, Carbon::parse($allVersions->last()->temporal_end)->diffInSeconds()); 287 | 288 | $allVersions = Widget::where('id', $widget->id)->get(); 289 | $this->assertCount(0, $allVersions); 290 | } 291 | 292 | public function testDestroyUpdatesTemporalEnd() 293 | { 294 | $widget = new Widget(['name' => 'test1']); 295 | $widget->save(); 296 | $widget->name = 'test2'; 297 | $widget->save(); 298 | 299 | sleep(3); 300 | 301 | Widget::destroy($widget->id); 302 | 303 | $deletedWidget = Widget::find($widget->id); 304 | $this->assertNull($deletedWidget); 305 | 306 | $allVersions = Widget::withoutGlobalScopes()->where('id', $widget->id)->get(); 307 | $this->assertCount(2, $allVersions); 308 | $this->assertLessThan(2, Carbon::parse($allVersions->last()->temporal_end)->diffInSeconds()); 309 | 310 | $allVersions = Widget::where('id', $widget->id)->get(); 311 | $this->assertCount(0, $allVersions); 312 | } 313 | 314 | public function testRestoreDeletedUpdatesTemporalEnd() 315 | { 316 | $widget = new Widget(['name' => 'test1']); 317 | $widget->save(); 318 | $widget->name = 'test2'; 319 | $widget->save(); 320 | 321 | $widget->delete(); 322 | $deletedWidget = Widget::find($widget->id); 323 | $this->assertNull($deletedWidget); 324 | 325 | sleep(2); 326 | 327 | $result = $widget->restore(); 328 | $this->assertTrue($result); 329 | $this->assertEquals(2, $widget->version); 330 | $this->assertEquals(Carbon::parse($widget->temporal_end), Carbon::parse($widget->getTemporalMax())); 331 | $this->assertLessThan(2, Carbon::parse($widget->updated_at)->diffInSeconds()); 332 | 333 | $restoredWidget = Widget::find($widget->id); 334 | $this->assertNotNull($restoredWidget); 335 | $this->assertEquals($restoredWidget->id, $widget->id); 336 | $this->assertEquals(2, $restoredWidget->version); 337 | $this->assertEquals(Carbon::parse($restoredWidget->temporal_end), Carbon::parse($widget->getTemporalMax())); 338 | $this->assertLessThan(2, Carbon::parse($restoredWidget->updated_at)->diffInSeconds()); 339 | 340 | $allVersions = Widget::withoutGlobalScopes()->where('id', $widget->id)->get(); 341 | $this->assertCount(2, $allVersions); 342 | 343 | $allVersions = Widget::where('id', $widget->id)->get(); 344 | $this->assertCount(1, $allVersions); 345 | } 346 | 347 | public function testRestoreActiveDoesNothing() 348 | { 349 | $widget = new Widget(['name' => 'test1']); 350 | $widget->save(); 351 | $widget->name = 'test2'; 352 | $widget->save(); 353 | 354 | sleep(2); 355 | 356 | $result = $widget->restore(); 357 | $this->assertFalse($result); 358 | $this->assertEquals(2, $widget->version); 359 | $this->assertEquals(Carbon::parse($widget->temporal_end), Carbon::parse($widget->getTemporalMax())); 360 | $this->assertGreaterThan(1, Carbon::parse($widget->updated_at)->diffInSeconds()); 361 | 362 | $restoredWidget = Widget::find($widget->id); 363 | $this->assertNotNull($restoredWidget); 364 | $this->assertEquals($restoredWidget->id, $widget->id); 365 | $this->assertEquals(2, $restoredWidget->version); 366 | $this->assertEquals(Carbon::parse($restoredWidget->temporal_end), Carbon::parse($widget->getTemporalMax())); 367 | $this->assertGreaterThan(1, Carbon::parse($restoredWidget->updated_at)->diffInSeconds()); 368 | 369 | $allVersions = Widget::withoutGlobalScopes()->where('id', $widget->id)->get(); 370 | $this->assertCount(2, $allVersions); 371 | 372 | $allVersions = Widget::where('id', $widget->id)->get(); 373 | $this->assertCount(1, $allVersions); 374 | } 375 | 376 | public function testPurgeDeletesAll() 377 | { 378 | $widget = new Widget(['name' => 'test1']); 379 | $widget->save(); 380 | $widget->name = 'test2'; 381 | $widget->save(); 382 | 383 | $result = $widget->purge(); 384 | $this->assertTrue($result); 385 | $this->assertFalse($widget->exists); 386 | 387 | $allVersions = Widget::withoutGlobalScopes()->where('id', $widget->id)->get(); 388 | $this->assertCount(0, $allVersions); 389 | 390 | $everything = Widget::where('id', '<>', $widget->id)->get(); 391 | $this->assertGreaterThan(0, count($everything)); 392 | } 393 | 394 | public function testConflictingVersions() 395 | { 396 | $widget1 = new Widget(['name' => 'test1']); 397 | $widget1->save(); 398 | 399 | sleep(2); 400 | 401 | $widget2 = Widget::find($widget1->id); 402 | $widget2->name = 'test2'; 403 | $widget2->save(); 404 | 405 | sleep(2); 406 | 407 | $this->expectException(Exception::class); 408 | 409 | $widget1->name = 'test3'; 410 | $widget1->save(); 411 | } 412 | 413 | public function testPreviousFindsPreviousVersions() 414 | { 415 | $widget = new Widget(['name' => 'test1']); 416 | $widget->save(); 417 | $widget->name = 'test2'; 418 | $widget->save(); 419 | $widget->name = 'test3'; 420 | $widget->save(); 421 | 422 | $previousWidget = $widget->previousVersion(); 423 | $this->assertNotNull($previousWidget); 424 | $this->assertEquals($previousWidget->id, $widget->id); 425 | $this->assertEquals(2, $previousWidget->version); 426 | 427 | $previousWidget = $previousWidget->previousVersion(); 428 | $this->assertNotNull($previousWidget); 429 | $this->assertEquals($previousWidget->id, $widget->id); 430 | $this->assertEquals(1, $previousWidget->version); 431 | 432 | $previousWidget = $previousWidget->previousVersion(); 433 | $this->assertNull($previousWidget); 434 | } 435 | 436 | public function testFirstFindsFirstVersion() 437 | { 438 | $widget = new Widget(['name' => 'test1']); 439 | $widget->save(); 440 | $widget->name = 'test2'; 441 | $widget->save(); 442 | $widget->name = 'test3'; 443 | $widget->save(); 444 | 445 | $firstWidget = $widget->firstVersion(); 446 | $this->assertNotNull($firstWidget); 447 | $this->assertEquals($firstWidget->id, $widget->id); 448 | $this->assertEquals(1, $firstWidget->version); 449 | } 450 | 451 | public function testNextFindsNextVersions() 452 | { 453 | $widget = new Widget(['name' => 'test1']); 454 | $widget->save(); 455 | $widget->name = 'test2'; 456 | $widget->save(); 457 | $widget->name = 'test3'; 458 | $widget->save(); 459 | 460 | $firstWidget = $widget->firstVersion(); 461 | $nextWidget = $firstWidget->nextVersion(); 462 | $this->assertNotNull($nextWidget); 463 | $this->assertEquals($nextWidget->id, $widget->id); 464 | $this->assertEquals(2, $nextWidget->version); 465 | 466 | $nextWidget = $nextWidget->nextVersion(); 467 | $this->assertNotNull($nextWidget); 468 | $this->assertEquals($nextWidget->id, $widget->id); 469 | $this->assertEquals(3, $nextWidget->version); 470 | 471 | $nextWidget = $nextWidget->nextVersion(); 472 | $this->assertNull($nextWidget); 473 | } 474 | 475 | public function testCurrentFindsCurrentVersion() 476 | { 477 | $widget = new Widget(['name' => 'test1']); 478 | $widget->save(); 479 | $widget->name = 'test2'; 480 | $widget->save(); 481 | $widget->name = 'test3'; 482 | $widget->save(); 483 | 484 | $currentWidget = $widget->currentVersion(); 485 | $this->assertNotNull($currentWidget); 486 | $this->assertEquals($currentWidget->id, $widget->id); 487 | 488 | $widget->delete(); 489 | $currentWidget = $widget->currentVersion(); 490 | $this->assertNull($currentWidget); 491 | } 492 | 493 | public function testLatestFindsLatestVersion() 494 | { 495 | $widget = new Widget(['name' => 'test1']); 496 | $widget->save(); 497 | $widget->name = 'test2'; 498 | $widget->save(); 499 | $widget->name = 'test3'; 500 | $widget->save(); 501 | 502 | $latestWidget = $widget->latestVersion(); 503 | $this->assertNotNull($latestWidget); 504 | $this->assertEquals($latestWidget->id, $widget->id); 505 | $this->assertEquals(3, $latestWidget->version); 506 | 507 | $widget->delete(); 508 | $latestWidget = $widget->latestVersion(); 509 | $this->assertNotNull($latestWidget); 510 | $this->assertEquals($latestWidget->id, $widget->id); 511 | $this->assertEquals(3, $latestWidget->version); 512 | $this->assertLessThan(2, Carbon::parse($latestWidget->temporal_end)->diffInSeconds()); 513 | } 514 | 515 | public function testAtVersionFindsVersion() 516 | { 517 | $widget = new Widget(['name' => 'test1']); 518 | $widget->save(); 519 | $widget->name = 'test2'; 520 | $widget->save(); 521 | $widget->name = 'test3'; 522 | $widget->save(); 523 | 524 | $currentWidget = $widget->atVersion(1); 525 | $this->assertNotNull($currentWidget); 526 | $this->assertEquals($currentWidget->id, $widget->id); 527 | $this->assertEquals(1, $currentWidget->version); 528 | 529 | $currentWidget = $widget->atVersion(2); 530 | $this->assertNotNull($currentWidget); 531 | $this->assertEquals($currentWidget->id, $widget->id); 532 | $this->assertEquals(2, $currentWidget->version); 533 | 534 | $currentWidget = $widget->atVersion(3); 535 | $this->assertNotNull($currentWidget); 536 | $this->assertEquals($currentWidget->id, $widget->id); 537 | $this->assertEquals(3, $currentWidget->version); 538 | 539 | $currentWidget = $widget->atVersion(4); 540 | $this->assertNull($currentWidget); 541 | } 542 | 543 | public function testAtDateFindsVersion() 544 | { 545 | $widget = new Widget(['name' => 'test1']); 546 | $widget->save(); 547 | sleep(2); 548 | $widget->name = 'test2'; 549 | $widget->save(); 550 | sleep(2); 551 | $widget->name = 'test3'; 552 | $widget->save(); 553 | 554 | $currentWidget = $widget->atDate(Carbon::now()->subSeconds(1)); 555 | $this->assertNotNull($currentWidget); 556 | $this->assertEquals($currentWidget->id, $widget->id); 557 | $this->assertEquals(2, $currentWidget->version); 558 | 559 | $widget = new Widget(['name' => 'test1']); 560 | $widget->save(); 561 | $widget->name = 'test2'; 562 | $widget->save(); 563 | $widget->name = 'test3'; 564 | $widget->save(); 565 | sleep(2); 566 | $widget->name = 'test4'; 567 | $widget->save(); 568 | 569 | $currentWidget = $widget->atDate(Carbon::now()->subSeconds(1)); 570 | $this->assertNotNull($currentWidget); 571 | $this->assertEquals($currentWidget->id, $widget->id); 572 | $this->assertEquals(3, $currentWidget->version); 573 | } 574 | 575 | public function testInRangeFindsVersions() 576 | { 577 | $widget = new Widget(['name' => 'test1']); 578 | $widget->save(); 579 | sleep(2); 580 | $widget->name = 'test2'; 581 | $widget->save(); 582 | sleep(2); 583 | $widget->name = 'test3'; 584 | $widget->save(); 585 | 586 | $atWidgets = $widget->inRange(Carbon::now()->subSeconds(3), Carbon::now()->subSeconds(1)); 587 | $this->assertCount(2, $atWidgets); 588 | $this->assertEquals($atWidgets->first()->id, $widget->id); 589 | $this->assertEquals(1, $atWidgets->first()->version); 590 | $this->assertEquals($atWidgets->get(1)->id, $widget->id); 591 | $this->assertEquals(2, $atWidgets->get(1)->version); 592 | 593 | $atWidgets = $widget->inRange(Carbon::now()->subSeconds(1)); 594 | $this->assertCount(2, $atWidgets); 595 | $this->assertEquals($atWidgets->first()->id, $widget->id); 596 | $this->assertEquals(2, $atWidgets->first()->version); 597 | $this->assertEquals($atWidgets->get(1)->id, $widget->id); 598 | $this->assertEquals(3, $atWidgets->get(1)->version); 599 | 600 | $atWidgets = $widget->inRange(null, Carbon::now()->subSeconds(1)); 601 | $this->assertCount(2, $atWidgets); 602 | $this->assertEquals($atWidgets->first()->id, $widget->id); 603 | $this->assertEquals(1, $atWidgets->first()->version); 604 | $this->assertEquals($atWidgets->get(1)->id, $widget->id); 605 | $this->assertEquals(2, $atWidgets->get(1)->version); 606 | } 607 | 608 | public function testAllVersionsFindsAllVersions() 609 | { 610 | $widget = new Widget(['name' => 'test1']); 611 | $widget->save(); 612 | $widget->name = 'test2'; 613 | $widget->save(); 614 | $widget->name = 'test3'; 615 | $widget->save(); 616 | 617 | $allWidgets = Widget::where('id', $widget->id)->allVersions()->get(); 618 | $this->assertCount(3, $allWidgets); 619 | 620 | $allWidgets = Widget::allVersions()->where('id', $widget->id)->get(); 621 | $this->assertCount(3, $allWidgets); 622 | } 623 | 624 | public function testCurrentVersionsFindsCurrentVersions() 625 | { 626 | $widget = new Widget(['name' => 'test1']); 627 | $widget->save(); 628 | $widget->name = 'test2'; 629 | $widget->save(); 630 | $widget->name = 'test3'; 631 | $widget->save(); 632 | 633 | $currentWidgets = Widget::allVersions()->where('id', $widget->id)->currentVersions()->get(); 634 | $this->assertCount(1, $currentWidgets); 635 | $currentWidget = $currentWidgets->first(); 636 | $this->assertEquals($currentWidget->id, $widget->id); 637 | $this->assertEquals(3, $currentWidget->version); 638 | 639 | $currentWidgets = Widget::allVersions()->currentVersions()->where('id', $widget->id)->get(); 640 | $this->assertCount(1, $currentWidgets); 641 | $currentWidget = $currentWidgets->first(); 642 | $this->assertEquals($currentWidget->id, $widget->id); 643 | $this->assertEquals(3, $currentWidget->version); 644 | } 645 | 646 | public function testCurrentVersionsStaticFindsCurrentVersions() 647 | { 648 | $widget = new Widget(['name' => 'test1']); 649 | $widget->save(); 650 | $widget->name = 'test2'; 651 | $widget->save(); 652 | $widget->name = 'test3'; 653 | $widget->save(); 654 | 655 | $currentWidgets = Widget::currentVersions()->where('id', $widget->id)->get(); 656 | $this->assertCount(1, $currentWidgets); 657 | $currentWidget = $currentWidgets->first(); 658 | $this->assertEquals($currentWidget->id, $widget->id); 659 | $this->assertEquals(3, $currentWidget->version); 660 | 661 | $currentWidgets = Widget::currentVersions()->where('id', $widget->id)->get(); 662 | $this->assertCount(1, $currentWidgets); 663 | $currentWidget = $currentWidgets->first(); 664 | $this->assertEquals($currentWidget->id, $widget->id); 665 | $this->assertEquals(3, $currentWidget->version); 666 | } 667 | 668 | public function testFirstVersionsFindsFirstVersions() 669 | { 670 | $widget = new Widget(['name' => 'test1']); 671 | $widget->save(); 672 | $widget->name = 'test2'; 673 | $widget->save(); 674 | $widget->name = 'test3'; 675 | $widget->save(); 676 | 677 | $firstWidgets = Widget::where('id', $widget->id)->firstVersions()->get(); 678 | $this->assertCount(1, $firstWidgets); 679 | $firstWidget = $firstWidgets->first(); 680 | $this->assertEquals($firstWidget->id, $widget->id); 681 | $this->assertEquals(1, $firstWidget->version); 682 | 683 | $firstWidgets = Widget::firstVersions()->where('id', $widget->id)->get(); 684 | $this->assertCount(1, $firstWidgets); 685 | $firstWidget = $firstWidgets->first(); 686 | $this->assertEquals($firstWidget->id, $widget->id); 687 | $this->assertEquals(1, $firstWidget->version); 688 | } 689 | 690 | public function testVersionsAtFindsAtVersions() 691 | { 692 | $widget = new Widget(['name' => 'test1']); 693 | $widget->save(); 694 | $widget->name = 'test2'; 695 | $widget->save(); 696 | $widget->name = 'test3'; 697 | $widget->save(); 698 | 699 | $atWidgets = Widget::where('id', $widget->id)->versionsAt(2)->get(); 700 | $this->assertCount(1, $atWidgets); 701 | $atWidget = $atWidgets->first(); 702 | $this->assertEquals($atWidget->id, $widget->id); 703 | $this->assertEquals(2, $atWidget->version); 704 | 705 | $atWidgets = Widget::versionsAt(2)->where('id', $widget->id)->get(); 706 | $this->assertCount(1, $atWidgets); 707 | $atWidget = $atWidgets->first(); 708 | $this->assertEquals($atWidget->id, $widget->id); 709 | $this->assertEquals(2, $atWidget->version); 710 | } 711 | 712 | public function testVersionsAtDateFindsAtDates() 713 | { 714 | $widget = new Widget(['name' => 'test1']); 715 | $widget->save(); 716 | sleep(2); 717 | $widget->name = 'test2'; 718 | $widget->save(); 719 | sleep(2); 720 | $widget->name = 'test3'; 721 | $widget->save(); 722 | 723 | $atWidgets = Widget::where('id', $widget->id)->versionsAtDate(Carbon::now()->subSeconds(1))->get(); 724 | $this->assertCount(1, $atWidgets); 725 | $atWidget = $atWidgets->first(); 726 | $this->assertEquals($atWidget->id, $widget->id); 727 | $this->assertEquals(2, $atWidget->version); 728 | 729 | $atWidgets = Widget::versionsAtDate(Carbon::now()->subSeconds(1))->where('id', $widget->id)->get(); 730 | $this->assertCount(1, $atWidgets); 731 | $atWidget = $atWidgets->first(); 732 | $this->assertEquals($atWidget->id, $widget->id); 733 | $this->assertEquals(2, $atWidget->version); 734 | } 735 | 736 | public function testVersionsInRangeFindsRanges() 737 | { 738 | $widget = new Widget(['name' => 'test1']); 739 | $widget->save(); 740 | sleep(2); 741 | $widget->name = 'test2'; 742 | $widget->save(); 743 | sleep(2); 744 | $widget->name = 'test3'; 745 | $widget->save(); 746 | 747 | $atWidgets = Widget::where('id', $widget->id)->versionsInRange(Carbon::now()->subSeconds(3), Carbon::now()->subSeconds(1))->get(); 748 | $this->assertCount(2, $atWidgets); 749 | $this->assertEquals($atWidgets->first()->id, $widget->id); 750 | $this->assertEquals(1, $atWidgets->first()->version); 751 | $this->assertEquals($atWidgets->get(1)->id, $widget->id); 752 | $this->assertEquals(2, $atWidgets->get(1)->version); 753 | 754 | $atWidgets = Widget::where('id', $widget->id)->versionsInRange(Carbon::now()->subSeconds(1))->get(); 755 | $this->assertCount(2, $atWidgets); 756 | $this->assertEquals($atWidgets->first()->id, $widget->id); 757 | $this->assertEquals(2, $atWidgets->first()->version); 758 | $this->assertEquals($atWidgets->get(1)->id, $widget->id); 759 | $this->assertEquals(3, $atWidgets->get(1)->version); 760 | 761 | $atWidgets = Widget::where('id', $widget->id)->versionsInRange(null, Carbon::now()->subSeconds(1))->get(); 762 | $this->assertCount(2, $atWidgets); 763 | $this->assertEquals($atWidgets->first()->id, $widget->id); 764 | $this->assertEquals(1, $atWidgets->first()->version); 765 | $this->assertEquals($atWidgets->get(1)->id, $widget->id); 766 | $this->assertEquals(2, $atWidgets->get(1)->version); 767 | 768 | $atWidgets = Widget::versionsInRange(null, Carbon::now()->subSeconds(1))->where('id', $widget->id)->get(); 769 | $this->assertCount(2, $atWidgets); 770 | $this->assertEquals($atWidgets->first()->id, $widget->id); 771 | $this->assertEquals(1, $atWidgets->first()->version); 772 | $this->assertEquals($atWidgets->get(1)->id, $widget->id); 773 | $this->assertEquals(2, $atWidgets->get(1)->version); 774 | } 775 | 776 | public function testCannotSaveOldVersions() 777 | { 778 | $widget = new Widget(['name' => 'test1']); 779 | $widget->save(); 780 | $widget->name = 'test2'; 781 | $widget->save(); 782 | $widget->name = 'test3'; 783 | $widget->save(); 784 | 785 | $firstWidget = $widget->firstVersion(); 786 | $firstWidget->name = 'new name'; 787 | 788 | $this->expectException(TemporalException::class); 789 | $firstWidget->save(); 790 | } 791 | 792 | public function testCannotDeleteOldVersions() 793 | { 794 | $widget = new Widget(['name' => 'test1']); 795 | $widget->save(); 796 | $widget->name = 'test2'; 797 | $widget->save(); 798 | $widget->name = 'test3'; 799 | $widget->save(); 800 | 801 | $firstWidget = $widget->firstVersion(); 802 | 803 | $this->expectException(TemporalException::class); 804 | $firstWidget->delete(); 805 | } 806 | 807 | /** 808 | * Get a database connection instance. 809 | * 810 | * @return Connection 811 | */ 812 | protected function connection() 813 | { 814 | return Eloquent::getConnectionResolver()->connection(); 815 | } 816 | 817 | /** 818 | * Get a schema builder instance. 819 | * 820 | * @return Schema\Builder 821 | */ 822 | protected function schema() 823 | { 824 | return $this->connection()->getSchemaBuilder(); 825 | } 826 | } 827 | 828 | /** 829 | * Eloquent Models... 830 | */ 831 | class Widget extends Eloquent 832 | { 833 | use Temporal; 834 | 835 | //protected $dates = ['temporal_start', 'temporal_end']; 836 | protected $table = 'widgets'; 837 | protected $guarded = []; 838 | protected $overwritable = ['worthless']; 839 | } --------------------------------------------------------------------------------