├── .github └── workflows │ └── tests.yml ├── .gitignore ├── composer.json ├── composer.lock ├── phpunit.xml ├── phpunit.xml.bak ├── readme.md ├── src ├── Database │ ├── Blueprint.php │ └── Migration.php ├── Exceptions │ └── ReadOnlyException.php ├── Models │ ├── BaseModel.php │ ├── BasePivotModel.php │ ├── BelongsToMany.php │ ├── Traits │ │ ├── NoDeletesModel.php │ │ ├── NoUpdatesModel.php │ │ └── ReadOnlyModel.php │ ├── User.php │ └── Version.php ├── Scopes │ ├── CreatedAtOrderScope.php │ └── SoftDeletingScope.php ├── VersionControlServiceProvider.php └── config │ └── version-control.php └── tests ├── Fixtures ├── Models │ ├── Job.php │ ├── Permission.php │ ├── PermissionRole.php │ ├── Post.php │ ├── Role.php │ ├── TouchingPermission.php │ └── User.php └── database │ ├── factories │ ├── JobFactory.php │ ├── PermissionFactory.php │ ├── RoleFactory.php │ └── UserFactory.php │ └── migrations │ ├── 0000_00_00_0000001_create_jobs_table.php │ ├── 0000_00_00_0000002_create_permission_role_table.php │ ├── 0000_00_00_0000003_create_permissions_table.php │ ├── 0000_00_00_0000005_create_roles_table.php │ └── 0000_00_00_0000006_create_users_table.php ├── ManyToManyTest.php ├── OneToMany.php ├── OneToOneTest.php ├── TestCase.php └── VersionControlBaseModelTest.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | tests: 13 | name: Run tests 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | php: [8.1, 8.2, 8.3] 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v2 23 | 24 | - name: Set up PHP 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: ${{ matrix.php }} 28 | extensions: mbstring, sqlite3, pdo_sqlite, json, tokenizer, xml, curl, zip 29 | 30 | - name: Install Composer dependencies 31 | run: composer install --prefer-dist --no-progress --no-suggest 32 | 33 | - name: Run tests 34 | run: vendor/bin/phpunit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea/ 3 | .DS_Store 4 | .phpunit.result.cache 5 | /.phpunit.cache -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rs/laravel-version-control", 3 | "description": "Foundations for making your app version controlled. Provides migration, blueprint and base models. Will make your app GxP compliant if you exclusively use the VC models and table structure as set out in this package.", 4 | "require": { 5 | "php": "^8.0" 6 | }, 7 | "autoload": { 8 | "psr-4": { 9 | "Redsnapper\\LaravelVersionControl\\": "src" 10 | } 11 | }, 12 | "autoload-dev": { 13 | "psr-4": { 14 | "Redsnapper\\LaravelVersionControl\\Tests\\": "tests/" 15 | } 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^10.0", 19 | "mockery/mockery": "^1.0", 20 | "orchestra/testbench": "^8.0", 21 | "laravel/legacy-factories": "^1.4" 22 | }, 23 | "authors": [ 24 | { 25 | "name": "Red Snapper Development Team", 26 | "email": "contact@redsnapper.net" 27 | } 28 | ], 29 | "minimum-stability": "dev", 30 | "extra": { 31 | "laravel": { 32 | "providers": [ 33 | "Redsnapper\\LaravelVersionControl\\VersionControlServiceProvider" 34 | ] 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | src/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /phpunit.xml.bak: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 22 | 23 | src/ 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Laravel Version Control 2 | 3 | This package provides base models to use to make your app Version Control. It will also meet GxP compliance requirements. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | composer require rs/laravel-version-control 9 | ``` 10 | 11 | If you wish to adjust the installation you can publish the assets 12 | 13 | `php artisan vendor:publish` to see publishing options, choose the appropriate option to publish this packages assets. 14 | 15 | ## Migrations 16 | 17 | You should setup your migrations to follow the migrations as seen in the tests/Fixtures/database/migrations files. 18 | For each model 2 tables will be created, the key (normal) table and the version history table. 19 | 20 | Example migration 21 | 22 | ```php 23 | 24 | use Redsnapper\LaravelVersionControl\Database\Blueprint; 25 | use Redsnapper\LaravelVersionControl\Database\Migration; 26 | 27 | class CreateUsersTable extends Migration 28 | { 29 | /** 30 | * Run the migrations. 31 | * 32 | * @return void 33 | */ 34 | public function up() 35 | { 36 | $this->makeVcTables("users",function(Blueprint $table){ 37 | $table->string('email')->unique(); 38 | $table->string('password'); 39 | },function(Blueprint $table){ 40 | $table->string('email'); 41 | $table->string('password'); 42 | }); 43 | } 44 | } 45 | ``` 46 | 47 | Note we are using are own custom Migration and Blueprint class. 48 | This will create 2 tables: The users table and a corresponding users_versions table. 49 | The 3rd parameter is optional and will fallback to the fields in the second parameter. 50 | 51 | ### Version Control Models 52 | 53 | Each model you create should extend the Redsnapper\LaravelVersionControl\Models\BaseModel 54 | 55 | ```php 56 | use Redsnapper\LaravelVersionControl\Models\BaseModel; 57 | 58 | class Post extends BaseModel 59 | { 60 | } 61 | 62 | ``` 63 | 64 | 65 | ### 'Pivot' tables 66 | 67 | Pivot table records are never destroyed. On creation they persist as records for the lifecycle of the project. 68 | Instead whenever a record is detached an active flag is switched to false. 69 | 70 | ### Versions relationship 71 | 72 | Versions can be accessed from models using the versions relationship. 73 | 74 | ```php 75 | $model->versions(); 76 | ``` 77 | 78 | ### Anonymize 79 | To anonymize any field for any model pass an array of the fields to be anonymized as below. 80 | ```php 81 | $model->anonymize(['email'=>'anon@example.com']); 82 | ``` 83 | This will create a new version for the action and will anonymize the fields passed. This will anonymize all versions attached to this model. 84 | -------------------------------------------------------------------------------- /src/Database/Blueprint.php: -------------------------------------------------------------------------------- 1 | uuid('uid')->unique(); 13 | $this->uuid('vc_version_uid'); 14 | $this->boolean('vc_active')->default(1); 15 | $this->primary('uid', "{$tableName}_vc_primary_key"); 16 | } 17 | 18 | public function vcVersionTableColumns($tableName) 19 | { 20 | $this->uuid('uid'); 21 | $this->uuid('model_uid'); 22 | $this->uuid('vc_parent')->nullable(); 23 | $this->boolean('vc_active')->default(true); 24 | $this->uuid('vc_modifier_uid')->nullable(); 25 | $this->primary('uid', "{$tableName}_vc_primary_key"); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Database/Migration.php: -------------------------------------------------------------------------------- 1 | schema = app()->make('db')->connection()->getSchemaBuilder(); 18 | 19 | if($this->blueprint) { 20 | $this->schema->blueprintResolver(function ($table, $callback) { 21 | return new $this->blueprint($table, $callback); 22 | }); 23 | } 24 | } 25 | 26 | /** 27 | * Makes the two required tables for a vc model complete with default vc fields and timestamps for each 28 | * Returns the two newly created vc table names 29 | * 30 | * @param string $modelName 31 | * @return array 32 | */ 33 | public function makeVcTables(string $tableName,Closure $modelClosure = null, Closure $versionClosure = null) 34 | { 35 | 36 | $this->schema->create($tableName, function (Blueprint $table) use ($tableName,$modelClosure) { 37 | $table->vcKeyTableColumns($tableName); 38 | $table->timestamps(); 39 | if(is_callable($modelClosure)){ 40 | $modelClosure($table); 41 | } 42 | }); 43 | 44 | $closure = $versionClosure ?? $modelClosure; 45 | 46 | $versionsTableName = $this->getVersionTableName($tableName); 47 | 48 | $this->schema->create($versionsTableName, function (Blueprint $table) use ($versionsTableName,$closure) { 49 | $table->vcVersionTableColumns($versionsTableName); 50 | $table->timestamp('created_at')->nullable(); 51 | if(is_callable($closure)){ 52 | $closure($table); 53 | } 54 | }); 55 | 56 | return ["{$tableName}_versions", $tableName]; 57 | } 58 | 59 | 60 | public function dropVcTables(string $tableName) 61 | { 62 | $this->schema->dropIfExists($tableName); 63 | $this->schema->dropIfExists($this->getVersionTableName($tableName)); 64 | } 65 | 66 | private function getVersionTableName($tableName) 67 | { 68 | return Pluralizer::singular($tableName) . "_versions"; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Exceptions/ReadOnlyException.php: -------------------------------------------------------------------------------- 1 | isDirty() && $this->exists) { 46 | return true; 47 | } 48 | 49 | $version = $this->getVersionInstance(); 50 | 51 | if (!$this->exists) { 52 | $version->createFromNew($this->attributes); 53 | } else { 54 | $version->createFromExisting($this->attributes); 55 | } 56 | 57 | if ($version->save()) { 58 | $this->uid = $version->model_uid; 59 | $this->vc_version_uid = $version->uid; 60 | $this->vc_active = $version->vc_active; 61 | 62 | return true; 63 | } 64 | 65 | return false; 66 | } 67 | 68 | /** 69 | * Name of the versions table 70 | * 71 | * @return string 72 | */ 73 | public function getVersionsTable(): string 74 | { 75 | return Pluralizer::singular($this->getTable())."_versions"; 76 | } 77 | 78 | /** 79 | * Get version model with table set 80 | * 81 | * @return Version 82 | */ 83 | private function getVersionInstance(): Version 84 | { 85 | $class = config('version-control.version_model'); 86 | $versionClass = new $class; 87 | return $versionClass->setTable($this->getVersionsTable()); 88 | } 89 | 90 | /** 91 | * Get all versions 92 | * 93 | * @return HasMany 94 | */ 95 | public function versions(): HasMany 96 | { 97 | $instance = $this->getVersionInstance(); 98 | 99 | $foreignKey = "model_uid"; 100 | $localKey = "uid"; 101 | 102 | return $this->newHasMany( 103 | $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey 104 | ); 105 | } 106 | 107 | /** 108 | * Get the current version 109 | * 110 | * @return HasOne 111 | */ 112 | public function currentVersion(): HasOne 113 | { 114 | $instance = $this->getVersionInstance(); 115 | 116 | $foreignKey = "uid"; 117 | $localKey = "vc_version_uid"; 118 | 119 | return $this->newHasOne($instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey); 120 | } 121 | 122 | /** 123 | * Is the model active 124 | * 125 | * @param $value 126 | * @return bool 127 | */ 128 | public function getVcActiveAttribute($value) 129 | { 130 | return (bool) $value; 131 | } 132 | 133 | /** 134 | * Perform the actual delete query on this model instance. 135 | * 136 | * @return mixed 137 | */ 138 | protected function performDeleteOnModel() 139 | { 140 | $this->vc_active = false; 141 | $this->save(); 142 | 143 | $this->exists = false; 144 | } 145 | 146 | /** 147 | * Perform a model insert operation. 148 | * 149 | * @param \Illuminate\Database\Eloquent\Builder $query 150 | * @return bool 151 | */ 152 | protected function performInsert(Builder $query) 153 | { 154 | if ($this->fireModelEvent('creating') === false) { 155 | return false; 156 | } 157 | 158 | if(!$this->createVersion()){ 159 | return false; 160 | } 161 | 162 | // First we'll need to create a fresh query instance and touch the creation and 163 | // update timestamps on this model, which are maintained by us for developer 164 | // convenience. After, we will just continue saving these model instances. 165 | if ($this->usesTimestamps()) { 166 | $this->updateTimestamps(); 167 | } 168 | 169 | // If the model has an incrementing key, we can use the "insertGetId" method on 170 | // the query builder, which will give us back the final inserted ID for this 171 | // table from the database. Not all tables have to be incrementing though. 172 | $attributes = $this->getAttributes(); 173 | 174 | if ($this->getIncrementing()) { 175 | $this->insertAndSetId($query, $attributes); 176 | } 177 | 178 | // If the table isn't incrementing we'll simply insert these attributes as they 179 | // are. These attribute arrays must contain an "id" column previously placed 180 | // there by the developer as the manually determined key for these models. 181 | else { 182 | if (empty($attributes)) { 183 | return true; 184 | } 185 | 186 | $query->insert($attributes); 187 | } 188 | 189 | // We will go ahead and set the exists property to true, so that it is set when 190 | // the created event is fired, just in case the developer tries to update it 191 | // during the event. This will allow them to do so and run an update here. 192 | $this->exists = true; 193 | 194 | $this->wasRecentlyCreated = true; 195 | 196 | $this->fireModelEvent('created', false); 197 | 198 | return true; 199 | } 200 | 201 | /** 202 | * Perform a model update operation. 203 | * 204 | * @param \Illuminate\Database\Eloquent\Builder $query 205 | * @return bool 206 | */ 207 | protected function performUpdate(Builder $query) 208 | { 209 | // If the updating event returns false, we will cancel the update operation so 210 | // developers can hook Validation systems into their models and cancel this 211 | // operation if the model does not pass validation. Otherwise, we update. 212 | if ($this->fireModelEvent('updating') === false) { 213 | return false; 214 | } 215 | 216 | if(!$this->createVersion()){ 217 | return false; 218 | } 219 | 220 | // First we need to create a fresh query instance and touch the creation and 221 | // update timestamp on the model which are maintained by us for developer 222 | // convenience. Then we will just continue saving the model instances. 223 | if ($this->usesTimestamps()) { 224 | $this->updateTimestamps(); 225 | } 226 | 227 | // Once we have run the update operation, we will fire the "updated" event for 228 | // this model instance. This will allow developers to hook into these after 229 | // models are updated, giving them a chance to do any special processing. 230 | $dirty = $this->getDirty(); 231 | 232 | if (count($dirty) > 0) { 233 | $this->setKeysForSaveQuery($query)->update($dirty); 234 | 235 | $this->syncChanges(); 236 | 237 | $this->fireModelEvent('updated', false); 238 | } 239 | 240 | return true; 241 | } 242 | 243 | /** 244 | * Compares the version number on this key row vs latest versioned table row 245 | * 246 | * @return bool 247 | */ 248 | public function validateVersion(): bool 249 | { 250 | return $this->versions()->latest()->first()->uid === $this->vc_version_uid; 251 | } 252 | 253 | /** 254 | * Compares the values in this key table row to the values in the latest versioned table row and validates it is 255 | * equal 256 | * 257 | * @return bool 258 | */ 259 | public function validateData(): bool 260 | { 261 | $me = collect(Arr::except($this->toArray(), ['uid', 'vc_version_uid', 'updated_at', 'created_at'])); 262 | 263 | $difference = $me->diffAssoc($this->versions()->latest()->first()->toArray()); 264 | 265 | return $difference->isEmpty(); 266 | } 267 | 268 | /** 269 | * Restore to this version 270 | * 271 | * @param string|Version $version 272 | * @return BaseModel 273 | */ 274 | public function restore($version) 275 | { 276 | if (is_string($version)) { 277 | $instance = $this->getVersionInstance(); 278 | $version = $instance->findOrFail($version); 279 | } 280 | 281 | return $version->restore($this); 282 | } 283 | 284 | /** 285 | * Anonymize field data 286 | * 287 | * @param array $fields 288 | */ 289 | public function anonymize(array $fields) 290 | { 291 | 292 | // Create a record to anonymize data 293 | $this->update($fields); 294 | 295 | // Update all the version data 296 | $this->versions()->update($fields); 297 | 298 | } 299 | 300 | 301 | /** 302 | * Instantiate a new BelongsToMany relationship. 303 | * 304 | * @param Builder $query 305 | * @param Model $parent 306 | * @param string $table 307 | * @param string $foreignPivotKey 308 | * @param string $relatedPivotKey 309 | * @param string $parentKey 310 | * @param string $relatedKey 311 | * @param string $relationName 312 | * @return BelongsToMany 313 | */ 314 | protected function newBelongsToMany( 315 | Builder $query, 316 | Model $parent, 317 | $table, 318 | $foreignPivotKey, 319 | $relatedPivotKey, 320 | $parentKey, 321 | $relatedKey, 322 | $relationName = null 323 | ) { 324 | return (new BelongsToMany($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, 325 | $relationName))->wherePivot('vc_active', 1); 326 | } 327 | 328 | /** 329 | * Check if the model is deleted or not 330 | * 331 | * @return bool 332 | */ 333 | public function trashed() 334 | { 335 | return !$this->vc_active; 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/Models/BasePivotModel.php: -------------------------------------------------------------------------------- 1 | getExistingPivots(); 55 | $current = $existing->pluck($this->relatedPivotKey)->all(); 56 | $records = $this->formatAttachRecords( 57 | $this->parseIds($id), $attributes 58 | ); 59 | foreach ($records as $record) { 60 | 61 | $id = $record[$this->relatedPivotKey]; 62 | 63 | if (!in_array($id, $current)) { 64 | $this->newPivot($record, false)->save(); 65 | } else { 66 | $this->updateExistingPivot($id, $record, $touch); 67 | } 68 | } 69 | 70 | if ($touch) { 71 | $this->touchIfTouching(); 72 | } 73 | 74 | } 75 | 76 | /** 77 | * Detach models from the relationship. 78 | * 79 | * @param mixed $ids 80 | * @param bool $touch 81 | * @return int 82 | */ 83 | public function detach($ids = null, $touch = true) 84 | { 85 | $ids = $this->parseIds($ids); 86 | 87 | $results = 0; 88 | $records = $this->getExistingPivots()->when(count($ids) > 0, function ($collection) use ($ids) { 89 | return $collection->whereIn($this->relatedPivotKey, $ids); 90 | }); 91 | 92 | foreach ($records as $record) { 93 | $results += $record->delete(); 94 | } 95 | 96 | if ($touch) { 97 | $this->touchIfTouching(); 98 | } 99 | 100 | return $results; 101 | } 102 | 103 | /** 104 | * Sync the intermediate tables with a list of IDs or collection of models. 105 | * 106 | * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids 107 | * @param bool $detaching 108 | * @return array 109 | */ 110 | public function sync($ids, $detaching = true) 111 | { 112 | $changes = [ 113 | 'attached' => [], 114 | 'detached' => [], 115 | 'updated' => [], 116 | ]; 117 | 118 | // First we need to attach any of the associated models that are not currently 119 | // in this joining table. We'll spin through the given IDs, checking to see 120 | // if they exist in the array of current ones, and if not we will insert. 121 | $current = $this->getCurrentlyAttachedPivots() 122 | ->pluck($this->relatedPivotKey)->all(); 123 | 124 | $detach = array_diff($current, array_keys( 125 | $records = $this->formatRecordsList($this->parseIds($ids)) 126 | )); 127 | 128 | // Next, we will take the differences of the currents and given IDs and detach 129 | // all of the entities that exist in the "current" array but are not in the 130 | // array of the new IDs given to the method which will complete the sync. 131 | if ($detaching && count($detach) > 0) { 132 | $this->detach($detach); 133 | 134 | $changes['detached'] = $this->castKeys($detach); 135 | } 136 | 137 | // Now we are finally ready to attach the new records. Note that we'll disable 138 | // touching until after the entire operation is complete so we don't fire a 139 | // ton of touch operations until we are totally done syncing the records. 140 | $changes = array_merge( 141 | $changes, $this->attachNew($records, $current, false) 142 | ); 143 | 144 | // Once we have finished attaching or detaching the records, we will see if we 145 | // have done any attaching or detaching, and if we have we will touch these 146 | // relationships if they are configured to touch on any database updates. 147 | if (count($changes['attached']) || 148 | count($changes['updated'])) { 149 | $this->touchIfTouching(); 150 | } 151 | 152 | return $changes; 153 | } 154 | 155 | /** 156 | * Toggles a model (or models) from the parent. 157 | * 158 | * Each existing model is detached, and non existing ones are attached. 159 | * 160 | * @param mixed $ids 161 | * @param bool $touch 162 | * @return array 163 | */ 164 | public function toggle($ids, $touch = true) 165 | { 166 | $changes = [ 167 | 'attached' => [], 168 | 'detached' => [], 169 | ]; 170 | 171 | $records = $this->formatRecordsList($this->parseIds($ids)); 172 | 173 | $current = $this->getCurrentlyAttachedPivots(); 174 | 175 | // Next, we will determine which IDs should get removed from the join table by 176 | // checking which of the given ID/records is in the list of current records 177 | // and removing all of those rows from this "intermediate" joining table. 178 | $detach = array_values(array_intersect( 179 | $current->pluck($this->relatedPivotKey)->all(), 180 | array_keys($records) 181 | )); 182 | 183 | if (count($detach) > 0) { 184 | $this->detach($detach, false); 185 | 186 | $changes['detached'] = $this->castKeys($detach); 187 | } 188 | 189 | // Finally, for all of the records which were not "detached", we'll attach the 190 | // records into the intermediate table. Then, we will add those attaches to 191 | // this change list and get ready to return these results to the callers. 192 | $attach = array_diff_key($records, array_flip($detach)); 193 | 194 | if (count($attach) > 0) { 195 | $this->attach($attach, [], false); 196 | 197 | $changes['attached'] = array_keys($attach); 198 | } 199 | 200 | // Once we have finished attaching or detaching the records, we will see if we 201 | // have done any attaching or detaching, and if we have we will touch these 202 | // relationships if they are configured to touch on any database updates. 203 | if ($touch && (count($changes['attached']) || 204 | count($changes['detached']))) { 205 | $this->touchIfTouching(); 206 | } 207 | 208 | return $changes; 209 | } 210 | 211 | /** 212 | * 213 | * /** 214 | * Update an existing pivot record on the table. 215 | * 216 | * @param mixed $id 217 | * @param array $attributes 218 | * @param bool $touch 219 | * @return int 220 | */ 221 | public function updateExistingPivot($id, array $attributes, $touch = true) 222 | { 223 | $model = $this->getExistingPivots() 224 | ->firstWhere($this->relatedPivotKey, $id) 225 | ->fill($attributes); 226 | $model->vc_active = true; 227 | 228 | $updated = $model->isDirty(); 229 | 230 | $model->save(); 231 | 232 | if ($touch) { 233 | $this->touchIfTouching(); 234 | } 235 | 236 | return (int)$updated; 237 | } 238 | 239 | /** 240 | * Get the pivot models that are currently attached. 241 | * 242 | * @return \Illuminate\Support\Collection 243 | */ 244 | protected function getCurrentlyAttachedPivots() 245 | { 246 | if (!$this->currentlyAttached) { 247 | $this->currentlyAttached = $this->getExistingPivots()->filter->vc_active; 248 | } 249 | 250 | return $this->currentlyAttached; 251 | } 252 | 253 | /** 254 | * Get the pivot models that are currently attached. 255 | * 256 | * @return \Illuminate\Support\Collection 257 | */ 258 | protected function getExistingPivots() 259 | { 260 | if (!$this->existingPivots) { 261 | $this->existingPivots = $this->newPivotQuery()->get()->map(function ($record) { 262 | return $this->newPivot((array)$record, true); 263 | }); 264 | } 265 | 266 | return $this->existingPivots; 267 | } 268 | 269 | /** 270 | * Create a new query builder for the pivot table. 271 | * 272 | * @return \Illuminate\Database\Query\Builder 273 | */ 274 | public function newPivotQuery() 275 | { 276 | $query = $this->newPivotStatement(); 277 | 278 | foreach ($this->pivotWheres as $arguments) { 279 | if ($arguments[0] !== 'vc_active') { 280 | call_user_func_array([$query, 'where'], $arguments); 281 | } 282 | } 283 | 284 | foreach ($this->pivotWhereIns as $arguments) { 285 | call_user_func_array([$query, 'whereIn'], $arguments); 286 | } 287 | 288 | return $query->where($this->foreignPivotKey, $this->parent->{$this->parentKey}); 289 | } 290 | 291 | /** 292 | * Get the class being used for pivot models. 293 | * 294 | * @return string 295 | */ 296 | public function getPivotClass() 297 | { 298 | return $this->using ?? BasePivotModel::class; 299 | } 300 | 301 | } 302 | -------------------------------------------------------------------------------- /src/Models/Traits/NoDeletesModel.php: -------------------------------------------------------------------------------- 1 | created_at = $model->freshTimestamp(); 64 | $model->uid = $uid; 65 | if (auth()->check()) { 66 | $model->vc_modifier_uid = auth()->user()->uid; 67 | } 68 | }); 69 | } 70 | 71 | /** 72 | * Create version from a new model 73 | * 74 | * @param array $attributes 75 | * @return Version 76 | */ 77 | public function createFromNew(array $attributes): self 78 | { 79 | 80 | $this->forceFill($this->removeBaseModelAttributes($attributes)); 81 | $this->model_uid = (string) Str::uuid(); 82 | $this->vc_active = true; 83 | $this->vc_parent = null; 84 | 85 | return $this; 86 | } 87 | 88 | /** 89 | * Create version from existing model 90 | * 91 | * @param array $attributes 92 | * @return Version 93 | */ 94 | public function createFromExisting(array $attributes): self 95 | { 96 | 97 | $this->forceFill($this->removeBaseModelAttributes($attributes)); 98 | 99 | $this->model_uid = $attributes['uid']; 100 | 101 | // The previous version 102 | $this->vc_parent = $attributes['vc_version_uid']; 103 | 104 | return $this; 105 | } 106 | 107 | /** 108 | * Remove base model attributes 109 | * Needed for seeders as guards are ignored during seeding 110 | * 111 | * @param array $attributes 112 | * @return array 113 | */ 114 | protected function removeBaseModelAttributes(array $attributes): array 115 | { 116 | return Arr::except($attributes, [ 117 | 'vc_version_uid', 118 | 'uid', 119 | 'created_at', 120 | 'updated_at' 121 | ]); 122 | } 123 | 124 | /** 125 | * Parent of this version 126 | * 127 | * @return HasOne 128 | */ 129 | public function parent(): HasOne 130 | { 131 | $instance = $this->newRelatedInstance(Version::class); 132 | $instance->setTable($this->getTable()); 133 | 134 | $foreignKey = $this->getKeyName(); 135 | 136 | $localKey = 'vc_parent'; 137 | 138 | return $this->newHasOne($instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey); 139 | } 140 | 141 | /** 142 | * Restore this version 143 | * 144 | * @param BaseModel $model 145 | * 146 | * @return bool 147 | */ 148 | public function restore(BaseModel $model) 149 | { 150 | 151 | $model->fill( 152 | Arr::except($this->attributes, ['uid', 'model_uid', 'vc_parent', 'created_at']) 153 | ); 154 | 155 | $model->forceFill([ 156 | 'vc_version_uid' => $this->attributes['uid'] 157 | ]); 158 | 159 | return tap($model)->save(); 160 | } 161 | 162 | /** 163 | * User who modified this version 164 | * 165 | * @return BelongsTo 166 | */ 167 | public function modifyingUser(): BelongsTo 168 | { 169 | return $this->belongsTo(config('version-control.user'), 'vc_modifier_uid', 'uid') 170 | ->withDefault(config('version-control.default_modifying_user')); 171 | } 172 | 173 | /** 174 | * Cast vc active to boolean. 175 | * 176 | * @param int $value 177 | * @return bool 178 | */ 179 | public function getVcActiveAttribute($value) 180 | { 181 | return (bool) $value; 182 | } 183 | 184 | /** 185 | * Is this version active 186 | * 187 | * @return bool 188 | */ 189 | public function isActive(): bool 190 | { 191 | return $this->vc_active; 192 | } 193 | 194 | /** 195 | * Was this version a delete action 196 | * 197 | * @return bool 198 | */ 199 | public function isDeleted(): bool 200 | { 201 | return !$this->vc_active; 202 | } 203 | 204 | /** 205 | * Return only model data 206 | * 207 | * @return array 208 | */ 209 | public function toModelArray() 210 | { 211 | return Arr::except($this->attributes, ['uid', 'model_uid', 'vc_parent']); 212 | } 213 | 214 | /** 215 | * Perform the actual delete query on this model instance. 216 | * 217 | * @return mixed 218 | */ 219 | protected function performDeleteOnModel() 220 | { 221 | throw new ReadOnlyException(__FUNCTION__, get_called_class()); 222 | } 223 | 224 | public function scopeWithoutLatestOrder() 225 | { 226 | 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/Scopes/CreatedAtOrderScope.php: -------------------------------------------------------------------------------- 1 | orderBy('created_at', 'desc'); 14 | } 15 | 16 | /** 17 | * @param Builder $builder 18 | */ 19 | public function extend(Builder $builder) 20 | { 21 | $builder->macro('withoutCreatedAtOrder', function (Builder $builder) { 22 | return $builder->withoutGlobalScope($this); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Scopes/SoftDeletingScope.php: -------------------------------------------------------------------------------- 1 | where($model->getTable() . ".vc_active", 1); 14 | } 15 | 16 | /** 17 | * Extend the query builder with the needed functions. 18 | * 19 | * @param \Illuminate\Database\Eloquent\Builder $builder 20 | * @return void 21 | */ 22 | public function extend(Builder $builder) 23 | { 24 | $builder->macro('withTrashed', function (Builder $builder) { 25 | return $builder->withoutGlobalScope($this); 26 | }); 27 | 28 | $builder->macro('onlyTrashed', function (Builder $builder) { 29 | 30 | $builder->withoutGlobalScope($this)->where('vc_active',0); 31 | 32 | return $builder; 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/VersionControlServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([__DIR__ . '/config' => config_path()], 'redsnapper-laravel-version-control'); 27 | //TODO Review whether this is correct 28 | $this->publishes([__DIR__ => app_path()], 'redsnapper-laravel-version-control-src'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/config/version-control.php: -------------------------------------------------------------------------------- 1 | User::class, 8 | 'default_modifying_user' => [], 9 | 'version_model' => Version::class 10 | ]; 11 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/Job.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/Permission.php: -------------------------------------------------------------------------------- 1 | belongsToMany(Role::class) 21 | ->using(PermissionRole::class) 22 | ->withPivot('flag'); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/PermissionRole.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/Role.php: -------------------------------------------------------------------------------- 1 | belongsToMany(Permission::class)->withPivot('flag'); 17 | } 18 | 19 | public function users() 20 | { 21 | return $this->hasMany(User::class); 22 | } 23 | 24 | public function touchingPermissions() 25 | { 26 | return $this->belongsToMany(TouchingPermission::class, 27 | 'permission_role', 28 | 'permission_uid', 29 | 'role_uid')->withPivot('flag'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Fixtures/Models/TouchingPermission.php: -------------------------------------------------------------------------------- 1 | belongsToMany(Role::class,'permission_role', 'permission_uid', 'role_uid') 23 | ->withPivot('flag'); 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /tests/Fixtures/Models/User.php: -------------------------------------------------------------------------------- 1 | uid === auth()->user()->uid; 20 | } 21 | 22 | public function job() 23 | { 24 | return $this->hasOne(Job::class); 25 | } 26 | 27 | /** 28 | * @return BelongsTo 29 | */ 30 | public function role() 31 | { 32 | return $this->belongsTo(Role::class); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Fixtures/database/factories/JobFactory.php: -------------------------------------------------------------------------------- 1 | define(Job::class, function (Faker $faker) { 7 | return [ 8 | 'title' => $this->faker->word, 9 | ]; 10 | }); 11 | -------------------------------------------------------------------------------- /tests/Fixtures/database/factories/PermissionFactory.php: -------------------------------------------------------------------------------- 1 | define(Permission::class, function (Faker $faker) { 7 | return [ 8 | 'name' => $this->faker->jobTitle, 9 | ]; 10 | }); 11 | -------------------------------------------------------------------------------- /tests/Fixtures/database/factories/RoleFactory.php: -------------------------------------------------------------------------------- 1 | define(Role::class, function (Faker $faker) { 7 | return [ 8 | 'name' => $this->faker->jobTitle, 9 | ]; 10 | }); 11 | -------------------------------------------------------------------------------- /tests/Fixtures/database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | define(User::class, function (Faker $faker) { 7 | return [ 8 | 'email' => $this->faker->unique()->safeEmail, 9 | 'password' => 'secret' 10 | ]; 11 | }); 12 | -------------------------------------------------------------------------------- /tests/Fixtures/database/migrations/0000_00_00_0000001_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | makeVcTables("jobs",function(Blueprint $table){ 16 | $table->uuid('user_uid'); 17 | $table->string('title'); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | * 24 | * @return void 25 | */ 26 | public function down() 27 | { 28 | $this->dropVcTables("job"); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Fixtures/database/migrations/0000_00_00_0000002_create_permission_role_table.php: -------------------------------------------------------------------------------- 1 | makeVcTables("permission_role",function(Blueprint $table){ 19 | $table->uuid('permission_uid'); 20 | $table->uuid('role_uid'); 21 | $table->string('flag')->nullable(); 22 | $table->unique(['permission_uid','role_uid']); 23 | },function(Blueprint $table){ 24 | $table->uuid('permission_uid'); 25 | $table->uuid('role_uid'); 26 | $table->string('flag')->nullable(); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | * 33 | * @return void 34 | */ 35 | public function down() 36 | { 37 | $this->dropVcTables("permission_role"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Fixtures/database/migrations/0000_00_00_0000003_create_permissions_table.php: -------------------------------------------------------------------------------- 1 | makeVcTables("permissions",function(Blueprint $table){ 18 | $table->string('name')->unique(); 19 | },function(Blueprint $table){ 20 | $table->string('name'); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | $this->dropVcTables("permission"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Fixtures/database/migrations/0000_00_00_0000005_create_roles_table.php: -------------------------------------------------------------------------------- 1 | makeVcTables("roles",function(Blueprint $table){ 19 | $table->string('name'); 20 | }); 21 | 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | $this->dropVcTables("role"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Fixtures/database/migrations/0000_00_00_0000006_create_users_table.php: -------------------------------------------------------------------------------- 1 | makeVcTables("users",function(Blueprint $table){ 16 | $table->uuid('role_uid')->nullable(); 17 | $table->string('email')->unique(); 18 | $table->string('password'); 19 | $table->rememberToken(); 20 | },function(Blueprint $table){ 21 | $table->uuid('role_uid')->nullable(); 22 | $table->string('email'); 23 | $table->string('password'); 24 | $table->rememberToken(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | $this->dropVcTables("user"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/ManyToManyTest.php: -------------------------------------------------------------------------------- 1 | create(); 24 | $permissionA = factory(Permission::class)->create(); 25 | 26 | $role->permissions()->attach($permissionA); 27 | 28 | $this->assertCount(1,$role->permissions); 29 | $this->assertTrue($role->permissions->first()->is($permissionA)); 30 | $this->assertEquals($date,$role->permissions->first()->pivot->created_at); 31 | 32 | } 33 | 34 | /** @test */ 35 | public function can_attach_a_pivot_relation_using_a_model_with_pivot_data() 36 | { 37 | $role = factory(Role::class)->create(); 38 | $permission = factory(Permission::class)->create(); 39 | 40 | $role->permissions()->attach($permission,['flag' => 'foo']); 41 | 42 | $attached = $role->permissions->first(); 43 | $this->assertTrue($attached->is($permission)); 44 | $this->assertEquals('foo',$attached->pivot->flag); 45 | 46 | } 47 | 48 | /** @test */ 49 | public function can_attach_multiple_using_attach() 50 | { 51 | $role = factory(Role::class)->create(); 52 | $permissionA = factory(Permission::class)->create(); 53 | $permissionB = factory(Permission::class)->create(); 54 | 55 | $role->permissions()->attach([ 56 | $permissionA->uid => ['flag'=>'A'], 57 | $permissionB->uid => ['flag'=>'B'], 58 | ]); 59 | 60 | $permissions = $role->permissions()->orderBy('flag')->get(); 61 | 62 | $this->assertCount(2,$permissions); 63 | $this->assertEquals('A',$permissions->first()->pivot->flag); 64 | 65 | } 66 | 67 | /** @test */ 68 | public function can_attach_using_custom_pivot_model() 69 | { 70 | $role = factory(Role::class)->create(); 71 | $permission = factory(Permission::class)->create(); 72 | 73 | $permission->roles()->attach($role,['flag'=>'foo']); 74 | 75 | $this->assertEquals(PermissionRole::class,$permission->roles()->getPivotClass()); 76 | $this->assertTrue($permission->roles->first()->is($role)); 77 | 78 | } 79 | 80 | /** @test */ 81 | public function versions_work_correctly_when_attaching() 82 | { 83 | $role = factory(Role::class)->create(); 84 | $permissionA = factory(Permission::class)->create(); 85 | 86 | $role->permissions()->attach($permissionA,['flag'=>'foo']); 87 | $this->assertEquals("foo",$role->permissions->first()->pivot->currentVersion->flag); 88 | $this->assertEquals("foo",$role->permissions->first()->pivot->versions->first()->flag); 89 | } 90 | 91 | /** @test */ 92 | public function can_detach_a_pivot_relation() 93 | { 94 | $role = factory(Role::class)->create(); 95 | $permissionA = factory(Permission::class)->create(); 96 | $permissionB = factory(Permission::class)->create(); 97 | 98 | $role->permissions()->attach($permissionA); 99 | $role->permissions()->attach($permissionB); 100 | $this->assertCount(2,$role->permissions); 101 | $permissionCount = $role->permissions()->detach($permissionA); 102 | $this->assertDatabaseHas('permission_role',[ 103 | 'permission_uid'=>$permissionA->uid, 104 | 'role_uid'=>$role->uid, 105 | 'vc_active'=>false 106 | ]); 107 | $this->assertEquals(1,$permissionCount); 108 | $this->assertCount(1,$role->fresh()->permissions); 109 | $this->assertCount(2,$role->permissions->first()->pivot->versions); 110 | } 111 | 112 | /** @test */ 113 | public function can_detach_multiple_relations() 114 | { 115 | $role = factory(Role::class)->create(); 116 | $permissionA = factory(Permission::class)->create(); 117 | $permissionB = factory(Permission::class)->create(); 118 | 119 | $role->permissions()->attach($permissionA); 120 | $role->permissions()->attach($permissionB); 121 | 122 | $permissionCount = $role->permissions()->detach([$permissionA->getKey(),$permissionB->getKey()]); 123 | $this->assertEquals(2,$permissionCount); 124 | $this->assertCount(0,$role->fresh()->permissions); 125 | 126 | } 127 | 128 | /** @test */ 129 | public function calling_detach_without_arguments_detaches_all() 130 | { 131 | $role = factory(Role::class)->create(); 132 | $permissionA = factory(Permission::class)->create(); 133 | $permissionB = factory(Permission::class)->create(); 134 | 135 | $role->permissions()->attach($permissionA); 136 | $role->permissions()->attach($permissionB); 137 | $this->assertCount(2,$role->permissions); 138 | 139 | $role->permissions()->detach(); 140 | 141 | $this->assertCount(0,$role->refresh()->permissions); 142 | 143 | 144 | } 145 | 146 | /** @test */ 147 | public function can_reattach_a_deleted_relation() 148 | { 149 | $role = factory(Role::class)->create(); 150 | $permissionA = factory(Permission::class)->create(); 151 | $role->permissions()->attach($permissionA); 152 | $role->permissions()->detach($permissionA); 153 | $role->permissions()->attach($permissionA); 154 | 155 | $this->assertCount(1,$role->permissions); 156 | $this->assertCount(3,$role->permissions->first()->pivot->versions); 157 | 158 | $role->permissions()->attach($permissionA,['flag'=>'foo']); 159 | $this->assertCount(1,$role->refresh()->permissions); 160 | $this->assertCount(4,$role->permissions->first()->pivot->versions); 161 | 162 | } 163 | 164 | /** @test */ 165 | public function can_sync_pivot_relations() 166 | { 167 | $role = factory(Role::class)->create(); 168 | $permissionA = factory(Permission::class)->create(); 169 | $permissionB = factory(Permission::class)->create(); 170 | 171 | // Add permission A and B 172 | $results = $role->permissions()->sync([$permissionA->getKey(),$permissionB->getKey()]); 173 | 174 | $this->assertCount(2,$results['attached']); 175 | $this->assertContains($permissionA->getKey(),$results['attached']); 176 | $this->assertContains($permissionB->getKey(),$results['attached']); 177 | $this->assertCount(2,$role->permissions); 178 | 179 | // Remove permission B 180 | $results = $role->permissions()->sync($permissionA); 181 | 182 | $this->assertContains($permissionB->getKey(),$results['detached']); 183 | $this->assertCount(1,$role->fresh()->permissions); 184 | 185 | // Update permission A 186 | $results = $role->permissions()->sync([$permissionA->getKey() =>['flag'=>'foo']]); 187 | 188 | $this->assertCount(0,$results['detached']); 189 | $this->assertContains($permissionA->getKey(),$results['updated']); 190 | 191 | $this->assertCount(1,$role->refresh()->permissions); 192 | $this->assertEquals('foo',$role->permissions->first()->pivot->flag); 193 | $this->assertCount(2,$role->permissions->first()->pivot->versions); 194 | 195 | } 196 | 197 | /** @test */ 198 | public function a_reattached_pivot_model_returns_as_attached() 199 | { 200 | $role = factory(Role::class)->create(); 201 | $permission = factory(Permission::class)->create(); 202 | 203 | $role->permissions()->sync($permission); 204 | $role->permissions()->detach($permission); 205 | $results = $role->permissions()->sync([$permission->getKey()=>['flag'=>'foo']]); 206 | $this->assertCount(1,$results['attached']); 207 | $this->assertEquals('foo',$role->permissions->first()->pivot->flag); 208 | 209 | } 210 | 211 | /** @test */ 212 | public function a_sync_where_nothing_changes_results_in_no_change() 213 | { 214 | $role = factory(Role::class)->create(); 215 | $permission = factory(Permission::class)->create(); 216 | 217 | $role->permissions()->sync($permission); 218 | $results = $role->permissions()->sync($permission); 219 | 220 | $this->assertCount(0,$results['attached']); 221 | $this->assertCount(0,$results['updated']); 222 | $this->assertCount(1,$role->permissions->first()->pivot->versions); 223 | } 224 | 225 | /** @test */ 226 | public function can_sync_without_detaching() 227 | { 228 | 229 | $role = factory(Role::class)->create(); 230 | $permissionA = factory(Permission::class)->create(); 231 | $permissionB = factory(Permission::class)->create(); 232 | 233 | $role->permissions()->sync($permissionA); 234 | $role->permissions()->syncWithoutDetaching($permissionB); 235 | 236 | $this->assertCount(2,$role->permissions); 237 | 238 | } 239 | 240 | /** @test */ 241 | public function can_update_existing_pivot() 242 | { 243 | $role = factory(Role::class)->create(); 244 | $permission = factory(Permission::class)->create(); 245 | 246 | $role->permissions()->attach($permission,['flag'=>'foo']); 247 | $role->permissions()->updateExistingPivot($permission->getKey(),['flag'=>'bar']); 248 | $this->assertEquals('bar',$role->permissions->first()->pivot->flag); 249 | $this->assertCount(2,$role->permissions->first()->pivot->versions); 250 | } 251 | 252 | /** @test */ 253 | public function use_the_save_of_the_pivot_relationship() 254 | { 255 | $role = factory(Role::class)->create(); 256 | $permission = factory(Permission::class)->create(); 257 | 258 | $role->permissions()->save($permission,['flag'=>'foo']); 259 | 260 | $this->assertEquals('foo',$role->permissions->first()->pivot->flag); 261 | } 262 | 263 | /** @test */ 264 | public function test_toggle_method() 265 | { 266 | $role = factory(Role::class)->create(); 267 | $permissionA = factory(Permission::class)->create(); 268 | $permissionB = factory(Permission::class)->create(); 269 | 270 | $role->permissions()->toggle([$permissionA->uid]); 271 | 272 | $this->assertEquals( 273 | Permission::whereIn('uid', [$permissionA->uid])->pluck('name'), 274 | $role->load('permissions')->permissions->pluck('name') 275 | ); 276 | 277 | $role->permissions()->toggle([$permissionB->getKey(), $permissionA->getKey()]); 278 | $this->assertEquals( 279 | Permission::whereIn('uid', [$permissionB->uid])->pluck('name'), 280 | $role->load('permissions')->permissions->pluck('name') 281 | ); 282 | 283 | $role->permissions()->toggle([$permissionB->getKey(), $permissionA->getKey() => ['flag' => 'foo']]); 284 | 285 | $this->assertEquals( 286 | Permission::whereIn('uid', [$permissionA->getKey()])->pluck('name'), 287 | $role->load('permissions')->permissions->pluck('name') 288 | ); 289 | $this->assertEquals('foo', $role->permissions[0]->pivot->flag); 290 | $this->assertCount(3,$role->permissions[0]->pivot->versions); 291 | 292 | } 293 | 294 | /** @test */ 295 | public function test_first_method() 296 | { 297 | $role = factory(Role::class)->create(); 298 | $permission = factory(Permission::class)->create(); 299 | $role->permissions()->attach(Permission::all()); 300 | $this->assertEquals($permission->name, $role->permissions()->first()->name); 301 | } 302 | 303 | /** @test */ 304 | public function test_firstOrFail_method() 305 | { 306 | $this->expectException(ModelNotFoundException::class); 307 | $role = factory(Role::class)->create(); 308 | $role->permissions()->firstOrFail(['uid' => 10]); 309 | } 310 | 311 | /** @test */ 312 | public function test_find_method() 313 | { 314 | $role = factory(Role::class)->create(); 315 | $permissionA = factory(Permission::class)->create(); 316 | $permissionB = factory(Permission::class)->create(); 317 | $role->permissions()->attach(Permission::all()); 318 | $this->assertEquals($permissionB->name, $role->permissions()->find($permissionB->uid)->name); 319 | $this->assertCount(2, $role->permissions()->findMany([$permissionA->uid, $permissionB->uid])); 320 | } 321 | 322 | /** @test */ 323 | public function test_findOrFail_method() 324 | { 325 | $this->expectException(ModelNotFoundException::class); 326 | $role = factory(Role::class)->create(); 327 | $permission = factory(Permission::class)->create(); 328 | $role->permissions()->attach(Permission::all()); 329 | $role->permissions()->findOrFail(10); 330 | } 331 | 332 | /** @test */ 333 | public function test_findOrNew_method() 334 | { 335 | $role = factory(Role::class)->create(); 336 | $permission = factory(Permission::class)->create(); 337 | $role->permissions()->attach(Permission::all()); 338 | 339 | $this->assertEquals($permission->uid, $role->permissions()->findOrNew($permission->uid)->uid); 340 | $this->assertNull($role->permissions()->findOrNew('asd')->uid); 341 | $this->assertInstanceOf(Permission::class, $role->permissions()->findOrNew('asd')); 342 | } 343 | 344 | /** @test */ 345 | public function test_firstOrCreate_method() 346 | { 347 | $role = factory(Role::class)->create(); 348 | $permission = factory(Permission::class)->create(); 349 | $role->permissions()->attach(Permission::all()); 350 | $this->assertEquals($permission->uid, $role->permissions()->firstOrCreate(['name' => $permission->name])->uid); 351 | $new = $role->permissions()->firstOrCreate(['name' => 'wavez']); 352 | $this->assertEquals('wavez', $new->name); 353 | $this->assertNotNull($new->uid); 354 | } 355 | 356 | /** @test */ 357 | public function test_updateOrCreate_method() 358 | { 359 | $role = factory(Role::class)->create(); 360 | $permission = factory(Permission::class)->create(); 361 | $role->permissions()->attach(Permission::all()); 362 | $role->permissions()->updateOrCreate([$permission->getTable() . '.uid' => $permission->uid], ['name' => 'wavez']); 363 | $this->assertEquals('wavez', $permission->fresh()->name); 364 | $role->permissions()->updateOrCreate([$permission->getTable() . '.uid' => 'asd'], ['name' => 'dives']); 365 | $this->assertNotNull($role->permissions()->whereName('dives')->first()); 366 | } 367 | 368 | /** @test */ 369 | public function test_touching_related_models_on_sync() 370 | { 371 | $permission = TouchingPermission::create(['name' => Str::random()]); 372 | $role = factory(Role::class)->create(); 373 | $this->assertNotEquals('2017-10-10 10:10:10', $role->fresh()->updated_at->toDateTimeString()); 374 | $this->assertNotEquals('2017-10-10 10:10:10', $permission->fresh()->updated_at->toDateTimeString()); 375 | Carbon::setTestNow('2017-10-10 10:10:10'); 376 | $permission->roles()->sync([$role->uid]); 377 | $this->assertEquals('2017-10-10 10:10:10', $role->fresh()->updated_at->toDateTimeString()); 378 | $this->assertEquals('2017-10-10 10:10:10', $permission->fresh()->updated_at->toDateTimeString()); 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /tests/OneToMany.php: -------------------------------------------------------------------------------- 1 | create(); 15 | $userA = factory(User::class)->create(['role_uid' => $role->uid]); 16 | $userB = factory(User::class)->create(['role_uid' => $role->uid]); 17 | 18 | $this->assertTrue($userA->role->is($role)); 19 | $this->assertCount(2,$role->users); 20 | 21 | $userB->delete(); 22 | 23 | $this->assertCount(1,$role->fresh()->users); 24 | 25 | } 26 | 27 | /** @test */ 28 | public function only_an_active_relation_can_be_returned() 29 | { 30 | $role = factory(Role::class)->create(); 31 | $user = factory(User::class)->create(['role_uid' => $role->uid]); 32 | $role->delete(); 33 | 34 | $this->assertNull($user->role); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/OneToOneTest.php: -------------------------------------------------------------------------------- 1 | create(); 15 | $job = factory(Job::class)->create(['user_uid' => $user->uid]); 16 | 17 | $this->assertTrue($user->job->is($job)); 18 | $this->assertTrue($job->user->is($user)); 19 | 20 | } 21 | 22 | /** @test */ 23 | public function only_active_records_are_returned_by_has_one() 24 | { 25 | $user = factory(User::class)->create(); 26 | $job = factory(Job::class)->create(['user_uid' => $user->uid]); 27 | 28 | $this->assertTrue($user->job->is($job)); 29 | 30 | $user->job->delete(); 31 | 32 | $this->assertNull($user->fresh()->job); 33 | $this->assertTrue($user->job()->withTrashed()->get()->first()->is($job)); 34 | 35 | 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(realpath(__DIR__.'/Fixtures/database/migrations')); 18 | $this->withFactories(realpath(__DIR__.'/Fixtures/database/factories')); 19 | 20 | } 21 | 22 | /** 23 | * @param Application $app 24 | * @return array 25 | * 26 | */ 27 | protected function getPackageProviders($app) 28 | { 29 | return [VersionControlServiceProvider::class]; 30 | } 31 | 32 | /** 33 | * Set up the environment. 34 | * 35 | * @param Application $app 36 | */ 37 | protected function getEnvironmentSetUp($app) 38 | { 39 | $app['config']->set('database.default', 'sqlite'); 40 | $app['config']->set('database.connections.sqlite', [ 41 | 'driver' => 'sqlite', 42 | 'database' => ':memory:', 43 | 'prefix' => '', 44 | ]); 45 | $app['config']->set('version-control.user', User::class); 46 | $app['config']->set('version-control.default_modifying_user', 47 | ['email' => 'laravelversioncontrol@redsnapper.net']); 48 | $app['config']->set('version-control.version_model', Version::class); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/VersionControlBaseModelTest.php: -------------------------------------------------------------------------------- 1 | create([ 18 | 'email' => 'john@example.com', 19 | ]); 20 | 21 | 22 | $this->assertEquals('john@example.com',$user->email); 23 | $this->assertTrue($user->vc_active); 24 | $this->assertCount(1,$user->versions); 25 | 26 | $version = $user->versions->first(); 27 | $this->assertEquals('john@example.com',$version->email); 28 | $this->assertTrue($version->isActive()); 29 | $this->assertFalse($version->isDeleted()); 30 | 31 | $this->assertEquals($version->getKey(),$user->currentVersion->getKey()); 32 | 33 | } 34 | 35 | /** @test */ 36 | public function can_create_new_version_of_existing_record() 37 | { 38 | $user = factory(User::class)->create([ 39 | 'email' => 'john@example.com', 40 | ]); 41 | $user->email = "jane@example.com"; 42 | $user->save(); 43 | 44 | $this->assertCount(2,$user->versions); 45 | 46 | tap($user->currentVersion,function(Version $version) use($user){ 47 | $this->assertEquals('jane@example.com',$version->email); 48 | $this->assertTrue($version->isActive()); 49 | $this->assertEquals($user->password,$version->password); 50 | $this->assertEquals($version->getKey(),$user->currentVersion->getKey()); 51 | }); 52 | 53 | } 54 | 55 | /** @test */ 56 | public function saving_a_model_which_hasnt_changed_doesnt_create_a_new_version() 57 | { 58 | $user = factory(User::class)->create([ 59 | 'email' => 'john@example.com', 60 | ]); 61 | $user->save(); 62 | 63 | $this->assertCount(1,$user->versions); 64 | } 65 | 66 | /** @test */ 67 | public function can_be_deleted() 68 | { 69 | $user = factory(User::class)->create(); 70 | $user->delete(); 71 | 72 | $this->assertCount(2,$user->versions); 73 | $this->assertFalse($user->exists); 74 | $this->assertTrue($user->trashed()); 75 | 76 | tap($user->currentVersion,function(Version $version){ 77 | $this->assertTrue($version->isDeleted()); 78 | }); 79 | 80 | $this->assertNull(User::find($user->getKey())); 81 | 82 | } 83 | 84 | /** @test */ 85 | public function can_retrieve_trashed_model() 86 | { 87 | $userA = factory(User::class)->create(); 88 | $userA->delete(); 89 | $userB = factory(User::class)->create(); 90 | $this->assertCount(2,User::withTrashed()->get()); 91 | $this->assertCount(1,User::onlyTrashed()->get()); 92 | 93 | $this->assertTrue($userA->is(User::onlyTrashed()->first())); 94 | $this->assertCount(1,User::all()); 95 | } 96 | 97 | /** @test */ 98 | public function versions_are_returned_latest_first() 99 | { 100 | $user = factory(User::class)->create(['email'=>'version1@tests.com']); 101 | Carbon::setTestNow(now()->addMinute()); 102 | $user->email = "version2@tests.com"; 103 | $user->save(); 104 | 105 | $this->assertEquals('version2@tests.com', $user->versions()->first()->email); 106 | } 107 | 108 | /** @test */ 109 | public function version_latest_scope_can_be_removed() 110 | { 111 | $user = factory(User::class)->create(['email'=>'version1@tests.com']); 112 | Carbon::setTestNow(now()->addMinute()); 113 | $user->email = "version2@tests.com"; 114 | $user->save(); 115 | 116 | $this->assertEquals('version1@tests.com', 117 | $user->versions()->withoutCreatedAtOrder()->orderBy('created_at','asc')->first()->email); 118 | } 119 | 120 | /** @test */ 121 | public function can_be_restored_to_old_version() 122 | { 123 | $user = factory(User::class)->create(['email'=>'version1@tests.com']); 124 | $user->email = "version2@tests.com"; 125 | $user->save(); 126 | 127 | $version = $user->versions()->oldest()->first(); 128 | $version->restore($user); 129 | 130 | tap($user->fresh(),function(User $user) use($version){ 131 | $this->assertEquals("version1@tests.com", $user->email); 132 | $this->assertCount(3,$user->versions); 133 | $this->assertTrue($version->is($user->currentVersion->parent)); 134 | }); 135 | 136 | 137 | $user->email = "version4@redsnapper.net"; 138 | $user->save(); 139 | 140 | $user->restore($version); 141 | 142 | tap($user->fresh(),function(User $user) use($version){ 143 | $this->assertEquals("version1@tests.com", $user->email); 144 | $this->assertCount(5,$user->versions); 145 | $this->assertTrue($version->is($user->currentVersion->parent)); 146 | }); 147 | 148 | $user->email = "version6@redsnapper.net"; 149 | $user->save(); 150 | 151 | $user->restore($version->getKey()); 152 | 153 | tap($user->fresh(),function(User $user) use($version){ 154 | $this->assertEquals("version1@tests.com", $user->email); 155 | $this->assertCount(7,$user->versions); 156 | $this->assertTrue($version->is($user->currentVersion->parent)); 157 | }); 158 | 159 | } 160 | 161 | /** @test */ 162 | public function dates_on_restore_are_correct() 163 | { 164 | $oldDate = Carbon::create(2019, 1, 31); 165 | Carbon::setTestNow($oldDate); 166 | 167 | $user = factory(User::class)->create(['email'=>'version1@tests.com']); 168 | $user->email = "version2@tests.com"; 169 | $user->save(); 170 | 171 | $version = $user->versions()->oldest()->first(); 172 | 173 | $newDate = Carbon::create(2020, 1, 31); 174 | Carbon::setTestNow($newDate); 175 | $version->restore($user); 176 | 177 | $this->assertEquals($newDate,$user->currentVersion->created_at); 178 | $this->assertEquals($oldDate,$user->created_at); 179 | $this->assertEquals($newDate,$user->updated_at); 180 | } 181 | 182 | /** @test */ 183 | public function a_version_always_has_a_default_owner() 184 | { 185 | $userA = factory(User::class)->create(); 186 | 187 | $this->assertNotNull($userA->currentVersion->modifyingUser); 188 | $this->assertEquals(config('version-control.default_modifying_user')['email'], $userA->currentVersion->modifyingUser->email); 189 | } 190 | 191 | /** @test */ 192 | public function a_version_may_have_an_owner() 193 | { 194 | $userA = factory(User::class)->create(); 195 | $this->actingAs($userA); 196 | 197 | $userB = factory(User::class)->create(); 198 | 199 | $this->assertTrue($userA->is($userB->currentVersion->modifyingUser)); 200 | } 201 | 202 | 203 | /** @test */ 204 | public function can_validate_its_data() 205 | { 206 | $user = factory(User::class)->create(); 207 | $this->assertTrue($user->validateData()); 208 | 209 | $user->email ="foo"; 210 | 211 | $this->assertFalse($user->validateData()); 212 | } 213 | 214 | /** @test */ 215 | public function can_validate_its_own_version() 216 | { 217 | $user = factory(User::class)->create(); 218 | $this->assertTrue($user->validateVersion()); 219 | } 220 | 221 | /** @test */ 222 | public function cannot_delete_model_using_destroy() 223 | { 224 | $this->expectException(ReadOnlyException::class); 225 | $user = factory(User::class)->create(); 226 | $user->destroy($user->uid); 227 | } 228 | 229 | /** @test */ 230 | public function cannot_delete_model_using_truncate() 231 | { 232 | $this->expectException(ReadOnlyException::class); 233 | $user = factory(User::class)->create(); 234 | $user->truncate(); 235 | } 236 | 237 | /** @test */ 238 | public function cannot_delete_version_using_delete() 239 | { 240 | $this->expectException(ReadOnlyException::class); 241 | $user = factory(User::class)->create(); 242 | $user->currentVersion->delete(); 243 | } 244 | 245 | /** @test */ 246 | public function cannot_delete_version_using_destroy() 247 | { 248 | $this->expectException(ReadOnlyException::class); 249 | $user = factory(User::class)->create(); 250 | $version = $user->currentVersion; 251 | $version->destroy($version->uid); 252 | } 253 | 254 | /** @test */ 255 | public function cannot_delete_version_using_truncate() 256 | { 257 | $this->expectException(ReadOnlyException::class); 258 | $user = factory(User::class)->create(); 259 | $version = $user->currentVersion; 260 | $version->truncate(); 261 | } 262 | 263 | /** @test */ 264 | public function can_not_insert_on_a_model() 265 | { 266 | $this->expectException(ReadOnlyException::class); 267 | $user = new User(); 268 | $user->insert(['email'=>'foo']); 269 | } 270 | 271 | /** @test */ 272 | public function can_touch_a_model() 273 | { 274 | $oldDate = Carbon::create(2018, 1, 31); 275 | Carbon::setTestNow($oldDate); 276 | $user = factory(User::class)->create(); 277 | 278 | $newDate = Carbon::create(2019, 1, 31); 279 | Carbon::setTestNow($newDate); 280 | $user->touch(); 281 | 282 | $this->assertEquals($newDate,$user->updated_at); 283 | $this->assertEquals($oldDate,$user->created_at); 284 | $this->assertCount(2,$user->versions); 285 | $this->assertEquals($newDate,$user->currentVersion->created_at); 286 | } 287 | 288 | 289 | /** @test */ 290 | public function can_update_an_unguarded_model() 291 | { 292 | Version::unguard(); 293 | 294 | $user = factory(User::class)->create(); 295 | $user->email = "john@example.com"; 296 | $user->save(); 297 | 298 | $this->assertEquals('john@example.com',$user->currentVersion->email); 299 | 300 | } 301 | 302 | /** @test */ 303 | public function vc_active_is_a_boolean() 304 | { 305 | $user = factory(User::class)->create(); 306 | 307 | $this->assertTrue($user->fresh()->vc_active); 308 | } 309 | 310 | /** @test */ 311 | public function can_anonymize_fields() 312 | { 313 | $user = factory(User::class)->create(); 314 | $user->email = "john@example.com"; 315 | $user->save(); 316 | 317 | $user->anonymize(['email'=>$user->getKey()]); 318 | 319 | $this->assertEquals($user->getKey(), $user->email); 320 | $this->assertCount(3, $user->versions); 321 | 322 | $this->assertDatabaseMissing("user_versions", ['email' => 'john@example.com']); 323 | } 324 | 325 | } 326 | --------------------------------------------------------------------------------