├── .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 |
--------------------------------------------------------------------------------