├── .gitignore ├── src ├── Exceptions │ └── InvalidDateRangeException.php └── Temporal.php ├── tests ├── TestCase.php └── TemporalTest.php ├── composer.json ├── LICENSE.md ├── phpunit.xml.dist ├── README.md └── .circleci └── config.yml /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | vendor 4 | .idea 5 | ._DS_STORE -------------------------------------------------------------------------------- /src/Exceptions/InvalidDateRangeException.php: -------------------------------------------------------------------------------- 1 | =7.0.0", 13 | "illuminate/database": "^5.2", 14 | "illuminate/events": "^5.2" 15 | }, 16 | "require-dev": { 17 | "phpunit/phpunit": "~6.0", 18 | "mockery/mockery": "~1.0", 19 | "php-coveralls/php-coveralls": "~1.0" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "NavJobs\\Temporal\\": "src" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "NavJobs\\Temporal\\Test\\": "tests" 29 | } 30 | }, 31 | "scripts": { 32 | "test": "phpunit" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | src/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Circle CI](https://circleci.com/gh/navjobs/temporal-models.svg?style=shield)](https://circleci.com/gh/navjobs/temporal-models) 2 | [![Coverage Status](https://coveralls.io/repos/github/navjobs/temporal-models/badge.svg?branch=master)](https://coveralls.io/github/navjobs/temporal-models?branch=master) 3 | [![Code Climate](https://codeclimate.com/github/navjobs/temporal-models/badges/gpa.svg)](https://codeclimate.com/github/navjobs/temporal-models) 4 | 5 | ###### Temporal Models for Laravel 6 | Adds support for Temporal Models to Laravel 5.1+ 7 | 8 | > Usually in a database, entities are represented by a row in a table, when this row is updated the old information is 9 | > overwritten. The temporal model allows data to be referenced in time, it makes it possible to query the state of an 10 | > entity at a given time. 11 | > 12 | > For example, say you wanted to keep track of changes to products so when an order is placed you know the state of the 13 | > product without having to duplicate data in the orders table. You can make the products temporal and use the time of 14 | > the order to reference the state of the ordered products at that time, rather than how they currently are, as would 15 | > happen without using temporal data. 16 | > 17 | > The temporal model could also be used for auditing changes to things like wiki pages. Any changes would be 18 | > automatically logged without having to use a separate log table. 19 | 20 | [From FuelPHP docs](http://fuelphp.com/dev-docs/packages/orm/model/temporal.html) 21 | 22 | ## Installation 23 | 24 | You can install this package via Composer using this command: 25 | 26 | ```bash 27 | composer require navjobs/temporal-models 28 | ``` 29 | 30 | Next, the model you wish to make temporal must have the following fields in its Schema: 31 | 32 | ```php 33 | $table->dateTime('valid_start'); 34 | $table->dateTime('valid_end')->nullable(); 35 | ``` 36 | 37 | The model itself must use the `Temporal` trait and define two protected properties as in this example: 38 | 39 | ```php 40 | class Commission extends Model 41 | { 42 | use Temporal; 43 | 44 | protected $dates = ['valid_start', 'valid_end']; 45 | protected $temporalParentColumn = 'representative_id'; 46 | } 47 | ``` 48 | 49 | The $temporalParentColumn property contains the name of the column tying the temporal records together. In the example above the model would represent a commission rate. Its $temporalParentColumn might be 'representative_id'. A representative/salesperson would have only one active commission rate at any given time. Representing the commission in a temporal fashion enables us to record history of the commission rate and schedule any future commission rates. 50 | 51 | ## Usage 52 | 53 | ###### Creating Temporal Records 54 | When a temporal record is created it automatically resolves any scheduling conflicts. If a newly created record overlaps with a previously scheduled record then the previously scheduled record will be deleted. Any records already started will have their valid_end set to the valid_start of the newly created record. Temporal records cannot be created in the past. 55 | 56 | ###### Updating Temporal Records 57 | In order to preserve their historic nature, updates to temporal records are restricted to just valid_end after 58 | they have started. Attempts to update any other fields will fail. If this behavior is undesirable, it can be modified by adding the following property to the temporal model: 59 | 60 | ```php 61 | protected $enableUpdates = true; 62 | ``` 63 | 64 | Additionally, the behavior can be changed dynamically by calling ```$model->enableUpdates()->save();``` 65 | 66 | ###### Deleting Temporal Records 67 | Temporal records that have already started cannot be deleted. When the delete method is called on them they will simply 68 | have their valid_end set to the current time. If delete is called on a scheduled record then it will succeed. 69 | 70 | ###### Methods and Scopes 71 | The `Temporal` trait includes an isValid() method that optionally takes a Carbon object. The method returns whether the 72 | model was valid on the provided date or now if no Carbon object is provided. Also included are `valid()` and `invalid()` 73 | scopes. These scopes query for either the valid or invalid scopes at the time of the passed Carbon object or now if no Carbon object is passed. 74 | 75 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test-7.0-latest: 4 | docker: 5 | - image: circleci/php:7.0.25-node-browsers 6 | steps: 7 | - checkout 8 | - run: 9 | name: Update Composer 10 | command: 'composer self-update' 11 | - run: 12 | name: Validate composer.json 13 | command: 'composer validate --strict' 14 | - restore_cache: 15 | key: dependency-cache-7.0-latest-{{ checksum "composer.json" }} 16 | - run: 17 | name: Install dependencies 18 | command: composer update --prefer-dist --prefer-stable --no-suggest 19 | - save_cache: 20 | key: dependency-cache-7.0-latest-{{ checksum "composer.json" }} 21 | paths: 22 | - vendor 23 | - run: 24 | name: Run test suite 25 | command: vendor/bin/phpunit 26 | - store_test_results: 27 | path: build/logs/clover.xml 28 | - run: 29 | name: Send coverage to Coveralls 30 | command: vendor/bin/coveralls -n 31 | test-7.1-latest: 32 | docker: 33 | - image: circleci/php:7.1.11-node-browsers 34 | steps: 35 | - checkout 36 | - run: 37 | name: Update Composer 38 | command: 'composer self-update' 39 | - run: 40 | name: Validate composer.json 41 | command: 'composer validate --strict' 42 | - restore_cache: 43 | key: dependency-cache-7.1-latest-{{ checksum "composer.json" }} 44 | - run: 45 | name: Install dependencies 46 | command: composer update --prefer-dist --prefer-stable --no-suggest 47 | - save_cache: 48 | key: dependency-cache-7.1-latest-{{ checksum "composer.json" }} 49 | paths: 50 | - vendor 51 | - run: 52 | name: Run test suite 53 | command: vendor/bin/phpunit 54 | - store_test_results: 55 | path: build/logs/clover.xml 56 | - run: 57 | name: Send coverage to Coveralls 58 | command: vendor/bin/coveralls -n 59 | test-7.0-lowest: 60 | docker: 61 | - image: circleci/php:7.0.25-node-browsers 62 | steps: 63 | - checkout 64 | - run: 65 | name: Update Composer 66 | command: 'composer self-update' 67 | - run: 68 | name: Validate composer.json 69 | command: 'composer validate --strict' 70 | - restore_cache: 71 | key: dependency-cache-7.0-lowest-{{ checksum "composer.json" }} 72 | - run: 73 | name: Install dependencies 74 | command: composer update --prefer-dist --prefer-lowest --prefer-stable --no-suggest 75 | - save_cache: 76 | key: dependency-cache-7.0-lowest-{{ checksum "composer.json" }} 77 | paths: 78 | - vendor 79 | - run: 80 | name: Run test suite 81 | command: vendor/bin/phpunit 82 | - store_test_results: 83 | path: build/logs/clover.xml 84 | - run: 85 | name: Send coverage to Coveralls 86 | command: vendor/bin/coveralls -n 87 | test-7.1-lowest: 88 | docker: 89 | - image: circleci/php:7.1.11-node-browsers 90 | steps: 91 | - checkout 92 | - run: 93 | name: Update Composer 94 | command: 'composer self-update' 95 | - run: 96 | name: Validate composer.json 97 | command: 'composer validate --strict' 98 | - restore_cache: 99 | key: dependency-cache-7.1-lowest-{{ checksum "composer.json" }} 100 | - run: 101 | name: Install dependencies 102 | command: composer update --prefer-dist --prefer-lowest --prefer-stable --no-suggest 103 | - save_cache: 104 | key: dependency-cache-7.1-lowest-{{ checksum "composer.json" }} 105 | paths: 106 | - vendor 107 | - run: 108 | name: Run test suite 109 | command: vendor/bin/phpunit 110 | - store_test_results: 111 | path: build/logs/clover.xml 112 | - run: 113 | name: Send coverage to Coveralls 114 | command: vendor/bin/coveralls -n 115 | workflows: 116 | version: 2 117 | test: 118 | jobs: 119 | - test-7.0-latest 120 | - test-7.0-lowest 121 | - test-7.1-latest 122 | - test-7.1-lowest -------------------------------------------------------------------------------- /src/Temporal.php: -------------------------------------------------------------------------------- 1 | startCannotBeInThePast(); 34 | $item->endCurrent(); 35 | }); 36 | 37 | static::saving(function ($item) { 38 | $item->startCannotBeAfterEnd(); 39 | $item->removeSchedulingConflicts(); 40 | }); 41 | 42 | static::updating(function ($item) { 43 | return $item->canUpdate(); 44 | }); 45 | 46 | static::deleting(function ($item) { 47 | return $item->endOrDelete(); 48 | }); 49 | } 50 | 51 | /******************************************************************************** 52 | * Methods 53 | ********************************************************************************/ 54 | 55 | /** 56 | * Checks if the model is valid. 57 | * 58 | * @param Carbon $validTime 59 | * @return mixed 60 | */ 61 | public function isValid(Carbon $validTime = null) 62 | { 63 | $dateTime = $validTime ?? new Carbon(); 64 | 65 | return $this->valid_start->lte($dateTime) && (is_null($this->valid_end) || $this->valid_end->gte($dateTime)); 66 | } 67 | 68 | /** 69 | * Valid date cannot be in the past. 70 | * 71 | * @throws InvalidDateRangeException 72 | */ 73 | protected function startCannotBeInThePast() 74 | { 75 | if ($this->valid_start < Carbon::now()->subSeconds(5)) { 76 | throw new InvalidDateRangeException; 77 | } 78 | } 79 | 80 | /** 81 | * Start date cannot be greater than end date. 82 | * 83 | * @throws InvalidDateRangeException 84 | */ 85 | protected function startCannotBeAfterEnd() 86 | { 87 | if ($this->valid_end && $this->valid_start > $this->valid_end) { 88 | throw new InvalidDateRangeException; 89 | } 90 | } 91 | 92 | /** 93 | * If a temporal model is created, then we want any Temporal Models that were 94 | * already scheduled to start to be removed. 95 | */ 96 | protected function removeSchedulingConflicts() 97 | { 98 | if (is_null($this->valid_end)) { 99 | return $this->getQuery()->where('valid_start', '>', $this->valid_start)->delete(); 100 | } 101 | 102 | return $this->getQuery() 103 | ->where('valid_start', '<', $this->valid_end) 104 | ->where(function ($query) { 105 | $query->whereNull('valid_end') 106 | ->orWhere('valid_end', '>', $this->valid_start); 107 | }) 108 | ->update([ 109 | 'valid_end' => $this->valid_start 110 | ]); 111 | } 112 | 113 | /** 114 | * If a valid Temporal Model exists that should be ended go ahead and do so. 115 | */ 116 | protected function endCurrent() 117 | { 118 | $currentItem = $this->getQuery()->valid()->first(); 119 | 120 | if ($currentItem && $this->shouldBeEnded($currentItem)) { 121 | $currentItem->update([ 122 | 'valid_end' => $this->valid_start 123 | ]); 124 | } 125 | } 126 | 127 | /** 128 | * Build a query on the Temporal Model based on the fields that are present. 129 | * 130 | * @return mixed 131 | */ 132 | private function getQuery() 133 | { 134 | $query = $this->where($this->temporalParentColumn, $this->{$this->temporalParentColumn}); 135 | 136 | if ($this->{$this->primaryKey}) { 137 | $query->where($this->primaryKey, '!=', $this->{$this->primaryKey}); 138 | } 139 | 140 | if ($this->temporalPolymorphicTypeColumn) { 141 | $query->where($this->temporalPolymorphicTypeColumn, $this->{$this->temporalPolymorphicTypeColumn}); 142 | } 143 | 144 | return $query; 145 | } 146 | 147 | /** 148 | * Determine if the provided Temporal Model should be ended based the valid_start. 149 | * 150 | * @param $currentItem 151 | * @return bool 152 | */ 153 | private function shouldBeEnded($currentItem) 154 | { 155 | return is_null($currentItem->valid_end) || $currentItem->valid_end > $this->valid_start; 156 | } 157 | 158 | /** 159 | * Only the valid_end attribute is eligible to be updated. 160 | * 161 | * Must be valid 162 | * Must have valid_end in the dirty attributes 163 | * The new valid end must in the future (or null) 164 | * Dirty attributes must only contain valid_end 165 | * 166 | */ 167 | protected function canUpdate() 168 | { 169 | if ($this->enableUpdates) { 170 | return $this->enableUpdates; 171 | } 172 | 173 | $truthChecks = collect([ 174 | $this->getOriginal('valid_end') > Carbon::now() || is_null($this->getOriginal('valid_end')), 175 | array_key_exists('valid_end', $this->getDirty()), 176 | $this->valid_end >= Carbon::now()->subSeconds(5) || is_null($this->valid_end), 177 | count($this->getDirty()) == 1, 178 | ]); 179 | 180 | return $truthChecks->filter()->count() === $truthChecks->count() ? null : false; 181 | } 182 | 183 | /** 184 | * Only delete the Temporal model if valid_start is in the future, otherwise set the valid_end. 185 | * 186 | * @return bool|null 187 | */ 188 | protected function endOrDelete() 189 | { 190 | if ($this->enableUpdates) { 191 | return; 192 | } 193 | 194 | if ($this->valid_start > Carbon::now()) { 195 | return; 196 | } 197 | 198 | if ($this->isValid()) { 199 | $this->where($this->primaryKey, $this->{$this->primaryKey})->update([ 200 | 'valid_end' => Carbon::now() 201 | ]); 202 | } 203 | 204 | return false; 205 | } 206 | 207 | /** 208 | * Sets the enableUpdates property in a chainable manner. 209 | * 210 | * @param bool $status 211 | * @return $this 212 | */ 213 | public function enableUpdates($status = true) 214 | { 215 | $this->enableUpdates = $status; 216 | 217 | return $this; 218 | } 219 | 220 | /******************************************************************************** 221 | * Scopes 222 | ********************************************************************************/ 223 | 224 | /** 225 | * Scope a valid temporal model. 226 | * 227 | * @param $query 228 | * @param Carbon $validTime 229 | * @return mixed 230 | */ 231 | public function scopeValid($query, Carbon $validTime = null) 232 | { 233 | $dateTime = $validTime ?? new Carbon(); 234 | 235 | return $query->where('valid_start', '<=', $dateTime) 236 | ->where(function ($query) use ($dateTime) { 237 | $query->whereNull('valid_end') 238 | ->orWhere('valid_end', '>', $dateTime); 239 | }); 240 | } 241 | 242 | /** 243 | * Scope a invalid temporal model. 244 | * 245 | * @param $query 246 | * @param Carbon $validTime 247 | * @return mixed 248 | */ 249 | public function scopeInvalid($query, Carbon $validTime = null) 250 | { 251 | $dateTime = $validTime ?? new Carbon(); 252 | 253 | return $query->where(function ($query) use ($dateTime) { 254 | $query->where('valid_start', '>', $dateTime) 255 | ->orWhere('valid_end', '<', $dateTime); 256 | }); 257 | } 258 | } -------------------------------------------------------------------------------- /tests/TemporalTest.php: -------------------------------------------------------------------------------- 1 | addConnection([ 21 | 'driver' => 'sqlite', 22 | 'database' => ':memory:', 23 | ]); 24 | 25 | $db->setEventDispatcher(new Dispatcher(new Container())); 26 | $db->setAsGlobal(); 27 | $db->bootEloquent(); 28 | 29 | $this->createSchema(); 30 | $this->resetListeners(); 31 | } 32 | 33 | /** 34 | * Setup the database schema. 35 | * 36 | * @return void 37 | */ 38 | public function createSchema() 39 | { 40 | $this->schema()->create('commissions', function ($table) { 41 | $table->increments('id'); 42 | $table->integer('agent_id')->unsigned(); 43 | $table->dateTime('valid_start'); 44 | $table->dateTime('valid_end')->nullable(); 45 | $table->timestamps(); 46 | $table->softDeletes(); 47 | }); 48 | 49 | $this->schema()->create('polymorphic_commissions', function ($table) { 50 | $table->increments('id'); 51 | $table->integer('agent_id')->unsigned(); 52 | $table->string('agent_type'); 53 | $table->dateTime('valid_start'); 54 | $table->dateTime('valid_end')->nullable(); 55 | $table->timestamps(); 56 | $table->softDeletes(); 57 | }); 58 | } 59 | 60 | /** 61 | * Address a testing issue where model listeners are not reset. 62 | */ 63 | public function resetListeners() 64 | { 65 | TemporalTestCommission::flushEventListeners(); 66 | TemporalTestCommission::registerEvents(); 67 | } 68 | 69 | /** 70 | * Tear down the database schema. 71 | * 72 | * @return void 73 | */ 74 | public function tearDown() 75 | { 76 | $this->schema()->drop('commissions'); 77 | $this->schema()->drop('polymorphic_commissions'); 78 | } 79 | 80 | /** 81 | * Test that it can send out a stats based on the provided data 82 | */ 83 | public function testItCanCheckIfATemporalModelIsValid() 84 | { 85 | $temporalModel = new TemporalTestCommission([ 86 | 'agent_id' => 2, 87 | 'valid_start' => Carbon::now(), 88 | 'valid_end' => null 89 | ]); 90 | 91 | $this->assertTrue($temporalModel->isValid()); 92 | 93 | $temporalModel = new TemporalTestCommission([ 94 | 'agent_id' => 1, 95 | 'valid_start' => Carbon::now()->subYear(), 96 | 'valid_end' => Carbon::now()->subMonth() 97 | ]); 98 | 99 | $this->assertFalse($temporalModel->isValid()); 100 | } 101 | 102 | /** 103 | * Test that the dates must be valid. 104 | */ 105 | public function testItCannotSaveWithAStartDateInThePast() 106 | { 107 | $this->expectException(InvalidDateRangeException::class); 108 | 109 | $stub = new TemporalTestCommission(); 110 | $stub->agent_id = 1; 111 | $stub->valid_start = Carbon::now()->subDays(5); 112 | $stub->save(); 113 | } 114 | 115 | /** 116 | * Test that the dates must be valid. 117 | */ 118 | public function testItCannotSaveWithAStartDateAfterTheEndDate() 119 | { 120 | $this->expectException(InvalidDateRangeException::class); 121 | 122 | $stub = new TemporalTestCommission(); 123 | $stub->agent_id = 1; 124 | $stub->valid_start = Carbon::now()->addDay(); 125 | $stub->valid_end = Carbon::now(); 126 | $stub->save(); 127 | } 128 | 129 | /** 130 | * Tests... 131 | */ 132 | public function testItEndsTheCurrentCommissionIfANewOneIsCreatedThatOverlaps() 133 | { 134 | $currentCommission = $this->createCommission(); 135 | 136 | $newCommission = TemporalTestCommission::create([ 137 | 'id' => 2, 138 | 'agent_id' => 1, 139 | 'valid_start' => Carbon::now(), 140 | 'valid_end' => null 141 | ]); 142 | 143 | $this->assertEquals($newCommission->valid_start, $currentCommission->fresh()->valid_end); 144 | } 145 | 146 | /** 147 | * Tests... 148 | */ 149 | public function testItRemovesAScheduledCommissionWhenANewOneIsCreated() 150 | { 151 | $scheduledCommission = TemporalTestCommission::create([ 152 | 'id' => 2, 153 | 'agent_id' => 1, 154 | 'valid_start' => Carbon::now()->addDay(), 155 | 'valid_end' => null 156 | ]); 157 | TemporalTestCommission::create([ 158 | 'id' => 3, 159 | 'agent_id' => 1, 160 | 'valid_start' => Carbon::now(), 161 | 'valid_end' => null 162 | ]); 163 | 164 | $this->assertNull($scheduledCommission->fresh()); 165 | } 166 | 167 | /** 168 | * Tests... 169 | */ 170 | public function testItOnlyRemovesAScheduledCommissionWhenThereIsAConflict() 171 | { 172 | $scheduledCommission = TemporalTestCommission::create([ 173 | 'id' => 2, 174 | 'agent_id' => 1, 175 | 'valid_start' => Carbon::now()->addDays(20), 176 | 'valid_end' => null 177 | ]); 178 | TemporalTestCommission::create([ 179 | 'id' => 3, 180 | 'agent_id' => 1, 181 | 'valid_start' => Carbon::now(), 182 | 'valid_end' => Carbon::now()->addDays(19) 183 | ]); 184 | 185 | $this->assertNotNull($scheduledCommission->fresh()); 186 | } 187 | 188 | /** 189 | * Tests... 190 | */ 191 | public function testItCorrectlySetsTheValidEndOfTheCurrentWhenThereIsASchedulingConflict() 192 | { 193 | $currentCommission = TemporalTestCommission::create([ 194 | 'id' => 3, 195 | 'agent_id' => 1, 196 | 'valid_start' => Carbon::now(), 197 | 'valid_end' => Carbon::now()->addDays(20) 198 | ]); 199 | TemporalTestCommission::create([ 200 | 'id' => 2, 201 | 'agent_id' => 1, 202 | 'valid_start' => Carbon::now()->addDays(15), 203 | 'valid_end' => null 204 | ]); 205 | 206 | $this->assertEquals( 207 | Carbon::now()->addDays(15)->toDateString(), 208 | $currentCommission->fresh()->valid_end->toDateString() 209 | ); 210 | } 211 | 212 | /** 213 | * Tests... 214 | */ 215 | public function testItRemovesAScheduledPolymorphicCommissionWhenANewOneIsCreated() 216 | { 217 | $scheduledCommission = PolymorphicTemporalTestCommission::create([ 218 | 'id' => 2, 219 | 'agent_id' => 1, 220 | 'agent_type' => 'NavJobs\Temporal\Agent', 221 | 'valid_start' => Carbon::now()->addDay(), 222 | 'valid_end' => null 223 | ]); 224 | PolymorphicTemporalTestCommission::create([ 225 | 'id' => 3, 226 | 'agent_id' => 1, 227 | 'agent_type' => 'NavJobs\Temporal\Agent', 228 | 'valid_start' => Carbon::now(), 229 | 'valid_end' => null 230 | ]); 231 | 232 | $this->assertNull($scheduledCommission->fresh()); 233 | } 234 | 235 | /** 236 | * Tests... 237 | */ 238 | public function testItOnlyAllowsValidEndToBeUpdated() 239 | { 240 | $commission = $this->createCommission(); 241 | $commission->valid_start = Carbon::now()->addYear(); 242 | $commission->save(); 243 | $commission = $commission->fresh(); 244 | 245 | $this->assertEquals(Carbon::now()->subDays(10)->toDateString(), $commission->valid_start->toDateString()); 246 | 247 | $commission->agent_id = 30; 248 | $commission->save(); 249 | $commission = $commission->fresh(); 250 | 251 | $this->assertEquals(1, $commission->agent_id); 252 | 253 | $expectedEnd = Carbon::now()->addDay(10); 254 | $commission->valid_end = $expectedEnd; 255 | $commission->save(); 256 | $commission = $commission->fresh(); 257 | 258 | $this->assertEquals($expectedEnd->toDateString(), $commission->valid_end->toDateString()); 259 | 260 | //But it will not save if the valid end is in the past. 261 | $commission->valid_end = Carbon::now()->subDay(); 262 | $commission->save(); 263 | $commission = $commission->fresh(); 264 | 265 | $this->assertEquals($expectedEnd->toDateString(), $commission->valid_end->toDateString()); 266 | } 267 | 268 | /** 269 | * Tests... 270 | */ 271 | public function testItCanUpdateIfTheUserHasSpecifiedToAllowUpdates() 272 | { 273 | $commission = $this->createCommission(); 274 | $commission->valid_start = Carbon::now()->addYear(); 275 | $commission->enableUpdates()->save(); 276 | $commission = $commission->fresh(); 277 | 278 | $this->assertEquals(Carbon::now()->addYear()->toDateString(), $commission->valid_start->toDateString()); 279 | 280 | $commission->agent_id = 30; 281 | $commission->enableUpdates()->save(); 282 | $commission = $commission->fresh(); 283 | 284 | $this->assertEquals(30, $commission->agent_id); 285 | } 286 | 287 | /** 288 | * Tests... 289 | */ 290 | public function testItOnlyEndsACommissionInsteadOfDeletingWhenItHasAlreadyStarted() 291 | { 292 | $commission = $this->createCommission(); 293 | $commission->delete(); 294 | $commission = $commission->fresh(); 295 | 296 | $this->assertEquals(Carbon::now()->toDateString(), $commission->valid_end->toDateString()); 297 | } 298 | 299 | /** 300 | * Tests... 301 | */ 302 | public function testItDeletesACommissionCompletelyIfItHasNotStartedYet() 303 | { 304 | $commission = TemporalTestCommission::create([ 305 | 'id' => 3, 306 | 'agent_id' => 1, 307 | 'valid_start' => Carbon::now()->addYear(), 308 | 'valid_end' => null 309 | ]); 310 | $commission->delete(); 311 | $commission = $commission->fresh(); 312 | 313 | $this->assertNull($commission); 314 | } 315 | 316 | /** 317 | * Tests... 318 | */ 319 | public function testItCanDeleteIfTheUserHasSpecifiedToAllowUpdates() 320 | { 321 | $commission = $this->createCommission(); 322 | $commission->enableUpdates()->delete(); 323 | $commission = $commission->fresh(); 324 | 325 | $this->assertNull($commission); 326 | } 327 | 328 | /** 329 | * Tests... 330 | */ 331 | public function testItReceivesValidResultsFromValidScope() 332 | { 333 | TemporalTestCommission::flushEventListeners(); 334 | collect([ 335 | [ 336 | 'id' => 5, 337 | 'agent_id' => 1, 338 | 'valid_start' => Carbon::now()->subYear(), 339 | 'valid_end' => Carbon::now(), 340 | ], 341 | [ 342 | 'id' => 6, 343 | 'agent_id' => 1, 344 | 'valid_start' => Carbon::now(), 345 | 'valid_end' => Carbon::now()->addYear(), 346 | ], 347 | [ 348 | 'id' => 7, 349 | 'agent_id' => 1, 350 | 'valid_start' => Carbon::now()->addYear(), 351 | 'valid_end' => null, 352 | ], 353 | ])->each(function ($commission) { 354 | TemporalTestCommission::create($commission); 355 | }); 356 | TemporalTestCommission::registerEvents(); 357 | 358 | $this->assertEquals(6, TemporalTestCommission::valid()->first()->id); 359 | } 360 | 361 | /** 362 | * Tests... 363 | */ 364 | public function testItReceivesInvalidResultsFromInvalidScope() 365 | { 366 | TemporalTestCommission::flushEventListeners(); 367 | collect([ 368 | [ 369 | 'id' => 5, 370 | 'agent_id' => 1, 371 | 'valid_start' => Carbon::now()->subYear(), 372 | 'valid_end' => Carbon::now()->subMinute(), 373 | ], 374 | [ 375 | 'id' => 6, 376 | 'agent_id' => 1, 377 | 'valid_start' => Carbon::now(), 378 | 'valid_end' => Carbon::now()->addYear(), 379 | ], 380 | [ 381 | 'id' => 7, 382 | 'agent_id' => 1, 383 | 'valid_start' => Carbon::now()->addYear(), 384 | 'valid_end' => null, 385 | ], 386 | ])->each(function ($commission) { 387 | TemporalTestCommission::create($commission); 388 | }); 389 | TemporalTestCommission::registerEvents(); 390 | 391 | $this->assertEquals([5, 7], TemporalTestCommission::invalid()->pluck('id')->toArray()); 392 | } 393 | 394 | /** 395 | * Helpers... 396 | */ 397 | protected function createCommission() 398 | { 399 | TemporalTestCommission::flushEventListeners(); 400 | $commission = TemporalTestCommission::create([ 401 | 'id' => 1, 402 | 'agent_id' => 1, 403 | 'valid_start' => Carbon::now()->subDays(10), 404 | 'valid_end' => null 405 | ]); 406 | TemporalTestCommission::registerEvents(); 407 | 408 | return $commission; 409 | } 410 | 411 | /** 412 | * Helpers... 413 | */ 414 | protected function createPolymorphicCommission() 415 | { 416 | TemporalTestCommission::flushEventListeners(); 417 | $commission = TemporalTestCommission::create([ 418 | 'id' => 1, 419 | 'agent_id' => 1, 420 | 'agent_type' => 'NavJobs\Temporal\Agent', 421 | 'valid_start' => Carbon::now()->subDays(10), 422 | 'valid_end' => null 423 | ]); 424 | TemporalTestCommission::registerEvents(); 425 | 426 | return $commission; 427 | } 428 | 429 | /** 430 | * Get a database connection instance. 431 | * 432 | * @return Connection 433 | */ 434 | protected function connection() 435 | { 436 | return Eloquent::getConnectionResolver()->connection(); 437 | } 438 | 439 | /** 440 | * Get a schema builder instance. 441 | * 442 | * @return Schema\Builder 443 | */ 444 | protected function schema() 445 | { 446 | return $this->connection()->getSchemaBuilder(); 447 | } 448 | } 449 | 450 | /** 451 | * Eloquent Models... 452 | */ 453 | class TemporalTestCommission extends Eloquent 454 | { 455 | use Temporal; 456 | 457 | protected $dates = ['valid_start', 'valid_end', 'deleted_at']; 458 | protected $table = 'commissions'; 459 | protected $guarded = []; 460 | protected $temporalParentColumn = 'agent_id'; 461 | } 462 | 463 | /** 464 | * Eloquent Models... 465 | */ 466 | class PolymorphicTemporalTestCommission extends Eloquent 467 | { 468 | use Temporal; 469 | 470 | protected $dates = ['valid_start', 'valid_end', 'deleted_at']; 471 | protected $table = 'polymorphic_commissions'; 472 | protected $guarded = []; 473 | protected $temporalParentColumn = 'agent_id'; 474 | protected $temporalPolymorphicTypeColumn = 'agent_type'; 475 | } 476 | --------------------------------------------------------------------------------